diff options
Diffstat (limited to 'src/features/chat/ui')
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 405 | ||||
| -rw-r--r-- | src/features/chat/ui/CompactionView.svelte | 154 | ||||
| -rw-r--r-- | src/features/chat/ui/Composer.svelte | 438 | ||||
| -rw-r--r-- | src/features/chat/ui/ModelSelector.svelte | 108 | ||||
| -rw-r--r-- | src/features/chat/ui/ReasoningEffortSelector.svelte | 75 |
5 files changed, 1090 insertions, 90 deletions
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index cb6069b..e67ca5b 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -1,47 +1,368 @@ <script lang="ts"> - import type { RenderedChunk } from "../index"; + import type { TurnProviderRetryEvent } from "@dispatch/wire"; + import { groupRenderedChunks, resolveImageUrl, type RenderedChunk, viewProviderRetry } from "../index"; + import { + interleaveTurnMetrics, + viewCacheRate, + viewExpectedCache, + viewStepMetrics, + viewTurnMetrics, + type TurnMetricsEntry, + } from "../../../core/metrics"; + import { Markdown } from "../../markdown"; - let { chunks }: { chunks: readonly RenderedChunk[] } = $props(); + const badgeClass = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + } as const; + + let { + chunks, + turnMetrics = [], + hasEarlier = false, + onShowEarlier, + thinkingKeyBase = 0, + providerRetry = null, + apiBaseUrl = "", + }: { + chunks: readonly RenderedChunk[]; + turnMetrics?: readonly TurnMetricsEntry[]; + /** Earlier history is unloaded (chat limit) and can be paged back in. */ + hasEarlier?: boolean; + /** Page earlier history back in; the caller owns scroll-position preservation. */ + onShowEarlier?: () => Promise<void>; + /** + * Ordinal base for thinking-collapse keys: the count of thinking chunks + * unloaded by the chat limit, so the remaining ordinals don't shift (and + * swap collapse state) when a trim removes older thinking blocks. + */ + thinkingKeyBase?: number; + /** + * The latest `provider-retry` event for the current turn, or `null` when + * no retry is pending → renders the transient yellow "retrying…" banner. + * Never persisted (never part of the message history); coalesces to the + * newest attempt + delay, and is cleared when content resumes / turn ends. + */ + providerRetry?: TurnProviderRetryEvent | null; + /** + * The HTTP API base URL (e.g. `http://localhost:24203`). Persisted image + * chunks carry a compact relative path (`/images/<conv>/<uuid>.png`); this + * base is prepended to render them. The optimistic echo's data URL and any + * absolute URL pass through unchanged (see `resolveImageUrl`). Defaults to + * "" (root-relative — a browser resolves `/images/…` against its origin). + */ + apiBaseUrl?: string; + } = $props(); + + // True while a show-earlier page-in is awaited (disables the button). + let loadingEarlier = $state(false); + + async function showEarlier() { + if (!onShowEarlier || loadingEarlier) return; + loadingEarlier = true; + try { + await onShowEarlier(); + } finally { + loadingEarlier = false; + } + } + + const groups = $derived(groupRenderedChunks(chunks)); + + const rows = $derived(interleaveTurnMetrics(groups, turnMetrics)); + + // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that + // survives the provisional→committed (seq null → seq N) transition, so the + // collapse's open/close state is NOT lost when a turn seals. The ordinal + // starts at `thinkingKeyBase` so keys also survive a chat-limit trim removing + // older thinking blocks. (App isolates these keys per conversation via {#key}.) + const keyedRows = $derived.by(() => { + let thinking = thinkingKeyBase; + return rows.map((row, i) => { + if (row.kind === "step-metrics") { + return { row, key: `s${row.step.stepId}` }; + } + if (row.kind === "turn-metrics") { + return { row, key: `m${row.turn.turnId}` }; + } + const group = row.group; + let key: string; + if (group.kind === "tool-batch") { + key = `b${group.stepId}`; + } else if (group.chunk.chunk.type === "thinking") { + key = `think${thinking++}`; + } else if (group.chunk.seq != null) { + key = `c${group.chunk.seq}`; + } else { + key = `p${i}`; + } + return { row, key }; + }); + }); </script> -<div class="flex flex-col gap-2 p-4" role="log" aria-live="polite"> - {#each chunks as rendered, i (rendered.seq != null ? `c${rendered.seq}` : `p${i}`)} - <div class="chat {rendered.role === 'user' ? 'chat-start' : 'chat-end'}"> - <div class="chat-header text-xs opacity-70">{rendered.role}</div> - <div - class="chat-bubble" - class:chat-bubble-primary={rendered.role === "user"} - class:chat-bubble-secondary={rendered.role === "assistant"} - class:opacity-50={rendered.provisional} - > - {#if rendered.chunk.type === "text"} - <p>{rendered.chunk.text}</p> - {:else if rendered.chunk.type === "thinking"} - <details> - <summary>Thinking</summary> - <p>{rendered.chunk.text}</p> - </details> - {:else if rendered.chunk.type === "tool-call"} - <div class="text-sm"> - <strong>{rendered.chunk.toolName}</strong> - <pre class="text-xs mt-1">{JSON.stringify(rendered.chunk.input, null, 2)}</pre> - </div> - {:else if rendered.chunk.type === "tool-result"} - <div class="text-sm" class:text-error={rendered.chunk.isError}> - <strong>{rendered.chunk.toolName}</strong> - <pre class="text-xs mt-1">{rendered.chunk.content}</pre> - </div> - {:else if rendered.chunk.type === "error"} - <div class="text-error" role="alert"> - {rendered.chunk.message} - {#if rendered.chunk.code} - <span class="text-xs opacity-70">[{rendered.chunk.code}]</span> - {/if} - </div> - {:else if rendered.chunk.type === "system"} - <div class="text-sm opacity-70">{rendered.chunk.text}</div> - {/if} - </div> - </div> - {/each} +{#snippet chunkRow(rendered: RenderedChunk)} + {#if rendered.role === "user"} + <!-- User: a speech bubble, left-aligned. A user message may be multi-chunk + ([text, image, image, …]); each chunk renders in its own bubble. A + persisted image chunk's url is a compact relative path (`/images/…`) + served by the backend — resolve it against the API base. The + optimistic echo's data URL (and any absolute URL) passes through. --> + <div class="chat chat-start"> + <div class="chat-bubble chat-bubble-primary"> + {#if rendered.chunk.type === "text"} + <p>{rendered.chunk.text}</p> + {:else if rendered.chunk.type === "image"} + <img + src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)} + alt={rendered.chunk.mimeType ?? "pasted image"} + loading="lazy" + decoding="async" + class="max-h-80 max-w-full rounded" + /> + {/if} + </div> + </div> + {:else if rendered.chunk.type === "thinking"} + <!-- Thinking: a visible bubble (like tool cards), holding a checkbox collapse + (no arrow icon, smooth open/close). Title reads "Thinking" + loading dots + while generating, then "Thoughts" with no dots once complete. --> + <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> + <div class="chat-bubble w-full bg-transparent"> + <div class="collapse w-full rounded-box bg-base-200 text-sm"> + <input type="checkbox" aria-label="Toggle thoughts" /> + <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> + <span>{rendered.streaming ? "Thinking" : "Thoughts"}</span> + {#if rendered.streaming} + <span class="loading loading-dots loading-sm" aria-label="Generating"></span> + {/if} + </div> + <div class="collapse-content"> + <p class="whitespace-pre-wrap">{rendered.chunk.text}</p> + </div> + </div> + </div> + </div> + {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} + <!-- Single tool call/result: a collapsible card (collapsed by default, + like thinking). Title shows the tool name; content shows the + input/output. Same chat-start grid shim as the thinking block. --> + <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> + <div class="chat-bubble w-full bg-transparent"> + {#if rendered.chunk.type === "tool-call"} + <div class="collapse w-full rounded-box bg-base-200 text-sm"> + <input type="checkbox" aria-label="Toggle tool call" /> + <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> + <svg + class="h-4 w-4 opacity-60" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + ><path + d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" + /></svg + > + <span>{rendered.chunk.toolName}</span> + </div> + <div class="collapse-content"> + <pre class="overflow-x-auto text-xs">{JSON.stringify( + rendered.chunk.input, + null, + 2, + )}</pre> + </div> + </div> + {:else} + <div class="collapse w-full rounded-box bg-base-200 text-sm"> + <input type="checkbox" aria-label="Toggle tool result" /> + <div + class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium" + class:text-error={rendered.chunk.isError} + > + <svg + class="h-4 w-4 opacity-60" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + ><path + d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" + /></svg + > + <span>{rendered.chunk.toolName}</span> + {#if rendered.chunk.isError} + <span class="badge badge-error badge-xs">error</span> + {/if} + </div> + <div class="collapse-content"> + <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre> + </div> + </div> + {/if} + </div> + </div> + {:else} + <!-- Assistant text / system / error: an INVISIBLE speech bubble — same + chat-start grid as the user bubble, so it inherits identical left spacing. --> + <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> + <div class="chat-bubble w-full bg-transparent"> + {#if rendered.chunk.type === "text"} + <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} /> + {:else if rendered.chunk.type === "error"} + <div class="text-error" role="alert"> + {rendered.chunk.message} + {#if rendered.chunk.code} + <span class="text-xs opacity-70">[{rendered.chunk.code}]</span> + {/if} + </div> + {:else if rendered.chunk.type === "system"} + <div class="text-sm opacity-70">{rendered.chunk.text}</div> + {/if} + </div> + </div> + {/if} +{/snippet} + +<div class="flex flex-col gap-2 pt-4 pb-4 pl-6" role="log" aria-live="polite"> + {#if hasEarlier && onShowEarlier} + <!-- Chat limit: older chunks are unloaded; offer to page them back in. --> + <div class="flex justify-center"> + <button class="btn btn-ghost btn-xs" disabled={loadingEarlier} onclick={showEarlier}> + {#if loadingEarlier} + <span class="loading loading-spinner loading-xs" aria-hidden="true"></span> + Loading earlier messages… + {:else} + Show earlier messages + {/if} + </button> + </div> + {/if} + {#each keyedRows as { row, key } (key)} + {#if row.kind === "step-metrics"} + {@const sv = viewStepMetrics(row.step, row.index)} + <div class="chat chat-start"> + <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> + <div class="text-xs opacity-70"> + {sv.label} · {sv.tokensLabel} + {#if sv.tps} + · {sv.tps}{/if} + {#if sv.genTotal} + · {sv.genTotal}{/if} + </div> + </div> + </div> + {:else if row.kind === "turn-metrics"} + {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)} + {@const lastCache = viewCacheRate(row.turn.usage)} + {@const chatCache = viewCacheRate(row.cumulativeUsage)} + {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)} + <div class="chat chat-start"> + <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> + <div class="flex flex-col gap-1 text-xs"> + <div class="opacity-70"> + {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown}) + {#if turnView.tps} + · {turnView.tps}{/if} + {#if turnView.duration} + · {turnView.duration}{/if} + </div> + <div class="flex flex-wrap items-center gap-x-3 gap-y-1"> + <span class="flex items-center gap-1"> + <span class="opacity-70">Last turn:</span> + <span class="badge badge-sm {badgeClass[lastCache.level]}">{lastCache.pct}%</span> + </span> + <span class="flex items-center gap-1"> + <span class="opacity-70">Chat Total:</span> + <span class="badge badge-sm {badgeClass[chatCache.level]}">{chatCache.pct}%</span> + </span> + {#if retention} + <span class="flex items-center gap-1"> + <span class="opacity-70">Retention:</span> + <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span> + </span> + {/if} + </div> + </div> + </div> + </div> + {:else if row.group.kind === "single"} + {@render chunkRow(row.group.chunk)} + {:else} + <!-- Batched tool calls (one step): each entry is a collapsible card. + Click to expand and see the input/output. --> + <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> + <div class="chat-bubble w-full bg-transparent"> + <div class="flex flex-col gap-1"> + {#each row.group.entries as entry (entry.call.toolCallId)} + <div class="collapse w-full rounded-box bg-base-200 text-sm"> + <input type="checkbox" aria-label="Toggle tool call" /> + <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> + <svg + class="h-4 w-4 opacity-60" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + ><path + d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" + /></svg + > + <span>{entry.call.toolName}</span> + {#if entry.result?.isError} + <span class="badge badge-error badge-xs">error</span> + {:else if entry.result === null} + <span class="loading loading-spinner loading-xs" aria-label="Running"></span> + {/if} + </div> + <div class="collapse-content"> + <pre class="overflow-x-auto text-xs">{JSON.stringify( + entry.call.input, + null, + 2, + )}</pre> + {#if entry.result} + <pre + class="mt-1 max-h-96 overflow-auto text-xs" + class:text-error={entry.result.isError}>{entry.result.content}</pre> + {/if} + </div> + </div> + {/each} + </div> + </div> + </div> + {/if} + {/each} + {#if providerRetry} + {@const rv = viewProviderRetry(providerRetry)} + <!-- Transient yellow warning: a provider error is being retried with backoff. + NOT a message chunk (never persisted/replayed) — a live UI notification only, + shown where the reply would appear. Coalesces to the newest attempt + delay, + and is cleared (foldEvent) when content resumes or the turn ends. --> + <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> + <div class="chat-bubble w-full bg-transparent"> + <div class="alert alert-warning flex-wrap items-start gap-2 py-2 text-sm" role="status"> + <div class="flex flex-wrap items-center gap-2 font-medium"> + <span aria-hidden="true">⚠</span> + <span>{rv.attemptLabel} — retrying in {rv.delayLabel}…</span> + {#if rv.code} + <span class="badge badge-warning badge-sm font-mono">{rv.code}</span> + {/if} + </div> + <div class="w-full font-mono text-xs opacity-70">{rv.message}</div> + </div> + </div> + </div> + {/if} </div> diff --git a/src/features/chat/ui/CompactionView.svelte b/src/features/chat/ui/CompactionView.svelte new file mode 100644 index 0000000..5014e5c --- /dev/null +++ b/src/features/chat/ui/CompactionView.svelte @@ -0,0 +1,154 @@ +<script lang="ts"> + export type CompactNowResult = + | { readonly ok: true; readonly messagesSummarized: number; readonly messagesKept: number } + | { readonly ok: false; readonly error: string }; + + export type SaveCompactPercentResult = + | { readonly ok: true; readonly percent: number } + | { readonly ok: false; readonly error: string }; + + let { + percent, + canCompact, + compactNow, + savePercent, + }: { + /** The conversation's auto-compact percent (0-100), or null when not yet fetched. 0 = disabled. */ + percent: number | null; + /** Whether a real conversation is focused (a draft has nothing to compact). */ + canCompact: boolean; + compactNow: () => Promise<CompactNowResult | null>; + savePercent: (percent: number) => Promise<SaveCompactPercentResult | null>; + } = $props(); + + const DEFAULT_PERCENT = 85; + + let compacting = $state(false); + let compactError = $state<string | null>(null); + let compactResult = $state<{ summarized: number; kept: number } | null>(null); + + let percentInput = $state(""); + let savingPercent = $state(false); + let percentError = $state<string | null>(null); + let percentSaved = $state(false); + + // Sync the input from the prop when it changes (focus switch / initial load). + let lastPercent = $state<number | null>(null); + $effect(() => { + if (percent !== lastPercent) { + lastPercent = percent; + percentInput = percent !== null ? String(percent) : ""; + percentError = null; + percentSaved = false; + } + }); + + const percentLabel = $derived( + percent == null + ? "Loading…" + : percent === 0 + ? "Disabled (manual only)" + : percent === DEFAULT_PERCENT + ? `${percent}% (default)` + : `${percent}%`, + ); + + async function handleCompact() { + if (compacting || !canCompact) return; + compacting = true; + compactError = null; + compactResult = null; + const result = await compactNow(); + compacting = false; + if (result === null) return; + if (result.ok) { + compactResult = { summarized: result.messagesSummarized, kept: result.messagesKept }; + } else { + compactError = result.error; + } + } + + async function handleSavePercent() { + const value = Number.parseInt(percentInput, 10); + if (Number.isNaN(value) || value < 0 || value > 100) { + percentError = "Must be 0-100"; + return; + } + savingPercent = true; + percentError = null; + percentSaved = false; + const result = await savePercent(value); + savingPercent = false; + if (result === null) return; + if (result.ok) { + percentSaved = true; + } else { + percentError = result.error; + } + } +</script> + +<div class="flex flex-col gap-3"> + <!-- Manual compaction --> + <section class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Manual compaction</span> + <button + type="button" + class="btn btn-sm btn-outline" + disabled={!canCompact || compacting} + onclick={handleCompact} + > + {#if compacting} + <span class="loading loading-spinner loading-xs"></span> + Compacting… + {:else} + Compact now + {/if} + </button> + {#if !canCompact} + <p class="text-xs opacity-60">Open or start a conversation to compact its history.</p> + {:else if compactError} + <p class="text-xs text-error">{compactError}</p> + {:else if compactResult} + <p class="text-xs text-success"> + Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept. + </p> + {:else} + <p class="text-xs opacity-50"> + Summarizes old messages into a system summary + retains the most recent messages. + </p> + {/if} + </section> + + <!-- Auto-compact percent --> + <section class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Auto-compact percent</span> + <div class="flex items-center gap-2"> + <input + type="number" + class="input input-bordered input-sm w-24" + min="0" + max="100" + placeholder={String(DEFAULT_PERCENT)} + value={percentInput} + disabled={savingPercent} + onchange={handleSavePercent} + aria-label="Compact percent (0-100)" + /> + <span class="text-xs opacity-60">%</span> + {#if savingPercent} + <span class="loading loading-spinner loading-xs"></span> + {/if} + </div> + <p class="text-xs opacity-50"> + Current: {percentLabel} + <br /> + 0 disables auto-compact. Default is {DEFAULT_PERCENT}%. + </p> + {#if percentError} + <p class="text-xs text-error">{percentError}</p> + {:else if percentSaved} + <p class="text-xs text-success">Saved.</p> + {/if} + </section> +</div> diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index 3762340..04c28cd 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -1,33 +1,413 @@ <script lang="ts"> - let { onSend }: { onSend: (text: string) => void } = $props(); - - let text = $state(""); - - function handleSubmit(): void { - const trimmed = text.trim(); - if (trimmed.length === 0) return; - onSend(trimmed); - text = ""; - } - - function handleKeydown(e: KeyboardEvent): void { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - } + import type { ImageInput } from "@dispatch/wire"; + import { computeContextUsage, formatCompactTokens } from "../../../core/metrics"; + + const FALLBACK_CONTEXT_WINDOW = 1_000_000; + const MAX_LINES = 7; + /** Accept only raster images (the provider image-content formats). */ + const IMAGE_ACCEPT = "image/png,image/jpeg,image/gif,image/webp"; + /** Reject images larger than this before base64-encoding (keeps payloads sane). */ + const MAX_IMAGE_BYTES = 8 * 1024 * 1024; + + /** A staged image awaiting send: a stable id + the `ImageInput` to forward. */ + interface StagedImage { + readonly id: string; + readonly input: ImageInput; + } + + let { + onSend, + onQueue, + onStop, + contextSize = undefined, + contextWindow = undefined, + status = "idle", + }: { + /** + * Send a message (start a turn via `chat.send`). Carries any staged images + * as `ImageInput[]` (base64 data URLs or https URLs); the store forwards + * them on the WS `chat.send` op / `POST /chat` body. `images` is omitted + * (not an empty array) when none are staged, so the wire stays text-only. + */ + onSend: (text: string, images?: ImageInput[]) => void; + /** + * Enqueue a steering message (`chat.queue`). When provided AND the status + * is `running`, the send button becomes a "Queue" button that steers the + * in-flight turn instead of starting a new one. Steering is text-only — + * it never carries images (a mid-turn injection has no image surface). + */ + onQueue?: (text: string) => void; + /** Stop the in-flight generation (`POST /conversations/:id/stop`). */ + onStop?: () => void; + // Current context occupancy — updated progressively during a turn (the + // latest step's input+output) and finalized to the turn's `contextSize` on + // seal, or `undefined` when unknown — the status bar then shows + // "— tokens", never 0%. + contextSize?: number | undefined; + /** Per-model context window (max tokens) from `GET /models` modelInfo. */ + contextWindow?: number | undefined; + /** + * Coarse agent status for the status-bar icon. `queued` = the turn is in + * flight but waiting for a concurrency slot (CR-13) — shown as a loading + * RING (vs the loading DOTS of `running`/actively generating). Behaves like + * `running` for the send button (steer/stop). + */ + status?: ComposerStatus; + } = $props(); + + export type ComposerStatus = "idle" | "running" | "queued" | "error"; + + let text = $state(""); + let images = $state<StagedImage[]>([]); + let inputEl: HTMLTextAreaElement | undefined; + let fileInputEl: HTMLInputElement | undefined; + let dragOver = $state(false); + + const hasText = $derived(text.trim().length > 0); + const hasImages = $derived(images.length > 0); + const canSend = $derived(hasText || hasImages); + const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW); + const usage = $derived(computeContextUsage(contextSize, effectiveMax)); + + // One button, three modes: + // - idle → "Send" (starts a turn via chat.send) + // - running/queued + text → "Queue" (steers via chat.queue — text only) + // - running/queued + empty → "Stop" (aborts via POST /stop) + // (`queued` behaves like `running` — the turn is in flight, just waiting for a + // concurrency slot; the user can still steer or stop it.) + // Steering never carries images: when running with images staged but no text, + // the images stay staged (queue is text-only). Images-without-text while running + // is an unusual case that still sends (the server auto-starts/resolves). + const inFlight = $derived(status === "running" || status === "queued"); + const buttonMode = $derived.by<"send" | "queue" | "stop">(() => { + if (inFlight && !hasText && !hasImages && onStop !== undefined) return "stop"; + if (inFlight && hasText && onQueue !== undefined) return "queue"; + return "send"; + }); + const placeholder = $derived( + status === "queued" + ? "Queued for a slot…" + : status === "running" + ? "Steer the conversation..." + : "Type a message, paste or drop an image…", + ); + + // As the window fills, escalate color: calm → warning → danger. + function fillClass(pct: number): string { + if (pct >= 90) return "progress-error"; + if (pct >= 70) return "progress-warning"; + return "progress-success"; + } + + function resize(): void { + const el = inputEl; + if (!el) return; + el.style.height = "auto"; + const style = getComputedStyle(el); + const lineHeight = Number.parseFloat(style.lineHeight) || 20; + const paddingY = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom); + const borderY = + Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth); + const maxHeight = lineHeight * MAX_LINES + paddingY + borderY; + const next = Math.min(el.scrollHeight, maxHeight); + el.style.height = `${next}px`; + el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden"; + } + + // Re-run resize whenever the value changes (covers programmatic clears too). + $effect(() => { + void text; + resize(); + }); + + /** Read a File into a base64 data URL (`data:image/…;base64,…`). */ + function fileToDataUrl(file: File): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") resolve(reader.result); + else reject(new Error("unreadable image")); + }; + reader.onerror = () => reject(reader.error ?? new Error("read failed")); + reader.readAsDataURL(file); + }); + } + + let imgSeq = 0; + /** Stage a File as an image (skip non-images / oversized). Returns whether staged. */ + async function stageFile(file: File): Promise<boolean> { + if (!file.type.startsWith("image/")) return false; + if (file.size > MAX_IMAGE_BYTES) return false; + const url = await fileToDataUrl(file); + // Prefer the file's declared MIME; fall back to the data URL's prefix. + const mimeType = file.type || undefined; + const id = `img-${Date.now()}-${imgSeq++}`; + images = [...images, { id, input: { url, ...(mimeType ? { mimeType } : {}) } }]; + return true; + } + + function removeImage(id: string): void { + images = images.filter((img) => img.id !== id); + } + + /** Handle a paste anywhere in the form: extract image items from the clipboard. */ + async function handlePaste(e: ClipboardEvent): Promise<void> { + const items = e.clipboardData?.items; + if (items === undefined) return; + let hadImage = false; + const staged: File[] = []; + for (const item of items) { + if (item.kind === "file" && item.type.startsWith("image/")) { + const file = item.getAsFile(); + if (file !== null) { + staged.push(file); + hadImage = true; + } + } + } + if (!hadImage) return; // let the default text paste proceed + e.preventDefault(); // suppress pasting the image as a filename string + for (const file of staged) { + await stageFile(file); + } + } + + /** File-picker <input type="file"> change. */ + async function handleFilePick(e: Event): Promise<void> { + const target = e.currentTarget as HTMLInputElement; + const files = target.files; + if (files === null) return; + for (const file of files) { + await stageFile(file); + } + target.value = ""; // reset so picking the same file again re-fires change + } + + /** Drop images onto the composer. */ + async function handleDrop(e: DragEvent): Promise<void> { + dragOver = false; + const files = e.dataTransfer?.files; + if (files === undefined || files.length === 0) return; + const hadImage = Array.from(files).some((f) => f.type.startsWith("image/")); + if (!hadImage) return; + e.preventDefault(); + for (const file of files) { + await stageFile(file); + } + } + + function handleSubmit(): void { + const trimmed = text.trim(); + // Allow a send with images even when text is empty (an image-only turn). + if (trimmed.length === 0 && !hasImages) return; + if (buttonMode === "queue") { + // Steering is text-only — never forward images. + onQueue?.(trimmed); + } else { + const toSend: ImageInput[] | undefined = hasImages + ? images.map((img) => img.input) + : undefined; + onSend(trimmed, toSend); + } + text = ""; + images = []; + } + + function handleKeydown(e: KeyboardEvent): void { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + } </script> -<form class="flex gap-2 p-4" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}> - <textarea - class="textarea textarea-bordered flex-1" - bind:value={text} - onkeydown={handleKeydown} - placeholder="Type a message..." - rows="3" - aria-label="Message input" - ></textarea> - <button class="btn btn-primary" type="submit" disabled={text.trim().length === 0}> - Send - </button> +<form + class="flex flex-col" + onsubmit={(e) => { + e.preventDefault(); + handleSubmit(); + }} + ondrop={handleDrop} + ondragover={(e) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + dragOver = true; + } + }} + ondragleave={() => (dragOver = false)} +> + <!-- Top bar: expanding textarea + image-attach button + single context-aware button --> + <div class="flex items-end gap-2 px-4 pt-3 pb-2"> + <div + class="flex-1" + onpaste={handlePaste} + class:border-2={dragOver} + class:border-primary={dragOver} + class:border-dashed={dragOver} + class:rounded={dragOver} + > + <textarea + bind:this={inputEl} + class="textarea textarea-bordered w-full resize-none leading-normal !min-h-0 h-auto" + bind:value={text} + onkeydown={handleKeydown} + {placeholder} + rows="1" + aria-label="Message input"></textarea> + </div> + + <!-- Hidden file picker (images only; multiple). --> + <input + bind:this={fileInputEl} + type="file" + accept={IMAGE_ACCEPT} + multiple + class="hidden" + onchange={handleFilePick} + /> + <!-- Attach image button (opens the file picker). --> + <button + class="btn btn-ghost btn-square shrink-0" + type="button" + aria-label="Attach image" + title="Attach image" + onclick={() => fileInputEl?.click()} + > + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + class="h-5 w-5" + > + <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect> + <circle cx="8.5" cy="8.5" r="1.5"></circle> + <polyline points="21 15 16 10 5 21"></polyline> + </svg> + </button> + + {#if buttonMode === "stop"} + <button + class="btn btn-error w-20 shrink-0" + type="button" + aria-label="Stop generation" + onclick={() => onStop?.()} + > + Stop + </button> + {:else} + <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!canSend}> + {buttonMode === "queue" ? "Queue" : "Send"} + </button> + {/if} + </div> + + <!-- Staged image thumbnails (previews) with remove buttons. --> + {#if hasImages} + <div class="flex flex-wrap gap-2 px-4 pb-1"> + {#each images as img (img.id)} + <div class="group relative h-20 w-20 shrink-0 overflow-hidden rounded border border-base-300"> + <img + src={img.input.url} + alt={img.input.mimeType ?? "staged image"} + class="h-full w-full object-cover" + /> + <button + class="btn btn-circle btn-xs absolute right-0 top-0 bg-base-100/80 hover:bg-error hover:text-error-content" + type="button" + aria-label="Remove image" + onclick={() => removeImage(img.id)} + > + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="3" + stroke-linecap="round" + stroke-linejoin="round" + class="h-3 w-3" + > + <line x1="18" y1="6" x2="6" y2="18"></line> + <line x1="6" y1="6" x2="18" y2="18"></line> + </svg> + </button> + </div> + {/each} + </div> + {/if} + + <!-- Bottom status bar: status icon · context-window fill · token count --> + <div class="flex items-center gap-2 px-4 pb-2 text-xs text-base-content/50"> + <span class="shrink-0"> + {#if status === "queued"} + <!-- Waiting for a concurrency slot — a ring (vs the dots of `running`). --> + <span + class="loading loading-ring loading-xs text-primary" + aria-label="Queued" + title="Waiting for a concurrency slot" + ></span> + {:else if status === "running"} + <span class="loading loading-dots loading-xs text-primary"></span> + {:else if status === "error"} + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + class="h-4 w-4 text-error" + aria-label="Error" + > + <circle cx="12" cy="12" r="10"></circle> + <line x1="12" y1="8" x2="12" y2="12"></line> + <line x1="12" y1="16" x2="12.01" y2="16"></line> + </svg> + {:else} + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2.5" + stroke-linecap="round" + stroke-linejoin="round" + class="h-4 w-4 text-success" + aria-label="Idle" + > + <polyline points="20 6 9 17 4 12"></polyline> + </svg> + {/if} + </span> + + {#if usage.percent !== null} + <progress + class="progress h-2 flex-1 {fillClass(usage.percent)}" + value={usage.percent} + max="100" + ></progress> + {:else} + <progress class="progress h-2 flex-1 opacity-40" value="0" max="100"></progress> + {/if} + + <span class="shrink-0 whitespace-nowrap font-mono"> + {#if usage.current !== null} + {formatCompactTokens(usage.current)}{#if usage.max !== null}<span + class="text-base-content/40" + > + / {formatCompactTokens(usage.max)}</span + >{/if} + {#if usage.percent !== null} + <span class="ml-1">· {usage.percent.toFixed(1)}%</span> + {/if} + {:else} + <span class="text-base-content/40">— tokens</span> + {/if} + </span> + </div> </form> diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte index 3e25ec3..11b9feb 100644 --- a/src/features/chat/ui/ModelSelector.svelte +++ b/src/features/chat/ui/ModelSelector.svelte @@ -1,22 +1,92 @@ <script lang="ts"> - let { - models, - selected, - onSelect, - }: { - models: readonly string[]; - selected: string; - onSelect: (model: string) => void; - } = $props(); + import type { ModelMetadata } from "@dispatch/transport-contract"; + import { isVisionModel, joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select"; + + let { + models, + selected, + onSelect, + modelInfo = {}, + }: { + models: readonly string[]; + selected: string; + onSelect: (model: string) => void; + /** + * Per-model metadata from `GET /models` (`{ [name]: ModelMetadata }`). + * Used to show a "vision" badge next to models with `vision: true` (they + * natively accept images; others rely on the server's vision handoff). + * Optional — absent metadata → no badge (treated as non-vision). + */ + modelInfo?: Readonly<Record<string, ModelMetadata>>; + } = $props(); + + const keys = $derived(modelKeys(models)); + const current = $derived(splitModelName(selected)); + const keyModels = $derived(modelsForKey(models, current.key)); + + // Whether the currently-selected full model name is vision-capable. + const selectedVision = $derived(isVisionModel(modelInfo, selected)); + + // Switching key jumps to the first model available under it. + function selectKey(key: string): void { + const first = modelsForKey(models, key)[0] ?? ""; + onSelect(joinModelName(key, first)); + } + + function selectModel(model: string): void { + onSelect(joinModelName(current.key, model)); + } + + // The full `<key>/<model>` name for a model suffix under the current key. + function fullNameFor(modelSuffix: string): string { + return joinModelName(current.key, modelSuffix); + } </script> -<select - class="select" - value={selected} - onchange={(e) => onSelect(e.currentTarget.value)} - aria-label="Model selector" -> - {#each models as model (model)} - <option value={model}>{model}</option> - {/each} -</select> +<div class="flex flex-col gap-2"> + <select + class="select w-full" + value={current.key} + onchange={(e) => selectKey(e.currentTarget.value)} + aria-label="Key selector" + > + {#each keys as key (key)} + <option value={key}>{key}</option> + {/each} + </select> + <select + class="select w-full" + value={current.model} + onchange={(e) => selectModel(e.currentTarget.value)} + aria-label="Model selector" + > + {#each keyModels as model (model)} + <option value={model}> + {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if} + </option> + {/each} + </select> + {#if selectedVision} + <div class="flex items-center gap-1 text-xs text-base-content/60"> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + class="h-3.5 w-3.5" + aria-hidden="true" + > + <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> + <circle cx="12" cy="12" r="3"></circle> + </svg> + <span>Vision — this model sees images natively</span> + </div> + {:else} + <div class="text-xs text-base-content/40"> + Pasted images are auto-described (vision handoff) + </div> + {/if} +</div> diff --git a/src/features/chat/ui/ReasoningEffortSelector.svelte b/src/features/chat/ui/ReasoningEffortSelector.svelte new file mode 100644 index 0000000..d982905 --- /dev/null +++ b/src/features/chat/ui/ReasoningEffortSelector.svelte @@ -0,0 +1,75 @@ +<script lang="ts"> + import type { ReasoningEffort } from "@dispatch/transport-contract"; + import { + effectiveEffort, + effortOptions, + isReasoningEffort, + type SaveReasoningEffort, + } from "../reasoning-effort"; + + let { + persisted, + save, + }: { + /** The conversation's persisted level, or null when never set (default applies). */ + persisted: ReasoningEffort | null; + save: SaveReasoningEffort; + } = $props(); + + const options = effortOptions(); + + // The user's in-flight choice; null = mirror the (async-loaded) persisted prop. + // Re-mounted per conversation, so there is no cross-tab bleed. + let chosen = $state<ReasoningEffort | null>(null); + let saving = $state(false); + let error = $state<string | null>(null); + let justSaved = $state(false); + + const selected = $derived(chosen ?? effectiveEffort(persisted)); + + async function handleChange(value: string) { + if (!isReasoningEffort(value) || saving) return; + chosen = value; + saving = true; + error = null; + justSaved = false; + const result = await save(value); + saving = false; + if (result === null) return; + if (result.ok) { + justSaved = true; + } else { + error = result.error; + chosen = null; // revert to the persisted value + } + } +</script> + +<div class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span> + <div class="flex items-center gap-2"> + <select + class="select select-sm w-full" + value={selected} + disabled={saving} + onchange={(e) => handleChange(e.currentTarget.value)} + aria-label="Reasoning effort" + > + {#each options as option (option.value)} + <option value={option.value}>{option.label}</option> + {/each} + </select> + {#if saving} + <span class="loading loading-spinner loading-xs" aria-label="Saving reasoning effort"></span> + {/if} + </div> + {#if error} + <p class="text-xs text-error">{error}</p> + {:else if justSaved} + <p class="text-xs text-success">Saved — applies from the next turn.</p> + {:else} + <p class="text-xs opacity-50"> + How long the model thinks before answering. Changing it can re-prefill the prompt cache once. + </p> + {/if} +</div> |
