feat: add error handling, toast notifications, connection status, and retry logic (#20)
Add global error boundary, toast notification system for gRPC errors, connection status indicator in chat header, and automatic retry with exponential backoff for transient failures. Map gRPC status codes to user-friendly messages and add a Retry button on failed requests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,3 +21,4 @@
|
||||
| #17 | Audit/activity log view | COMPLETED | [issue-017.md](issue-017.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) |
|
||||
| #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';
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
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.
|
||||
@@ -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 = '/';
|
||||
|
||||
let transport: ReturnType<typeof createGrpcWebTransport> | null = null;
|
||||
@@ -49,9 +90,56 @@ function getClient(endpoint?: string) {
|
||||
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.
|
||||
*
|
||||
* 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,
|
||||
* each containing the current orchestration state, status message,
|
||||
* and optionally intermediate or final results.
|
||||
@@ -62,28 +150,48 @@ export async function* processRequest(
|
||||
sessionConfig?: SessionConfig,
|
||||
endpoint?: string
|
||||
): AsyncGenerator<ProcessRequestResponse> {
|
||||
const client = getClient(endpoint);
|
||||
|
||||
const request = create(ProcessRequestRequestSchema, {
|
||||
sessionId,
|
||||
userMessage,
|
||||
sessionConfig
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const response of client.processRequest(request)) {
|
||||
yield response;
|
||||
let lastError: OrchestratorError | null = null;
|
||||
|
||||
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) {
|
||||
// ConnectError has a `code` property
|
||||
const code = 'code' in err ? (err as { code: unknown }).code : undefined;
|
||||
throw new OrchestratorError(
|
||||
err.message,
|
||||
typeof code === 'string' ? code : 'unknown',
|
||||
err.message
|
||||
);
|
||||
|
||||
try {
|
||||
const client = getClient(endpoint);
|
||||
for await (const response of client.processRequest(request)) {
|
||||
connectionStore.reportSuccess();
|
||||
yield response;
|
||||
}
|
||||
// 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 '../app.css';
|
||||
import { themeStore } from '$lib/stores/theme.svelte';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -15,6 +16,7 @@
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
<ToastContainer />
|
||||
|
||||
<style>
|
||||
:global(.theme-transition),
|
||||
|
||||
@@ -16,15 +16,18 @@
|
||||
import SessionSidebar from '$lib/components/SessionSidebar.svelte';
|
||||
import ConfigSidebar from '$lib/components/ConfigSidebar.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 { sessionStore } from '$lib/stores/sessions.svelte';
|
||||
import { memoryStore } from '$lib/stores/memory.svelte';
|
||||
import { auditStore } from '$lib/stores/audit.svelte';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
|
||||
let messages: ChatMessage[] = $state([]);
|
||||
let isStreaming = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let lastFailedContent: string | null = $state(null);
|
||||
let orchestrationState: OrchestrationState = $state(OrchestrationState.UNSPECIFIED);
|
||||
let intermediateResult: string = $state('');
|
||||
let finalResult: SubagentResult | null = $state(null);
|
||||
@@ -79,6 +82,7 @@
|
||||
|
||||
async function handleSend(content: string) {
|
||||
error = null;
|
||||
lastFailedContent = null;
|
||||
orchestrationState = OrchestrationState.UNSPECIFIED;
|
||||
intermediateResult = '';
|
||||
finalResult = null;
|
||||
@@ -143,25 +147,37 @@
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
const msg =
|
||||
const friendlyMsg =
|
||||
err instanceof OrchestratorError
|
||||
? `Error (${err.code}): ${err.message}`
|
||||
? friendlyMessage(err.code)
|
||||
: 'An unexpected error occurred';
|
||||
error = msg;
|
||||
error = friendlyMsg;
|
||||
lastFailedContent = content;
|
||||
toastStore.addToast({ message: friendlyMsg, type: 'error' });
|
||||
auditStore.addEvent(sessionId, {
|
||||
eventType: 'error',
|
||||
details: msg
|
||||
details: friendlyMsg
|
||||
});
|
||||
const idx = messages.length - 1;
|
||||
messages[idx] = {
|
||||
...messages[idx],
|
||||
content: `\u26A0 ${msg}`
|
||||
content: `\u26A0 ${friendlyMsg}`
|
||||
};
|
||||
} finally {
|
||||
isStreaming = false;
|
||||
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>
|
||||
|
||||
<div class="flex h-screen overflow-hidden bg-white dark:bg-gray-900">
|
||||
@@ -194,6 +210,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Chat</h1>
|
||||
<ConnectionStatus />
|
||||
</div>
|
||||
<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 -->
|
||||
@@ -263,8 +280,18 @@
|
||||
{/if}
|
||||
|
||||
{#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">
|
||||
{error}
|
||||
<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">
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user