Merge pull request 'feat: error handling and connection status (#20)' (#40) from feature/issue-20-error-handling into main
This commit was merged in pull request #40.
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