Compare commits
2 Commits
284f84bd39
...
bb0eebff1b
| Author | SHA1 | Date | |
|---|---|---|---|
| bb0eebff1b | |||
|
|
14c83832f5 |
@@ -21,3 +21,4 @@
|
|||||||
| #17 | Audit/activity log view | COMPLETED | [issue-017.md](issue-017.md) |
|
| #17 | Audit/activity log view | COMPLETED | [issue-017.md](issue-017.md) |
|
||||||
| #18 | Dark/light theme toggle | COMPLETED | [issue-018.md](issue-018.md) |
|
| #18 | Dark/light theme toggle | COMPLETED | [issue-018.md](issue-018.md) |
|
||||||
| #19 | Responsive layout and mobile support | COMPLETED | [issue-019.md](issue-019.md) |
|
| #19 | Responsive layout and mobile support | COMPLETED | [issue-019.md](issue-019.md) |
|
||||||
|
| #20 | Error handling and connection status | COMPLETED | [issue-020.md](issue-020.md) |
|
||||||
|
|||||||
44
implementation-plans/issue-020.md
Normal file
44
implementation-plans/issue-020.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Issue #20 — Error handling and connection status
|
||||||
|
|
||||||
|
**Status: COMPLETED**
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Global error boundary, toast notifications for gRPC errors, connection status indicator, and retry logic with exponential backoff.
|
||||||
|
|
||||||
|
## Implemented Components
|
||||||
|
|
||||||
|
### Toast System
|
||||||
|
- **`src/lib/stores/toast.svelte.ts`** — Svelte 5 runes-based toast store with `addToast`, `removeToast`, `clear`. Auto-dismiss with configurable durations (5s for success/info, 10s for errors, 8s for warnings).
|
||||||
|
- **`src/lib/components/ToastContainer.svelte`** — Fixed bottom-right toast stack. Color-coded by type (red/amber/green/blue) with icons, dismiss buttons, and full dark mode support.
|
||||||
|
|
||||||
|
### Connection Status
|
||||||
|
- **`src/lib/stores/connection.svelte.ts`** — Tracks gRPC connection state (`connected` / `reconnecting` / `disconnected`) based on consecutive failures.
|
||||||
|
- **`src/lib/components/ConnectionStatus.svelte`** — Header indicator with colored dot (green/amber-pulse/red) and label. Dark mode support.
|
||||||
|
|
||||||
|
### Retry Logic
|
||||||
|
- **`src/lib/services/orchestrator.ts`** — Added:
|
||||||
|
- Exponential backoff retry (up to 3 retries) for transient gRPC codes (`UNAVAILABLE`, `DEADLINE_EXCEEDED`, `ABORTED`, `INTERNAL`).
|
||||||
|
- `friendlyMessage()` mapper from gRPC codes to user-friendly strings.
|
||||||
|
- Connection store integration (reports success/failure).
|
||||||
|
- Toast notifications on errors.
|
||||||
|
|
||||||
|
### Error Boundary
|
||||||
|
- **`src/routes/+error.svelte`** — Global SvelteKit error page with status code, friendly message, and "Go back" / "Try again" / "Home" buttons. Full dark mode support.
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
- **`src/routes/+layout.svelte`** — Added `ToastContainer` to global layout.
|
||||||
|
- **`src/routes/chat/+page.svelte`** — Added `ConnectionStatus` in header, "Retry" button on failed requests, toast notifications on errors, user-friendly error messages.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `src/lib/stores/toast.svelte.ts` | New |
|
||||||
|
| `src/lib/stores/connection.svelte.ts` | New |
|
||||||
|
| `src/lib/components/ToastContainer.svelte` | New |
|
||||||
|
| `src/lib/components/ConnectionStatus.svelte` | New |
|
||||||
|
| `src/routes/+error.svelte` | New |
|
||||||
|
| `src/lib/services/orchestrator.ts` | Modified — retry, friendly messages, store integration |
|
||||||
|
| `src/routes/+layout.svelte` | Modified — added ToastContainer |
|
||||||
|
| `src/routes/chat/+page.svelte` | Modified — ConnectionStatus, Retry button, toast integration |
|
||||||
|
| `implementation-plans/issue-020.md` | New |
|
||||||
|
| `implementation-plans/_index.md` | Modified |
|
||||||
28
src/lib/components/ConnectionStatus.svelte
Normal file
28
src/lib/components/ConnectionStatus.svelte
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { connectionStore } from '$lib/stores/connection.svelte';
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
connected: {
|
||||||
|
dot: 'bg-green-500',
|
||||||
|
label: 'Connected',
|
||||||
|
textColor: 'text-green-700 dark:text-green-400'
|
||||||
|
},
|
||||||
|
reconnecting: {
|
||||||
|
dot: 'bg-amber-500 animate-pulse',
|
||||||
|
label: 'Reconnecting',
|
||||||
|
textColor: 'text-amber-700 dark:text-amber-400'
|
||||||
|
},
|
||||||
|
disconnected: {
|
||||||
|
dot: 'bg-red-500',
|
||||||
|
label: 'Disconnected',
|
||||||
|
textColor: 'text-red-700 dark:text-red-400'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = $derived(statusConfig[connectionStore.status]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1.5" title="Server: {config.label}">
|
||||||
|
<span class="h-2 w-2 rounded-full {config.dot}"></span>
|
||||||
|
<span class="hidden text-xs {config.textColor} sm:inline">{config.label}</span>
|
||||||
|
</div>
|
||||||
86
src/lib/components/ToastContainer.svelte
Normal file
86
src/lib/components/ToastContainer.svelte
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { toastStore, type ToastType } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
|
const typeStyles: Record<ToastType, { bg: string; border: string; icon: string }> = {
|
||||||
|
error: {
|
||||||
|
bg: 'bg-red-50 dark:bg-red-900/40',
|
||||||
|
border: 'border-red-200 dark:border-red-800',
|
||||||
|
icon: 'text-red-500 dark:text-red-400'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: 'bg-amber-50 dark:bg-amber-900/40',
|
||||||
|
border: 'border-amber-200 dark:border-amber-800',
|
||||||
|
icon: 'text-amber-500 dark:text-amber-400'
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
bg: 'bg-green-50 dark:bg-green-900/40',
|
||||||
|
border: 'border-green-200 dark:border-green-800',
|
||||||
|
icon: 'text-green-500 dark:text-green-400'
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
bg: 'bg-blue-50 dark:bg-blue-900/40',
|
||||||
|
border: 'border-blue-200 dark:border-blue-800',
|
||||||
|
icon: 'text-blue-500 dark:text-blue-400'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeLabels: Record<ToastType, string> = {
|
||||||
|
error: 'Error',
|
||||||
|
warning: 'Warning',
|
||||||
|
success: 'Success',
|
||||||
|
info: 'Info'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if toastStore.toasts.length > 0}
|
||||||
|
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||||
|
{#each toastStore.toasts as toast (toast.id)}
|
||||||
|
{@const style = typeStyles[toast.type]}
|
||||||
|
<div
|
||||||
|
class="pointer-events-auto flex items-start gap-3 rounded-lg border px-4 py-3 shadow-lg {style.bg} {style.border}"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<!-- Icon -->
|
||||||
|
<div class="mt-0.5 shrink-0 {style.icon}">
|
||||||
|
{#if toast.type === 'error'}
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||||
|
</svg>
|
||||||
|
{:else if toast.type === 'warning'}
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
||||||
|
</svg>
|
||||||
|
{:else if toast.type === 'success'}
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-xs font-medium text-gray-900 dark:text-gray-100">{typeLabels[toast.type]}</p>
|
||||||
|
<p class="mt-0.5 text-sm text-gray-700 dark:text-gray-300">{toast.message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dismiss button -->
|
||||||
|
{#if toast.dismissable}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => toastStore.removeToast(toast.id)}
|
||||||
|
class="shrink-0 rounded-md p-1 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
} from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
|
} from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create } from '@bufbuild/protobuf';
|
||||||
import { ProcessRequestRequestSchema } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
|
import { ProcessRequestRequestSchema } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
|
||||||
|
import { connectionStore } from '$lib/stores/connection.svelte';
|
||||||
|
import { toastStore } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application-level error wrapping gRPC status codes.
|
* Application-level error wrapping gRPC status codes.
|
||||||
@@ -22,6 +24,45 @@ export class OrchestratorError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map gRPC status codes to user-friendly messages.
|
||||||
|
*/
|
||||||
|
const GRPC_USER_MESSAGES: Record<string, string> = {
|
||||||
|
unavailable: 'The server is currently unavailable. Please try again later.',
|
||||||
|
deadline_exceeded: 'The request timed out. Please try again.',
|
||||||
|
cancelled: 'The request was cancelled.',
|
||||||
|
not_found: 'The requested resource was not found.',
|
||||||
|
already_exists: 'The resource already exists.',
|
||||||
|
permission_denied: 'You do not have permission to perform this action.',
|
||||||
|
resource_exhausted: 'Rate limit reached. Please wait a moment and try again.',
|
||||||
|
failed_precondition: 'The operation cannot be performed in the current state.',
|
||||||
|
aborted: 'The operation was aborted. Please try again.',
|
||||||
|
unimplemented: 'This feature is not yet available.',
|
||||||
|
internal: 'An internal server error occurred. Please try again.',
|
||||||
|
unauthenticated: 'Authentication required. Please log in.',
|
||||||
|
data_loss: 'Data loss detected. Please contact support.',
|
||||||
|
unknown: 'An unexpected error occurred. Please try again.'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codes considered transient / retriable.
|
||||||
|
*/
|
||||||
|
const TRANSIENT_CODES = new Set(['unavailable', 'deadline_exceeded', 'aborted', 'internal']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a user-friendly message for a gRPC error code.
|
||||||
|
*/
|
||||||
|
export function friendlyMessage(code: string): string {
|
||||||
|
return GRPC_USER_MESSAGES[code.toLowerCase()] ?? GRPC_USER_MESSAGES['unknown'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the given error code is transient and should be retried.
|
||||||
|
*/
|
||||||
|
function isTransient(code: string): boolean {
|
||||||
|
return TRANSIENT_CODES.has(code.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_ENDPOINT = '/';
|
const DEFAULT_ENDPOINT = '/';
|
||||||
|
|
||||||
let transport: ReturnType<typeof createGrpcWebTransport> | null = null;
|
let transport: ReturnType<typeof createGrpcWebTransport> | null = null;
|
||||||
@@ -49,9 +90,56 @@ function getClient(endpoint?: string) {
|
|||||||
return createClient(OrchestratorService, getTransport(endpoint));
|
return createClient(OrchestratorService, getTransport(endpoint));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep for a given number of milliseconds.
|
||||||
|
*/
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate exponential backoff delay with jitter.
|
||||||
|
* Base delay doubles each attempt: 1s, 2s, 4s (capped at 8s).
|
||||||
|
*/
|
||||||
|
function backoffDelay(attempt: number): number {
|
||||||
|
const base = Math.min(1000 * Math.pow(2, attempt), 8000);
|
||||||
|
const jitter = Math.random() * base * 0.25;
|
||||||
|
return base + jitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract gRPC error code from an error, normalising to lowercase string.
|
||||||
|
*/
|
||||||
|
function extractCode(err: unknown): string {
|
||||||
|
if (err instanceof Error && 'code' in err) {
|
||||||
|
const raw = (err as { code: unknown }).code;
|
||||||
|
if (typeof raw === 'string') return raw.toLowerCase();
|
||||||
|
if (typeof raw === 'number') return String(raw);
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap an error into an OrchestratorError with a friendly message.
|
||||||
|
*/
|
||||||
|
function toOrchestratorError(err: unknown): OrchestratorError {
|
||||||
|
if (err instanceof OrchestratorError) return err;
|
||||||
|
if (err instanceof Error) {
|
||||||
|
const code = extractCode(err);
|
||||||
|
return new OrchestratorError(friendlyMessage(code), code, err.message);
|
||||||
|
}
|
||||||
|
return new OrchestratorError(friendlyMessage('unknown'), 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a request to the orchestrator and yield streaming responses.
|
* Send a request to the orchestrator and yield streaming responses.
|
||||||
*
|
*
|
||||||
|
* Includes automatic retry with exponential backoff for transient failures.
|
||||||
|
* Updates the connection status store on success or failure and fires
|
||||||
|
* toast notifications on errors.
|
||||||
|
*
|
||||||
* Returns an async iterator of `ProcessRequestResponse` messages,
|
* Returns an async iterator of `ProcessRequestResponse` messages,
|
||||||
* each containing the current orchestration state, status message,
|
* each containing the current orchestration state, status message,
|
||||||
* and optionally intermediate or final results.
|
* and optionally intermediate or final results.
|
||||||
@@ -62,28 +150,48 @@ export async function* processRequest(
|
|||||||
sessionConfig?: SessionConfig,
|
sessionConfig?: SessionConfig,
|
||||||
endpoint?: string
|
endpoint?: string
|
||||||
): AsyncGenerator<ProcessRequestResponse> {
|
): AsyncGenerator<ProcessRequestResponse> {
|
||||||
const client = getClient(endpoint);
|
|
||||||
|
|
||||||
const request = create(ProcessRequestRequestSchema, {
|
const request = create(ProcessRequestRequestSchema, {
|
||||||
sessionId,
|
sessionId,
|
||||||
userMessage,
|
userMessage,
|
||||||
sessionConfig
|
sessionConfig
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
let lastError: OrchestratorError | null = null;
|
||||||
for await (const response of client.processRequest(request)) {
|
|
||||||
yield response;
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
if (attempt > 0) {
|
||||||
|
connectionStore.setReconnecting();
|
||||||
|
const delay = backoffDelay(attempt - 1);
|
||||||
|
await sleep(delay);
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
|
||||||
if (err instanceof Error) {
|
try {
|
||||||
// ConnectError has a `code` property
|
const client = getClient(endpoint);
|
||||||
const code = 'code' in err ? (err as { code: unknown }).code : undefined;
|
for await (const response of client.processRequest(request)) {
|
||||||
throw new OrchestratorError(
|
connectionStore.reportSuccess();
|
||||||
err.message,
|
yield response;
|
||||||
typeof code === 'string' ? code : 'unknown',
|
}
|
||||||
err.message
|
// Completed successfully — no retry needed
|
||||||
);
|
return;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
lastError = toOrchestratorError(err);
|
||||||
|
const code = lastError.code;
|
||||||
|
|
||||||
|
if (isTransient(code) && attempt < MAX_RETRIES) {
|
||||||
|
// Will retry — continue loop
|
||||||
|
connectionStore.reportFailure();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-transient or exhausted retries
|
||||||
|
connectionStore.reportFailure();
|
||||||
|
toastStore.addToast({ message: lastError.message, type: 'error' });
|
||||||
|
throw lastError;
|
||||||
}
|
}
|
||||||
throw new OrchestratorError('Unknown error', 'unknown');
|
}
|
||||||
|
|
||||||
|
// Should not reach here, but guard against it
|
||||||
|
if (lastError) {
|
||||||
|
throw lastError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/lib/stores/connection.svelte.ts
Normal file
38
src/lib/stores/connection.svelte.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export type ConnectionStatus = 'connected' | 'reconnecting' | 'disconnected';
|
||||||
|
|
||||||
|
function createConnectionStore() {
|
||||||
|
let status: ConnectionStatus = $state('connected');
|
||||||
|
let consecutiveFailures = $state(0);
|
||||||
|
|
||||||
|
function reportSuccess() {
|
||||||
|
status = 'connected';
|
||||||
|
consecutiveFailures = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportFailure() {
|
||||||
|
consecutiveFailures += 1;
|
||||||
|
if (consecutiveFailures >= 3) {
|
||||||
|
status = 'disconnected';
|
||||||
|
} else {
|
||||||
|
status = 'reconnecting';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setReconnecting() {
|
||||||
|
status = 'reconnecting';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get status() {
|
||||||
|
return status;
|
||||||
|
},
|
||||||
|
get consecutiveFailures() {
|
||||||
|
return consecutiveFailures;
|
||||||
|
},
|
||||||
|
reportSuccess,
|
||||||
|
reportFailure,
|
||||||
|
setReconnecting
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const connectionStore = createConnectionStore();
|
||||||
82
src/lib/stores/toast.svelte.ts
Normal file
82
src/lib/stores/toast.svelte.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
export type ToastType = 'error' | 'warning' | 'success' | 'info';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: ToastType;
|
||||||
|
duration: number;
|
||||||
|
dismissable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DURATIONS: Record<ToastType, number> = {
|
||||||
|
success: 5000,
|
||||||
|
info: 5000,
|
||||||
|
warning: 8000,
|
||||||
|
error: 10000
|
||||||
|
};
|
||||||
|
|
||||||
|
function createToastStore() {
|
||||||
|
let toasts: Toast[] = $state([]);
|
||||||
|
// Internal bookkeeping only — not reactive state, so Map is intentional.
|
||||||
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||||
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
function addToast(
|
||||||
|
options: Omit<Toast, 'id' | 'duration' | 'dismissable'> & {
|
||||||
|
id?: string;
|
||||||
|
duration?: number;
|
||||||
|
dismissable?: boolean;
|
||||||
|
}
|
||||||
|
): string {
|
||||||
|
const id = options.id ?? crypto.randomUUID();
|
||||||
|
const duration = options.duration ?? DEFAULT_DURATIONS[options.type];
|
||||||
|
const dismissable = options.dismissable ?? true;
|
||||||
|
|
||||||
|
const toast: Toast = {
|
||||||
|
id,
|
||||||
|
message: options.message,
|
||||||
|
type: options.type,
|
||||||
|
duration,
|
||||||
|
dismissable
|
||||||
|
};
|
||||||
|
|
||||||
|
toasts = [...toasts, toast];
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
removeToast(id);
|
||||||
|
}, duration);
|
||||||
|
timers.set(id, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeToast(id: string) {
|
||||||
|
const timer = timers.get(id);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timers.delete(id);
|
||||||
|
}
|
||||||
|
toasts = toasts.filter((t) => t.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
for (const timer of timers.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
timers.clear();
|
||||||
|
toasts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get toasts() {
|
||||||
|
return toasts;
|
||||||
|
},
|
||||||
|
addToast,
|
||||||
|
removeToast,
|
||||||
|
clear
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toastStore = createToastStore();
|
||||||
54
src/routes/+error.svelte
Normal file
54
src/routes/+error.svelte
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { resolveRoute } from '$app/paths';
|
||||||
|
|
||||||
|
const homeHref = resolveRoute('/');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-white px-4 dark:bg-gray-900">
|
||||||
|
<div class="w-full max-w-md text-center">
|
||||||
|
<!-- Error icon -->
|
||||||
|
<div class="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
|
||||||
|
<svg class="h-8 w-8 text-red-500 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status code -->
|
||||||
|
<p class="text-5xl font-bold text-gray-900 dark:text-gray-100">{$page.status}</p>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<h1 class="mt-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Something went wrong
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{$page.error?.message ?? 'An unexpected error occurred. Please try again.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="mt-8 flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => history.back()}
|
||||||
|
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Go back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => location.reload()}
|
||||||
|
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
<!-- eslint-disable svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
|
||||||
|
<a
|
||||||
|
href={homeHref}
|
||||||
|
class="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { themeStore } from '$lib/stores/theme.svelte';
|
import { themeStore } from '$lib/stores/theme.svelte';
|
||||||
|
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
<ToastContainer />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:global(.theme-transition),
|
:global(.theme-transition),
|
||||||
|
|||||||
@@ -16,15 +16,18 @@
|
|||||||
import SessionSidebar from '$lib/components/SessionSidebar.svelte';
|
import SessionSidebar from '$lib/components/SessionSidebar.svelte';
|
||||||
import ConfigSidebar from '$lib/components/ConfigSidebar.svelte';
|
import ConfigSidebar from '$lib/components/ConfigSidebar.svelte';
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
import { processRequest, OrchestratorError } from '$lib/services/orchestrator';
|
import ConnectionStatus from '$lib/components/ConnectionStatus.svelte';
|
||||||
|
import { processRequest, OrchestratorError, friendlyMessage } from '$lib/services/orchestrator';
|
||||||
import { OrchestrationState } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
|
import { OrchestrationState } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
|
||||||
import { sessionStore } from '$lib/stores/sessions.svelte';
|
import { sessionStore } from '$lib/stores/sessions.svelte';
|
||||||
import { memoryStore } from '$lib/stores/memory.svelte';
|
import { memoryStore } from '$lib/stores/memory.svelte';
|
||||||
import { auditStore } from '$lib/stores/audit.svelte';
|
import { auditStore } from '$lib/stores/audit.svelte';
|
||||||
|
import { toastStore } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
let messages: ChatMessage[] = $state([]);
|
let messages: ChatMessage[] = $state([]);
|
||||||
let isStreaming = $state(false);
|
let isStreaming = $state(false);
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
|
let lastFailedContent: string | null = $state(null);
|
||||||
let orchestrationState: OrchestrationState = $state(OrchestrationState.UNSPECIFIED);
|
let orchestrationState: OrchestrationState = $state(OrchestrationState.UNSPECIFIED);
|
||||||
let intermediateResult: string = $state('');
|
let intermediateResult: string = $state('');
|
||||||
let finalResult: SubagentResult | null = $state(null);
|
let finalResult: SubagentResult | null = $state(null);
|
||||||
@@ -79,6 +82,7 @@
|
|||||||
|
|
||||||
async function handleSend(content: string) {
|
async function handleSend(content: string) {
|
||||||
error = null;
|
error = null;
|
||||||
|
lastFailedContent = null;
|
||||||
orchestrationState = OrchestrationState.UNSPECIFIED;
|
orchestrationState = OrchestrationState.UNSPECIFIED;
|
||||||
intermediateResult = '';
|
intermediateResult = '';
|
||||||
finalResult = null;
|
finalResult = null;
|
||||||
@@ -143,25 +147,37 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg =
|
const friendlyMsg =
|
||||||
err instanceof OrchestratorError
|
err instanceof OrchestratorError
|
||||||
? `Error (${err.code}): ${err.message}`
|
? friendlyMessage(err.code)
|
||||||
: 'An unexpected error occurred';
|
: 'An unexpected error occurred';
|
||||||
error = msg;
|
error = friendlyMsg;
|
||||||
|
lastFailedContent = content;
|
||||||
|
toastStore.addToast({ message: friendlyMsg, type: 'error' });
|
||||||
auditStore.addEvent(sessionId, {
|
auditStore.addEvent(sessionId, {
|
||||||
eventType: 'error',
|
eventType: 'error',
|
||||||
details: msg
|
details: friendlyMsg
|
||||||
});
|
});
|
||||||
const idx = messages.length - 1;
|
const idx = messages.length - 1;
|
||||||
messages[idx] = {
|
messages[idx] = {
|
||||||
...messages[idx],
|
...messages[idx],
|
||||||
content: `\u26A0 ${msg}`
|
content: `\u26A0 ${friendlyMsg}`
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
isStreaming = false;
|
isStreaming = false;
|
||||||
sessionStore.updateMessages(sessionId, messages);
|
sessionStore.updateMessages(sessionId, messages);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRetry() {
|
||||||
|
if (!lastFailedContent) return;
|
||||||
|
const content = lastFailedContent;
|
||||||
|
// Remove the failed assistant message before retrying
|
||||||
|
if (messages.length >= 2) {
|
||||||
|
messages = messages.slice(0, -2);
|
||||||
|
}
|
||||||
|
handleSend(content);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-screen overflow-hidden bg-white dark:bg-gray-900">
|
<div class="flex h-screen overflow-hidden bg-white dark:bg-gray-900">
|
||||||
@@ -194,6 +210,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Chat</h1>
|
<h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Chat</h1>
|
||||||
|
<ConnectionStatus />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 md:gap-2">
|
<div class="flex items-center gap-1 md:gap-2">
|
||||||
<!-- eslint-disable svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
|
<!-- eslint-disable svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
|
||||||
@@ -263,8 +280,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="mx-4 mb-2 rounded-lg bg-red-50 dark:bg-red-900/30 px-4 py-2 text-sm text-red-600 dark:text-red-400">
|
<div class="mx-4 mb-2 flex items-center justify-between gap-3 rounded-lg bg-red-50 dark:bg-red-900/30 px-4 py-2 text-sm text-red-600 dark:text-red-400">
|
||||||
{error}
|
<span>{error}</span>
|
||||||
|
{#if lastFailedContent}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleRetry}
|
||||||
|
disabled={isStreaming}
|
||||||
|
class="shrink-0 rounded-md bg-red-100 px-3 py-1 text-xs font-medium text-red-700 hover:bg-red-200 dark:bg-red-800/50 dark:text-red-300 dark:hover:bg-red-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user