From f9e125a21469566ccebd77b691e5e753d1f42000 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 25 Jul 2026 05:58:21 -0500 Subject: [PATCH] feat(webui): Wave 6A usage badge and failover timeline Make ranked free failover legible on every turn: capture a success-inclusive RouteTrace across tier skips and proxy endpoint hops, request stream usage when the proxy supports it, and measure client TTFT/total. Expand the routing chip into a keyboard-accessible disclosure with hop outcomes, skip reasons, and a free vs openrouter/free note. Add a session totals row that aggregates tokens (when exposed) and wall time. Playwright failover-timeline.spec.ts covers multi-hop expand + latency-only degrade. Wave 5 stays blocked on 6A per refreshed requirements. webui npm test: 91 passing. Playwright failover-timeline green. --- .github/workflows/deploy-pages.yml | 2 +- CONCEPTS.md | 5 + docs/assets/chat.js | 382 ++++++++++++++++-- docs/assets/shell/chat-overrides.css | 161 +++++++- ...-25-chat-ui-wave5-agent-ux-requirements.md | 76 ++-- ...ve6-transparency-discovery-requirements.md | 71 ++-- docs/chat-ui-plugins.md | 5 +- ...25-003-feat-chat-ui-wave5-agent-ux-plan.md | 6 +- ...4-feat-chat-ui-wave6a-transparency-plan.md | 40 ++ tests/e2e/failover-timeline.spec.ts | 75 ++++ tests/e2e/helpers.ts | 82 ++++ webui/shell/chat-overrides.css | 161 +++++++- webui/src/main.ts | 2 + webui/src/plugins/routing-chip/index.ts | 87 +++- webui/src/plugins/session-usage/index.ts | 74 ++++ .../session-usage/session-usage.test.ts | 55 +++ webui/src/plugins/session-usage/totals.ts | 57 +++ .../providers/FailoverProvider.tiers.test.ts | 61 +++ webui/src/providers/FailoverProvider.ts | 137 +++++-- webui/src/providers/route-trace.test.ts | 52 +++ webui/src/providers/route-trace.ts | 62 +++ webui/src/providers/routing-metadata.test.ts | 53 +++ webui/src/providers/routing-metadata.ts | 42 ++ webui/src/providers/sse.test.ts | 83 ++++ webui/src/providers/sse.ts | 66 ++- .../src/providers/tiers/orchestrator.test.ts | 61 +++ webui/src/providers/tiers/orchestrator.ts | 26 +- 27 files changed, 1839 insertions(+), 145 deletions(-) create mode 100644 docs/plans/2026-07-25-004-feat-chat-ui-wave6a-transparency-plan.md create mode 100644 tests/e2e/failover-timeline.spec.ts create mode 100644 webui/src/plugins/session-usage/index.ts create mode 100644 webui/src/plugins/session-usage/session-usage.test.ts create mode 100644 webui/src/plugins/session-usage/totals.ts create mode 100644 webui/src/providers/route-trace.test.ts create mode 100644 webui/src/providers/route-trace.ts create mode 100644 webui/src/providers/routing-metadata.test.ts create mode 100644 webui/src/providers/sse.test.ts diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 97eb277..757939a 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -127,7 +127,7 @@ jobs: run: | cd webui && npm ci && npm run build cd .. - npx playwright test tests/e2e/pages-chat.spec.ts tests/e2e/failover-dual-endpoint.spec.ts tests/e2e/model-picker.spec.ts tests/e2e/message-actions.spec.ts tests/e2e/health-panel.spec.ts tests/e2e/turnstile-gate.spec.ts tests/e2e/catalog-explorer.spec.ts tests/e2e/export-session.spec.ts tests/e2e/hash-routing.spec.ts tests/e2e/import-session.spec.ts tests/e2e/shortcuts-sheet.spec.ts tests/e2e/streaming-polish.spec.ts tests/e2e/compare-mode.spec.ts tests/e2e/vision-attach.spec.ts tests/e2e/tier-settings.spec.ts + npx playwright test tests/e2e/pages-chat.spec.ts tests/e2e/failover-dual-endpoint.spec.ts tests/e2e/model-picker.spec.ts tests/e2e/message-actions.spec.ts tests/e2e/health-panel.spec.ts tests/e2e/turnstile-gate.spec.ts tests/e2e/catalog-explorer.spec.ts tests/e2e/export-session.spec.ts tests/e2e/hash-routing.spec.ts tests/e2e/import-session.spec.ts tests/e2e/shortcuts-sheet.spec.ts tests/e2e/streaming-polish.spec.ts tests/e2e/compare-mode.spec.ts tests/e2e/vision-attach.spec.ts tests/e2e/tier-settings.spec.ts tests/e2e/failover-timeline.spec.ts - name: Run live Pages chat e2e (real proxy) env: diff --git a/CONCEPTS.md b/CONCEPTS.md index 58d9018..945ac79 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -45,6 +45,11 @@ Shared vocabulary for the static chat gateway and Python library. | **Compare mode** | Two-column chat UI: same prompt to two independently configured sources | | **Web UI provider tier** | Optional headless-browser route against free web chat UIs; requires user-run runner; off by default on public demo | | **SearXNG discovery tier** | Metasearch pass to suggest candidate free web chat URLs when API/web tiers exhaust | +| **Thinking block** | Collapsible reasoning trace in assistant messages when the active route streams a reasoning channel | +| **Tool-call card** | Structured in-message UI for streamed tool invocations with lifecycle status (display-only until a later wave adds execution) | +| **Usage badge** | Per-reply UI showing token counts (when exposed by route) plus TTFT and total latency | +| **Failover timeline** | Expanded routing view: ordered hop attempts with endpoint, model, outcome, and tier skip reasons | +| **Voice input** | Web Speech dictation into the composer on supported browsers; user sends manually | ## Learnings index diff --git a/docs/assets/chat.js b/docs/assets/chat.js index 1355df1..b82e3f6 100644 --- a/docs/assets/chat.js +++ b/docs/assets/chat.js @@ -5971,6 +5971,41 @@ function modelSupportsVision(modelId, catalog) { return entry?.supports_vision === true; } +// src/providers/route-trace.ts +var RouteTrace = class { + hops = []; + nextIndex = 0; + record(input) { + const hopIndex = input.hopIndex ?? this.nextIndex; + this.nextIndex = Math.max(this.nextIndex, hopIndex + 1); + const hop = { ...input, hopIndex }; + this.hops.push(hop); + return hop; + } + hasTier(tier) { + return this.hops.some((h) => h.tier === tier); + } + snapshot() { + return this.hops.map((h) => ({ ...h })); + } + get length() { + return this.hops.length; + } +}; +function classifyHopError(err) { + if (err && typeof err === "object" && "kind" in err && typeof err.kind === "string") { + return err.kind; + } + if (err instanceof Error) { + const msg = err.message; + if (/429|rate limit/i.test(msg)) return "rate_limit"; + if (/quota|credit|exhausted/i.test(msg)) return "quota"; + if (/503|502|cold start|unavailable/i.test(msg)) return "cold_start"; + if (/401|403|auth|turnstile/i.test(msg)) return "auth"; + } + return "error"; +} + // src/providers/routing-metadata.ts var lastCompletionMeta = null; var COMPLETION_META_EVENT = "llm-fallbacks:completion-meta"; @@ -6003,6 +6038,25 @@ function formatRoutingChip(meta) { } return parts.join(" \xB7 ") || "\u2014"; } +function formatUsageBadge(meta) { + const latencyParts = []; + if (meta.ttftMs !== void 0 && Number.isFinite(meta.ttftMs)) { + latencyParts.push(`TTFT ${Math.round(meta.ttftMs)}ms`); + } + const total = meta.totalMs ?? meta.durationMs; + if (total !== void 0 && Number.isFinite(total)) { + latencyParts.push(`${Math.round(total)}ms`); + } + const usage = meta.usage; + if (usage && (usage.promptTokens !== void 0 || usage.completionTokens !== void 0)) { + const inTok = usage.promptTokens ?? "?"; + const outTok = usage.completionTokens ?? "?"; + const tokenPart = `${inTok}\u2192${outTok} tok`; + return latencyParts.length ? `${tokenPart} \xB7 ${latencyParts.join(" \xB7 ")}` : tokenPart; + } + return latencyParts.join(" \xB7 ") || ""; +} +var FREE_ALIAS_NOTE = "`free` is our ranked quality-sorted alias; `openrouter/free` is OpenRouter's own meta-router."; // src/providers/sse.ts async function parseSSE(response, onMessage) { @@ -6035,12 +6089,30 @@ async function parseSSE(response, onMessage) { function randomId() { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } -function emitOpenAiSseAsStreamEvents(response, onEvent) { +function parseUsage(raw) { + if (!raw || typeof raw !== "object") return void 0; + const u3 = raw; + const promptTokens = typeof u3.prompt_tokens === "number" ? u3.prompt_tokens : void 0; + const completionTokens = typeof u3.completion_tokens === "number" ? u3.completion_tokens : void 0; + const totalTokens = typeof u3.total_tokens === "number" ? u3.total_tokens : void 0; + if (promptTokens === void 0 && completionTokens === void 0 && totalTokens === void 0) { + return void 0; + } + return { promptTokens, completionTokens, totalTokens }; +} +async function emitOpenAiSseAsStreamEvents(response, onEvent, startedAt = performance.now()) { let messageStarted = false; let currentMessageId = randomId(); let currentTextBlockId = null; let finishEmitted = false; - return parseSSE(response, (data) => { + let ttftMs; + let usage; + const markFirstToken = () => { + if (ttftMs === void 0) { + ttftMs = Math.max(0, performance.now() - startedAt); + } + }; + await parseSSE(response, (data) => { if (data === "[DONE]") return true; let parsed; try { @@ -6048,6 +6120,10 @@ function emitOpenAiSseAsStreamEvents(response, onEvent) { } catch { return; } + const parsedUsage = parseUsage(parsed.usage); + if (parsedUsage) { + usage = parsedUsage; + } const choice = parsed.choices?.[0]; if (!choice) return; if (!messageStarted) { @@ -6059,6 +6135,7 @@ function emitOpenAiSseAsStreamEvents(response, onEvent) { } const delta = choice.delta ?? {}; if (delta.content) { + markFirstToken(); if (!currentTextBlockId) currentTextBlockId = randomId(); onEvent({ type: "text_delta", @@ -6080,6 +6157,11 @@ function emitOpenAiSseAsStreamEvents(response, onEvent) { finishEmitted = true; } }); + return { + usage, + ttftMs, + totalMs: Math.max(0, performance.now() - startedAt) + }; } function emitTextAsStreamEvents(text, onEvent) { const messageId = randomId(); @@ -6171,8 +6253,9 @@ function formatAttemptError(err) { return String(err); } var TierOrchestrator = class { - constructor(handlers) { + constructor(handlers, trace) { this.handlers = handlers; + this.trace = trace; } async streamChat(request, onEvent) { const settings = loadProviderTierSettings(); @@ -6183,13 +6266,30 @@ var TierOrchestrator = class { if (!handler) continue; try { await handler(request, onEvent); + if (this.trace && !this.trace.hasTier(entry.id)) { + this.trace.record({ tier: entry.id, outcome: "success" }); + } return; } catch (err) { if (err instanceof TierSkipError) { attempts.push({ tier: entry.id, error: err.message }); + this.trace?.record({ + tier: entry.id, + outcome: "skip", + errorClass: "skip", + reason: err.message + }); continue; } attempts.push({ tier: entry.id, error: formatAttemptError(err) }); + if (this.trace && !this.trace.hasTier(entry.id)) { + this.trace.record({ + tier: entry.id, + outcome: "error", + errorClass: classifyHopError(err), + reason: formatAttemptError(err) + }); + } if (request.signal.aborted) throw err; } } @@ -6419,6 +6519,9 @@ var FailoverProvider = class { providerUrls = {}; statusListeners = /* @__PURE__ */ new Set(); lastRoute = ""; + /** Active per-request hop trail (Wave 6A). */ + activeTrace = null; + requestStartedAt = 0; constructor(initialConfig) { this.config = initialConfig || readRuntimeConfig(); } @@ -6489,21 +6592,42 @@ var FailoverProvider = class { let lastError = "All proxy endpoints failed"; let hopIndex = 0; let lastRateLimit; + const proxyBody = { + ...body, + // Ask proxies for a trailing usage chunk when supported (R58/R64). + stream_options: { include_usage: true } + }; for (const base of config.endpoints) { this.setStatus(`proxy: ${base} \u2026`); try { - const res = await this.chatViaProxy(base, body, config.guestToken, signal); + const res = await this.chatViaProxy(base, proxyBody, config.guestToken, signal); if (res.ok) { this.lastRoute = `proxy/${base}`; window.LLM_FALLBACKS_ROUTE = this.lastRoute; const headerMeta = readRoutingHeaders(res); + const endpoint = endpointLabel(base, res); + const timing = await emitOpenAiSseAsStreamEvents( + res, + onEvent, + this.requestStartedAt || performance.now() + ); + this.activeTrace?.record({ + tier: "proxy_failover", + endpoint, + model: headerMeta.modelHeader, + outcome: "success", + hopIndex + }); setLastCompletionMeta({ - endpoint: endpointLabel(base, res), + endpoint, modelHeader: headerMeta.modelHeader, fallbackCount: hopIndex, - durationMs: headerMeta.durationMs + durationMs: headerMeta.durationMs, + trace: this.activeTrace?.snapshot(), + usage: timing.usage, + ttftMs: timing.ttftMs, + totalMs: timing.totalMs }); - await emitOpenAiSseAsStreamEvents(res, onEvent); return; } if (!res.ok) { @@ -6512,6 +6636,18 @@ var FailoverProvider = class { const retryAfter = retryAfterHeader ?? (res.status === 429 ? parseRetryAfterFromBody(errText) : void 0); const rateScope = res.status === 429 ? parseRateLimitScopeFromBody(errText) : void 0; lastError = `${base}: HTTP ${res.status} \u2014 ${errText.slice(0, 160)}`; + const mapped = mapHttpError(res.status, errText, base, { + retryAfterSeconds: retryAfter, + scope: rateScope + }); + this.activeTrace?.record({ + tier: "proxy_failover", + endpoint: base, + outcome: "error", + errorClass: mapped.kind, + reason: mapped.message, + hopIndex + }); if (res.status === 429) { lastRateLimit = { retryAfterSeconds: retryAfter, @@ -6520,18 +6656,21 @@ var FailoverProvider = class { showRateLimitBanner(retryAfter); } if (!RETRYABLE.has(res.status)) { - throw mapHttpError(res.status, errText, base, { - retryAfterSeconds: retryAfter, - scope: rateScope - }); + throw mapped; } } } catch (err) { if (signal.aborted) throw err; - if (err instanceof ChatRouteError) { - throw err; - } + if (err instanceof ChatRouteError) throw err; lastError = `${base}: ${err instanceof Error ? err.message : String(err)}`; + this.activeTrace?.record({ + tier: "proxy_failover", + endpoint: base, + outcome: "error", + errorClass: classifyHopError(err), + reason: lastError, + hopIndex + }); } hopIndex += 1; } @@ -6580,9 +6719,13 @@ var FailoverProvider = class { }); this.lastRoute = result.route; window.LLM_FALLBACKS_ROUTE = this.lastRoute; + const totalMs = this.requestStartedAt ? Math.max(0, performance.now() - this.requestStartedAt) : void 0; setLastCompletionMeta({ endpoint: result.route, - fallbackCount: 0 + fallbackCount: 0, + trace: this.activeTrace?.snapshot(), + ttftMs: totalMs, + totalMs }); emitTextAsStreamEvents(result.content, onEvent); } @@ -6615,7 +6758,14 @@ var FailoverProvider = class { metaSet = true; this.lastRoute = `web_ui/${settings.webRunnerUrl}`; window.LLM_FALLBACKS_ROUTE = this.lastRoute; - setLastCompletionMeta({ endpoint: this.lastRoute, fallbackCount: 0 }); + const totalMs = this.requestStartedAt ? Math.max(0, performance.now() - this.requestStartedAt) : void 0; + setLastCompletionMeta({ + endpoint: this.lastRoute, + fallbackCount: 0, + trace: this.activeTrace?.snapshot(), + ttftMs: totalMs, + totalMs + }); } onEvent(event); } @@ -6646,28 +6796,50 @@ var FailoverProvider = class { ); } } - const orchestrator = new TierOrchestrator({ - qualityApi: (req, onEv) => this.streamWithCompletionTracking((inner) => this.streamQualityApiRoute(req, inner), onEv), - webUi: (req, onEv) => this.streamWithCompletionTracking((inner) => this.streamWebUiRoute(req, inner), onEv), - searxngDiscovery: (req, onEv) => this.streamWithCompletionTracking( - (inner) => this.streamSearxngDiscoveryRoute(req, inner), - onEv - ), - proxyFailover: (req, onEv) => this.streamWithCompletionTracking((inner) => this.streamProxyFailoverRoute(req, inner), onEv) - }); + this.activeTrace = new RouteTrace(); + this.requestStartedAt = performance.now(); + const orchestrator = new TierOrchestrator( + { + qualityApi: (req, onEv) => this.streamWithCompletionTracking((inner) => this.streamQualityApiRoute(req, inner), onEv), + webUi: (req, onEv) => this.streamWithCompletionTracking((inner) => this.streamWebUiRoute(req, inner), onEv), + searxngDiscovery: (req, onEv) => this.streamWithCompletionTracking( + (inner) => this.streamSearxngDiscoveryRoute(req, inner), + onEv + ), + proxyFailover: (req, onEv) => this.streamWithCompletionTracking( + (inner) => this.streamProxyFailoverRoute(req, inner), + onEv + ) + }, + this.activeTrace + ); try { await orchestrator.streamChat(request, onEvent); + const meta = getLastCompletionMeta(); + if (meta && this.activeTrace) { + setLastCompletionMeta({ ...meta, trace: this.activeTrace.snapshot() }); + } } catch (err) { if (err instanceof TierOrchestratorError) { throw this.mapTierFailure(err); } throw err; + } finally { + this.activeTrace = null; } } // Preserve the ChatRouteError taxonomy (rate-limit / quota / cold-start) while // surfacing which tiers were tried and their last error (R40). A no-attempt // failure means every tier skipped or none were enabled. mapTierFailure(err) { + if (this.activeTrace && this.activeTrace.length > 0) { + setLastCompletionMeta({ + endpoint: "\u2014", + fallbackCount: Math.max(0, this.activeTrace.length - 1), + trace: this.activeTrace.snapshot(), + totalMs: this.requestStartedAt ? Math.max(0, performance.now() - this.requestStartedAt) : void 0 + }); + } if (err.attempts.length === 0) { return mapProxyChainFailure( "No chat routes are available yet. Enable a provider tier in Settings, or wait for the demo proxy to finish deploying." @@ -7579,6 +7751,97 @@ function escapeHtml2(s) { return s.replace(/&/g, "&").replace(//g, ">"); } +// src/plugins/session-usage/totals.ts +function emptySessionTotals() { + return { + replies: 0, + repliesWithUsage: 0, + promptTokens: 0, + completionTokens: 0, + totalMs: 0 + }; +} +function accumulateSessionTotals(current, meta) { + const next = { ...current, replies: current.replies + 1 }; + const wall = meta.totalMs ?? meta.durationMs; + if (wall !== void 0 && Number.isFinite(wall)) { + next.totalMs += Math.max(0, wall); + } + const usage = meta.usage; + if (usage && (usage.promptTokens !== void 0 || usage.completionTokens !== void 0)) { + next.repliesWithUsage += 1; + next.promptTokens += usage.promptTokens ?? 0; + next.completionTokens += usage.completionTokens ?? 0; + } + return next; +} +function formatSessionTotals(totals) { + if (totals.replies === 0) return "Session: no replies yet"; + const parts = [`${totals.replies} repl${totals.replies === 1 ? "y" : "ies"}`]; + if (totals.repliesWithUsage > 0) { + const tokenLabel = totals.repliesWithUsage < totals.replies ? `${totals.promptTokens}\u2192${totals.completionTokens} tok (partial)` : `${totals.promptTokens}\u2192${totals.completionTokens} tok`; + parts.push(tokenLabel); + } + if (totals.totalMs > 0) { + parts.push(`${Math.round(totals.totalMs)}ms`); + } + return `Session: ${parts.join(" \xB7 ")}`; +} + +// src/plugins/session-usage/index.ts +function SessionUsagePlugin() { + let host = null; + let totals = emptySessionTotals(); + let sessionId = null; + let onMeta = null; + const paint = () => { + if (!host) return; + host.textContent = formatSessionTotals(totals); + host.hidden = totals.replies === 0; + }; + const reset = () => { + totals = emptySessionTotals(); + paint(); + }; + return { + name: "session-usage", + onMount(ctx) { + host = document.createElement("div"); + host.className = "lf-session-totals"; + host.setAttribute("aria-live", "polite"); + host.hidden = true; + const layout = ctx.container.querySelector(".mur-chat-layout-wrapper"); + const form = ctx.container.querySelector(".mur-chat-form-container"); + if (layout && form) { + layout.insertBefore(host, form); + } else { + ctx.container.appendChild(host); + } + sessionId = ctx.engine.state.currentSessionId; + onMeta = (event) => { + const detail = event.detail; + if (!detail) return; + totals = accumulateSessionTotals(totals, detail); + paint(); + }; + window.addEventListener(COMPLETION_META_EVENT, onMeta); + ctx.engine.subscribe( + (s) => s.currentSessionId, + (id) => { + if (id !== sessionId) { + sessionId = id; + reset(); + } + } + ); + }, + destroy() { + if (onMeta) window.removeEventListener(COMPLETION_META_EVENT, onMeta); + host?.remove(); + } + }; +} + // src/plugins/model-picker/index.ts var R11_TEXT = "`free` = our ranked chain; `openrouter/free` = OpenRouter meta-router"; var RANK_HELP_URL = "https://github.com/bodecloud/llm_fallbacks#quality-scoring"; @@ -7674,6 +7937,62 @@ function ModelPickerPlugin() { // src/plugins/routing-chip/index.ts var CHIP_CLASS = "lf-routing-chip"; +var ROOT_CLASS = "lf-routing-root"; +function escapeHtml3(text) { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} +function outcomeLabel(outcome) { + if (outcome === "success") return "ok"; + if (outcome === "skip") return "skip"; + return outcome === "error" ? "error" : outcome; +} +function hopRow(hop) { + const tier = TIER_LABELS[hop.tier] ?? hop.tier; + const host = hop.endpoint ? hostnameFromUrl(hop.endpoint) : ""; + const model = hop.model ? escapeHtml3(hop.model) : ""; + const reason = hop.reason ? escapeHtml3(hop.reason) : ""; + const parts = [ + `#${hop.hopIndex}`, + `${escapeHtml3(tier)}` + ]; + if (host) parts.push(`${escapeHtml3(host)}`); + if (model) parts.push(`${model}`); + parts.push( + `${outcomeLabel(hop.outcome)}` + ); + return ` +
  • +
    ${parts.join("")}
    + ${reason ? `

    ${reason}

    ` : ""} +
  • + `; +} +function renderChip(root, meta) { + const summary = formatRoutingChip(meta); + const badge = formatUsageBadge(meta); + const hops = meta.trace ?? []; + const panelId = `lf-routing-panel-${Math.random().toString(36).slice(2, 9)}`; + root.className = ROOT_CLASS; + root.innerHTML = ` + + + `; + const toggle = root.querySelector(`.${CHIP_CLASS}`); + const panel = root.querySelector(".lf-routing-panel"); + toggle?.addEventListener("click", () => { + if (!toggle || !panel) return; + const open = toggle.getAttribute("aria-expanded") === "true"; + toggle.setAttribute("aria-expanded", String(!open)); + panel.hidden = open; + }); +} function RoutingChipPlugin() { let engine = null; let container = null; @@ -7685,14 +8004,12 @@ function RoutingChipPlugin() { const domAssistants = container.querySelectorAll(".mur-message-assistant"); const msgEl = domAssistants[domAssistants.length - 1]; if (!msgEl) return; - let chip = msgEl.querySelector(`.${CHIP_CLASS}`); - if (!chip) { - chip = document.createElement("div"); - chip.className = CHIP_CLASS; - chip.setAttribute("aria-label", "Routing metadata"); - msgEl.appendChild(chip); + let root = msgEl.querySelector(`.${ROOT_CLASS}`); + if (!root) { + root = document.createElement("div"); + msgEl.appendChild(root); } - chip.textContent = formatRoutingChip(meta); + renderChip(root, meta); } function scheduleAttach(meta) { requestAnimationFrame(() => { @@ -8308,6 +8625,7 @@ async function bootstrap() { ModelPickerPlugin(), MessageActionsPlugin(), RoutingChipPlugin(), + SessionUsagePlugin(), StatusStripPlugin(), TurnstileGatePlugin(), FailoverSettingsPlugin({ diff --git a/docs/assets/shell/chat-overrides.css b/docs/assets/shell/chat-overrides.css index f5a1205..548bfa7 100644 --- a/docs/assets/shell/chat-overrides.css +++ b/docs/assets/shell/chat-overrides.css @@ -645,20 +645,171 @@ body.lf-chat-page::before { white-space: nowrap; } -/* ── Wave 1: routing chip ───────────────────────────────────── */ -.lf-routing-chip { - margin-top: 0.35rem; +/* ── Wave 1 / 6A: routing chip + failover timeline ───────────── */ +.lf-routing-root { + margin-top: 0.4rem; font-size: 0.68rem; letter-spacing: 0.02em; color: #8b8ba3; - opacity: 0.9; } -.lf-routing-chip::before { +.lf-routing-chip { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.55rem; + max-width: 100%; + margin: 0; + padding: 0.2rem 0.45rem; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: inherit; + font: inherit; + letter-spacing: inherit; + text-align: left; + cursor: pointer; + opacity: 0.95; +} + +.lf-routing-chip:hover { + border-color: rgba(157, 78, 221, 0.28); + background: rgba(26, 26, 46, 0.55); +} + +.lf-routing-chip:focus-visible { + outline: 2px solid #c77dff; + outline-offset: 2px; +} + +.lf-routing-summary::before { content: "↳ "; opacity: 0.65; } +.lf-usage-badge { + color: #c4c4d4; + opacity: 0.95; +} + +.lf-routing-chevron { + opacity: 0.7; + font-size: 0.75em; +} + +.lf-routing-chip[aria-expanded="true"] .lf-routing-chevron { + transform: rotate(180deg); +} + +.lf-routing-panel { + margin-top: 0.4rem; + padding: 0.55rem 0.65rem; + border: 1px solid rgba(157, 78, 221, 0.22); + border-radius: 8px; + background: rgba(18, 18, 31, 0.92); +} + +.lf-routing-panel[hidden] { + display: none !important; +} + +.lf-hop-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.lf-hop-row { + border: 1px solid rgba(157, 78, 221, 0.16); + border-radius: 6px; + padding: 0.4rem 0.5rem; + background: rgba(26, 26, 46, 0.55); +} + +.lf-hop-main { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.55rem; + align-items: center; + color: #c4c4d4; +} + +.lf-hop-index { + color: #8b8ba3; + font-variant-numeric: tabular-nums; +} + +.lf-hop-tier { + color: #e8e8ef; + font-weight: 600; +} + +.lf-hop-endpoint, +.lf-hop-model { + color: #8b8ba3; + word-break: break-all; +} + +.lf-hop-outcome { + margin-left: auto; + padding: 0.05rem 0.4rem; + border-radius: 999px; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.lf-hop-outcome-success { + color: #7dcea0; + background: rgba(125, 206, 160, 0.12); +} + +.lf-hop-outcome-skip { + color: #f0c674; + background: rgba(240, 198, 116, 0.12); +} + +.lf-hop-outcome-error { + color: #e88; + background: rgba(238, 136, 136, 0.12); +} + +.lf-hop-reason { + margin: 0.3rem 0 0; + color: #8b8ba3; + font-size: 0.72rem; +} + +.lf-alias-note { + margin: 0.55rem 0 0; + color: #8b8ba3; + font-size: 0.72rem; + line-height: 1.35; +} + +.lf-routing-empty { + margin: 0; + color: #8b8ba3; +} + +/* Wave 6A: session totals row */ +.lf-session-totals { + flex-shrink: 0; + padding: 0.35rem 0.75rem; + border-top: 1px solid rgba(157, 78, 221, 0.18); + background: rgba(18, 18, 31, 0.85); + font-size: 0.72rem; + color: #8b8ba3; + letter-spacing: 0.02em; +} + +.lf-session-totals[hidden] { + display: none !important; +} + /* ── Wave 1: hide unwired ResearchWizard chrome ─────────────── */ #voiceInputBtn, .research-output-container, diff --git a/docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md b/docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md index 77cf732..dcf6df2 100644 --- a/docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md +++ b/docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md @@ -3,34 +3,40 @@ title: Chat UI Wave 5 — agent UX trust layer date: 2026-07-25 status: confirmed priority_wave: wave5-agent-ux +refreshed: 2026-07-25 origin: docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md prior_waves: [wave1, wave2, wave3, wave4, wave4b] +blocked_by: wave6a strategy: STRATEGY.md --- # Chat UI Wave 5 — agent UX trust layer +## Delta (2026-07-25 refresh) + +Wave 4B merged. Post-ship pressure test + best-practice scan: **ranked free failover legibility (Wave 6A) is higher leverage than agent chrome.** Sequencing flip: implement **Wave 6A before Wave 5**. Wave **5A** slimmed to **reasoning + tool-call display** only; **voice moves to optional 5B** with PWA. Product requirements for reasoning/tools unchanged — only order and 5A/5B split. + ## Summary -After Wave 4B (vision, compare, omnifail tiers), close the remaining **2026 agent UX gap** on the static demo: **collapsible reasoning blocks**, **tool-call lifecycle cards**, and **composer voice input** on supported browsers. Optional follow-on slice adds **PWA installability** and offline read of local sessions. Stays static-first; no MCP marketplace, tool execution loop, or backend accounts. +After Wave **6A** (usage badge + failover timeline), close the remaining **2026 agent UX gap** on the static demo: **collapsible reasoning blocks** and **tool-call lifecycle cards**. Optional **5B** adds **composer voice input**, **PWA installability**, and offline read of local sessions. Stays static-first; no MCP marketplace, tool execution loop, or backend accounts. ## Problem Frame -Waves 1–4 delivered table-stakes chat (model picker, routing chip, export/import, streaming polish). Wave 4B addresses multimodal and compare. Visitors comparing to ChatGPT and Claude still see a **2023-class agent surface**: murm-ui already receives `reasoning_delta` and `tool_call_*` stream events, but the bundled renderer **skips reasoning** (`continue`) and shows tool calls as a one-line emoji placeholder. ResearchWizard **voice chrome exists but stays hidden** (Wave 1 R18) — which now reads as unfinished rather than intentionally unsupported. +Waves 1–4B delivered table-stakes chat plus vision, compare, and omnifail tiers. Visitors comparing to ChatGPT and Claude can still see a **2023-class agent surface**: murm-ui already receives `reasoning_delta` and `tool_call_*` stream events, but without plugins the renderer **skips reasoning** and shows tool calls as a one-line emoji placeholder. ResearchWizard **voice chrome exists but stays hidden** (Wave 1 R18). -Demand is **speculative** (no observed visitor workaround). Success is measured by demo parity with reasoning-model and agentic-chat expectations, not traffic proof. STRATEGY still anchors static Pages + edge proxies — Wave 5 is client-side trust polish, not a pivot to Open WebUI. +Demand is **speculative** (no observed visitor workaround). Success is measured by demo parity with reasoning-model and agentic-chat expectations, not traffic proof. STRATEGY still anchors static Pages + edge proxies — Wave 5 is client-side trust polish, not a pivot to Open WebUI. Free proxy routes often omit reasoning/tool channels; **BYOK or mocked streams** are the primary validation path. ## Requirements -### Reasoning / thinking blocks +### Reasoning / thinking blocks (5A) | ID | Requirement | |----|-------------| -| R44 | When the active route streams **reasoning content**, assistant messages show a **collapsed thinking header** (e.g. “Thinking…” while streaming, “Thought for Ns” when complete) with expand/collapse for the full trace. | +| R44 | When the active route streams **reasoning content**, assistant messages show a **collapsed thinking header** (e.g. “Thinking…” while streaming, “Thought for Ns” / “Thought Process” when complete) with expand/collapse for the full trace. | | R45 | When the provider **does not** expose a reasoning channel, the UI shows **nothing** — no empty placeholders or fake thinking states. | | R46 | The **final answer text** remains visually primary; reasoning uses muted styling and sits above or beside the answer without displacing it. | -### Tool-call display (read-only v1) +### Tool-call display (read-only, 5A) | ID | Requirement | |----|-------------| @@ -38,7 +44,7 @@ Demand is **speculative** (no observed visitor workaround). Success is measured | R48 | Wave 5 is **display-only** — the public demo does not execute tools client-side or round-trip tool results unless a later wave explicitly adds it. | | R49 | Each card shows **tool name**, **status**, and an **expandable args summary**; raw JSON is available on expand, not the default view. | -### Voice input +### Voice input (optional 5B — with PWA) | ID | Requirement | |----|-------------| @@ -46,7 +52,7 @@ Demand is **speculative** (no observed visitor workaround). Success is measured | R51 | Unsupported browsers show a **disabled mic** with tooltip copy explaining the limitation — not a hidden or broken button. | | R52 | Voice **fills the composer only**; the user reviews and sends manually (no auto-send on end-of-speech in v1). | -### PWA shell (optional Wave 5B slice) +### PWA shell (optional 5B) | ID | Requirement | |----|-------------| @@ -63,23 +69,25 @@ Demand is **speculative** (no observed visitor workaround). Success is measured ## Approaches considered -### A. Agent UX trust layer (recommended) +### A. Agent UX after failover education (recommended) -Ship R44–R52 in one wave: murm-ui plugins + SSE mapping extensions so reasoning and tool events reach the renderer; unhide and wire `#voiceInputBtn`. **Pros:** Highest perception ROI vs carrying cost; reuses existing stream plumbing and shell CSS. **Cons:** Provider-dependent reasoning visibility on free routes. +Ship Wave **6A** first, then **5A** (R44–R49, R56): wire murm-ui `ThinkingPlugin` / `ToolsPlugin` and extend SSE mapping so reasoning and tool events reach the renderer. **Pros:** Identity bet (ranked failover) lands before parity chrome; reuses existing stream plumbing. **Cons:** Agent-surface parity waits one wave. -### B. PWA-first +### B. Keep 5A before 6A (superseded) -Prioritize R53–R55 before reasoning/tools/voice. **Pros:** Repeat-visitor retention, installable icon. **Cons:** Does not fix the “feels like 2023 agent chat” comparison; SW build pipeline adds CI surface before visible UX win. +Ship reasoning/tools/voice before transparency. **Pros:** Faster ChatGPT-class surface. **Cons:** Does not teach why this gateway exists; voice hygiene dilutes the slice. ### C. MCP client panel first -Ship external MCP connect + tool list before display polish. **Pros:** Power-user agent gateway story. **Cons:** CORS/auth complexity, conflicts with “ranked free model demo” positioning; high trust surface for a speculative need. +Ship external MCP connect + tool list before display polish. **Pros:** Power-user agent gateway story. **Cons:** Conflicts with STRATEGY “display/routing-first”; high trust surface. -**Recommendation:** **A**, then **B** as a separate PR-sized slice (5B). Defer **C** until product explicitly pivots toward agent gateway. **Extend** existing murm-ui block rendering and `FailoverProvider` SSE mapping — net-new plugins, not a fork. +**Recommendation:** **A**. Optional **5B** (voice R50–R52 + PWA R53–R55) after 5A. Defer **C** until product explicitly pivots toward agent gateway. ## Scope boundaries -**In scope:** `webui/`, murm-ui plugin(s), SSE event forwarding, shell voice wiring, optional SW in build pipeline, Playwright e2e with mocks, `docs/chat-ui-plugins.md`, `CONCEPTS.md`. +**In scope (5A):** `webui/` murm-ui thinking/tools plugins, SSE event forwarding, dark-shell CSS overrides, Playwright e2e with mocked reasoning/tool streams, `docs/chat-ui-plugins.md`, `CONCEPTS.md`. + +**In scope (5B, optional):** shell voice wiring, service worker in build pipeline. **Deferred for later:** @@ -87,8 +95,8 @@ Ship external MCP connect + tool list before display polish. **Pros:** Power-use - MCP client settings panel and remote tool invocation - TTS / read-aloud replies, wake word, MediaRecorder + paid STT - Generative UI / iframe widget runtime for tool results -- More than two compare columns (Wave 4B scope) -- Vision through web-UI tier (Wave 4B deferral) +- More than two compare columns +- Vision through web-UI tier **Outside this product's identity (STRATEGY):** @@ -98,11 +106,10 @@ Ship external MCP connect + tool list before display polish. **Pros:** Power-use ## Success criteria -- On a BYOK or proxy route that streams reasoning deltas, user sees collapsed thinking with working expand/collapse and a visible final answer. +- After 6A ships: on a BYOK or mocked route that streams reasoning deltas, user sees collapsed thinking with working expand/collapse and a visible final answer. - On a mocked tool-call stream, user sees lifecycle cards with status transitions — not the emoji placeholder. -- On Chrome, mic dictation fills `#chatinput`; on Safari/Firefox, mic is disabled with explanatory tooltip. -- Zero-config text-only chat on the public homepage is unchanged when no reasoning/tools/voice features activate. -- (5B) With SW registered, repeat visit loads from cache; offline mode shows history read-only and blocks send with clear copy. +- Zero-config text-only chat on the public homepage is unchanged when no reasoning/tools features activate. +- (5B) On Chrome, mic dictation fills the composer; on unsupported browsers, mic is disabled with tooltip. With SW registered, offline mode shows history read-only and blocks send with clear copy. ## Key decisions @@ -110,17 +117,18 @@ Ship external MCP connect + tool list before display polish. **Pros:** Power-use |----|----------|-----------| | K9 | **Display-only tool cards in v1** | Agentic credibility without Worker tool execution or abuse surface | | K10 | **Reasoning is provider-gated** | Matches 2026 norm; avoids fake thinking on models without a channel | -| K11 | **Voice = composer dictation, no TTS** | Reuses hidden shell affordance; legacy `docs/legacy/chatgpt-web/` patterns inform UX, not wholesale port | -| K12 | **PWA as optional 5B slice** | Installability is medium value; agent UX polish is higher leverage for “not simplistic” perception | -| K13 | **MCP deferred** | Speculative power-user need; CORS and auth cost disproportionate to demo thesis | -| K14 | **Speculative demand recorded** | No visitor evidence yet; ship for ChatGPT-class parity after Wave 4B lands | +| K11 | **Voice deferred to 5B with PWA** | Hygiene vs identity; unhide shell mic after failover story ships | +| K12 | **PWA remains optional 5B** | Installability is medium value; agent display is higher leverage than SW | +| K13 | **MCP deferred** | Speculative power-user need; conflicts with display/routing-first | +| K14 | **Speculative demand recorded** | No visitor evidence; ship for ChatGPT-class parity after transparency | +| K22 | **Wave 6A before Wave 5** | STRATEGY: make ranked free failover tangible before agent chrome | ## Dependencies and assumptions -- **Wave 4B merged** before Wave 5 **implementation** starts — avoids parallel large diffs on `FailoverProvider` and composer. (Planning for Wave 5 may proceed earlier.) -- murm-ui stream event types for `reasoning_delta` and `tool_call_*` already exist in the bundled renderer; `webui/src/providers/sse.ts` currently forwards **text only** — planning must extend mapping (verified in repo). -- Free proxy routes may not emit reasoning or tool streams today; BYOK paths are the primary validation target for R44–R49. -- Web Speech API availability is Chromium-skewed; graceful degrade is mandatory. +- **Wave 6A merged** before Wave 5 **implementation** starts (Wave 4B already on `main`). +- murm-ui ships `ThinkingPlugin` and `ToolsPlugin`; default renderer skips reasoning without them. `webui/src/providers/sse.ts` currently forwards **text only** — planning must extend mapping (verified in repo). +- Free proxy routes may not emit reasoning or tool streams today; BYOK or mocked e2e is the primary validation target for R44–R49. +- Web Speech API availability is Chromium-skewed; graceful degrade is mandatory for 5B. - `docs/manifest.json` exists; no service worker today (verified). ## Outstanding questions @@ -129,7 +137,7 @@ Ship external MCP connect + tool list before display polish. **Pros:** Power-use |----|----------|---------------------| | Q7 | Reasoning default: collapsed always vs remember user expand preference? | Collapsed always; session-persist expand is follow-up | | Q8 | Tool card placement: inline in message vs grouped timeline? | Inline above answer text, matching reasoning block | -| Q9 | PWA: ship in same release as 5A or separate 5B PR? | Separate 5B PR after 5A merges | +| Q9 | Voice+PWA: same 5B PR or split? | Same 5B PR after 5A merges | ## Research references @@ -137,6 +145,8 @@ Ship external MCP connect + tool list before display polish. **Pros:** Power-use - [TanStack AI — thinking content](https://tanstack.com/ai/latest/docs/chat/thinking-content) — UI-only reasoning, progressive disclosure - [UX/UI Principles — tool-use UX](https://uxuiprinciples.com/en/principles/tool-use-function-calling-ux) — show activity, human-in-the-loop norms - [MDN — PWA offline operation](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Offline_and_background_operation) — SW + IndexedDB patterns -- Prior: `docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md` (deferred tool/reasoning, PWA) -- Prior: `docs/brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md` (Wave 4B out-of-scope list) -- Repo: `docs/assets/chat.js` (murm-ui reasoning/tool render hooks), `docs/legacy/chatgpt-web/` (prior voice UX), `webui/shell/styles.css` (`#voiceInputBtn` styles) +- [Digestible UX — reasoning UX comparison](https://www.digestibleux.com/p/how-ai-models-show-their-reasoning) — collapse by default +- Prior: `docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md` +- Prior: `docs/brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md` +- Prior: `docs/brainstorms/2026-07-25-chat-ui-wave6-transparency-discovery-requirements.md` (ships before this wave) +- Repo: murm-ui `ThinkingPlugin` / `ToolsPlugin`, `webui/src/providers/sse.ts`, `webui/shell/styles.css` (`#voiceInputBtn`) diff --git a/docs/brainstorms/2026-07-25-chat-ui-wave6-transparency-discovery-requirements.md b/docs/brainstorms/2026-07-25-chat-ui-wave6-transparency-discovery-requirements.md index 893a0de..01dcd22 100644 --- a/docs/brainstorms/2026-07-25-chat-ui-wave6-transparency-discovery-requirements.md +++ b/docs/brainstorms/2026-07-25-chat-ui-wave6-transparency-discovery-requirements.md @@ -3,26 +3,32 @@ title: Chat UI Wave 6 — transparency, branching, and discovery date: 2026-07-25 status: confirmed priority_wave: wave6-transparency-discovery -origin: user brainstorm — what to improve after Waves 1–5 -prior_waves: [wave1, wave2, wave3, wave4, wave4b, wave5] +next_slice: wave6a +refreshed: 2026-07-25 +origin: user brainstorm — what to improve after Waves 1–4B +prior_waves: [wave1, wave2, wave3, wave4, wave4b] strategy: STRATEGY.md --- # Chat UI Wave 6 — transparency, branching, and discovery +## Delta (2026-07-25 refresh) + +Wave 4B merged. Sequencing flip vs original “after Wave 5” framing: **Wave 6A (usage + failover timeline) is the next implementation slice**, before Wave 5 agent UX. Full Wave 6 still covers branching, templates, share/embed, and optional analytics/artifacts — but **6A alone** is the identity bet and unblocks Wave 5. + ## Summary -After Wave 5 (agent UX trust layer), make the static demo **teach and retain** — not just chat. Wave 6 adds **per-reply usage and latency**, a **failover timeline** that explains ranked routing, **conversation branching**, a **prompt template library**, and **read-only share/embed** modes. Optional slice adds a **session analytics drawer** and **read-only artifact pane**. Stays static-first; no accounts, MCP execution, or cloud session sync. +Make the static demo **teach and retain** — not just chat. **Wave 6A** (next) adds **per-reply usage and latency** and a **failover timeline** that explains ranked routing using Wave 4B tier metadata. Later slices add **conversation branching**, a **prompt template library**, and **read-only share/embed**. Optional **6C** adds a **session analytics drawer** and **read-only artifact pane**. Stays static-first; no accounts, MCP execution, or cloud session sync. ## Problem Frame -Waves 1–5 close the ChatGPT-class baseline: picker, routing chip, export/import, vision/compare, omnifail tiers, reasoning/tools display, voice, and optional PWA. Visitors still leave without understanding **why this project exists** — the ranked `free` alias, fallback hops, and quality scoring are buried in README copy. Meanwhile 2026 chat products treat **branching**, **prompt libraries**, and **usage transparency** as table stakes for power users. +Waves 1–4B closed multimodal and omnifail routing. Visitors still leave without understanding **why this project exists** — the ranked `free` alias, fallback hops, and quality scoring are buried in README copy. Meanwhile 2026 chat products treat **usage transparency** and **explainable routing** as table stakes. Agent chrome (reasoning/tool cards) matters for parity but does not own this product’s wedge. -Demand remains **speculative** (no visitor evidence). Success is measured by demo differentiation (failover story visible in-product) and power-user parity, not traffic proof. STRATEGY still anchors Python artifacts + edge proxies — Wave 6 is client-side education and local-session depth, not a pivot to Open WebUI. +Demand remains **speculative** (no visitor evidence). Success is measured by demo differentiation (failover story visible in-product) and power-user depth, not traffic proof. STRATEGY still anchors Python artifacts + edge proxies — Wave 6 is client-side education and local-session depth, not a pivot to Open WebUI. ## Requirements -### Usage and latency transparency +### Usage and latency transparency (6A) | ID | Requirement | |----|-------------| @@ -30,7 +36,7 @@ Demand remains **speculative** (no visitor evidence). Success is measured by dem | R59 | When token counts are unavailable, the badge shows **latency only** — no fabricated token estimates. | | R60 | A **session totals row** (footer or drawer header) aggregates tokens and wall time for the active session. | -### Failover timeline and routing education +### Failover timeline and routing education (6A) | ID | Requirement | |----|-------------| @@ -39,7 +45,7 @@ Demand remains **speculative** (no visitor evidence). Success is measured by dem | R63 | When omnifail tiers (Wave 4B) are active, the timeline includes **tier name** and skip reason for disabled or exhausted tiers. | | R64 | Timeline data comes from **response metadata already available** to the client; Wave 6 does not add new proxy logging backends. | -### Conversation branching +### Conversation branching (6B) | ID | Requirement | |----|-------------| @@ -48,7 +54,7 @@ Demand remains **speculative** (no visitor evidence). Success is measured by dem | R67 | Export/import (Wave 3) includes branch structure or exports the **active branch only** with clear labeling — planning picks one behavior and documents it. | | R68 | Branching is **IndexedDB-only**; no server merge or cross-device sync. | -### Prompt template library +### Prompt template library (6B or late 6A) | ID | Requirement | |----|-------------| @@ -57,7 +63,7 @@ Demand remains **speculative** (no visitor evidence). Success is measured by dem | R71 | Users can **save custom templates** to local storage; bundled templates are read-only. | | R72 | Templates respect the active model and route capabilities (e.g., vision template disabled when route lacks vision). | -### Share and embed (static-safe) +### Share and embed (static-safe) (6B) | ID | Requirement | |----|-------------| @@ -82,7 +88,7 @@ Demand remains **speculative** (no visitor evidence). Success is measured by dem | R81 | Preview is **display-only** — no script execution beyond sandboxed iframe rules; no tool round-trip. | | R82 | Artifact pane degrades to **code view only** when content type is unsupported or sandbox blocks render. | -### Mobile shell polish +### Mobile shell polish (after 6A core) | ID | Requirement | |----|-------------| @@ -90,7 +96,7 @@ Demand remains **speculative** (no visitor evidence). Success is measured by dem | R84 | Composer controls (attach, mic, send) sit in the **thumb zone** with minimum 44px touch targets. | | R85 | Streaming messages do not trap scroll; **sticky composer** remains visible without obscuring the last reply. | -### Streaming accessibility+ +### Streaming accessibility (6A for timeline/badge; rest with polish) | ID | Requirement | |----|-------------| @@ -100,31 +106,33 @@ Demand remains **speculative** (no visitor evidence). Success is measured by dem ## Approaches considered -### A. Transparency-first (recommended) +### A. Transparency-first 6A, then depth (recommended) -Ship R58–R64, R69–R72, and R83–R88 in one wave: make failover and usage visible, add templates and mobile/a11y polish. **Pros:** Highest alignment with STRATEGY messaging; low server cost; compounds routing chip from Wave 1. **Cons:** Less viral than share features alone. +Ship **R58–R64 + R88** as Wave **6A** immediately after Wave 4B — make failover and usage visible before Wave 5 agent chrome. Then Wave 5A, then **6B** (branching + share + templates) and optional **6C**. **Pros:** Highest STRATEGY alignment; compounds routing chip; low server cost. **Cons:** Branching/share wait longer. ### B. Branching and share-first -Prioritize R65–R76 before usage timeline. **Pros:** Matches ChatGPT Sept 2025 branching; share links aid distribution. **Pros:** Viral/demo growth angle. **Cons:** Does not explain *why* this gateway exists; branch graph adds IndexedDB migration risk before core story is visible. +Prioritize R65–R76 before usage timeline. **Pros:** Viral/demo growth angle. **Cons:** Does not explain *why* this gateway exists. ### C. Analytics and artifacts panel -Prioritize R77–R82 before branching. **Pros:** Power-user delight; artifact pane feels “agentic” without execution loop. **Cons:** Higher UI surface; artifacts less differentiated than failover education on a failover-focused product. +Prioritize R77–R82 before branching. **Pros:** Power-user delight. **Cons:** Less differentiated than failover education. -**Recommendation:** **A** as Wave 6A, then **B** (branching + share) as 6B if scope needs splitting. **C** (analytics + artifacts) as optional 6C slice — ship only if 6A/6B land under budget. **Extend** routing chip, session storage, and static template artifacts — no new backend services. +**Recommendation:** **A**. **6A** is the next ship slice. Templates (R69–R72) may ride with 6B unless planning finds them trivial to attach to 6A. Mobile polish (R83–R85) and remaining a11y (R86–R87) attach to whichever UI slice touches those surfaces. ## Scope boundaries -**In scope:** `webui/`, static template JSON/markdown in `docs/` or `webui/`, IndexedDB schema extensions for branches, optional Worker header passthrough for token counts (if not already exposed), Playwright e2e, `CONCEPTS.md`, `docs/chat-ui-plugins.md`. +**In scope (6A):** usage badge, failover timeline expansion of routing chip, client metadata plumbing, keyboard reachability for badge/timeline, Playwright mocks, `CONCEPTS.md`, `docs/chat-ui-plugins.md`. + +**In scope (6B+):** branching IndexedDB, templates, share/embed, mobile bottom sheets, session analytics, artifact pane. **Deferred for later:** - MCP client panel and **tool execution loop** (Wave 5 display-only remains the ceiling until product pivot) - TTS, wake word, MediaRecorder + paid STT -- Full **generative UI widget runtime** (interactive tool results, not sandboxed preview) -- **Cloud-hosted share** with KV persistence and TTL (v1 share is client-encoded or download) -- Text/PDF document attach beyond images (high cost, uneven free-model support) +- Full **generative UI widget runtime** +- **Cloud-hosted share** with KV persistence and TTL +- Text/PDF document attach beyond images - Session folders/tags across devices - More than two compare columns @@ -137,13 +145,19 @@ Prioritize R77–R82 before branching. **Pros:** Power-user delight; artifact pa ## Success criteria -- A first-time visitor expands a reply and sees **which models were tried**, why fallback occurred, and how `free` differs from `openrouter/free`. +### 6A (next) + +- A first-time visitor expands a reply and sees **which models/endpoints were tried**, why fallback or tier skip occurred, and how `free` differs from `openrouter/free`. - Usage badge shows real token counts on at least one BYOK/proxy route; latency-only degrade on routes without usage metadata. +- Timeline and badge are keyboard reachable. + +### Later slices + - User branches from a mid-conversation message, switches branch in sidebar, and exports without data loss on the chosen export semantics. - Template picker inserts a compare-models prompt with variables filled; custom template persists across reload. - Share link or HTML snapshot opens read-only with secrets stripped; `?embed=1` loads in an iframe without layout breakage. - Mobile viewport: model picker opens as bottom sheet; composer remains usable one-handed. -- Screen reader hears start/complete announcements; failover timeline is keyboard navigable. +- Screen reader hears start/complete announcements. ## Key decisions @@ -155,12 +169,13 @@ Prioritize R77–R82 before branching. **Pros:** Power-user delight; artifact pa | K18 | **Templates from static artifacts** | Preserves Python-as-brain; no live template CMS | | K19 | **Artifact pane is sandboxed preview only** | Agentic feel without deferred tool execution | | K20 | **6B/6C are splittable** | Keeps 6A shippable if branch graph or analytics overrun estimate | -| K21 | **Speculative demand recorded** | No visitor evidence; ship for differentiation after Wave 5 | +| K21 | **6A before Wave 5** | Identity wedge before agent chrome; Wave 4B metadata already available | +| K23 | **Speculative demand recorded** | No visitor evidence; ship for differentiation | ## Dependencies and assumptions -- **Wave 5 merged** before Wave 6 — avoids parallel composer and SSE mapper conflicts. -- Wave 4B routing metadata (R6–R7) supplies hop timeline inputs for Wave 6A. Tier names in the timeline (R63) land when the Wave 4B orchestrator exists; do not block 6A on web/SearXNG tiers. +- **Wave 4B merged** — orchestrator attempts and routing metadata supply timeline inputs (R63). Do not block 6A on web/SearXNG tiers being configured. +- **Wave 5 does not block 6A.** Wave 5 implementation waits on 6A. - Token usage requires provider/proxy to expose counts in response or stream trailers; not all free routes will qualify (R59 degrade path). - Branching may require murm-ui session graph support or a thin wrapper — planning verifies without prescribing architecture here. - Share URL length limits may cap very large sessions; planning defines max snapshot size or falls back to download-only. @@ -173,13 +188,15 @@ Prioritize R77–R82 before branching. **Pros:** Power-user delight; artifact pa | Q11 | Share: compressed hash URL vs downloadable HTML file? | Hash URL under size cap; HTML download fallback | | Q12 | Session analytics in 6A or 6C? | 6C optional slice after 6A | | Q13 | Mobile bottom sheet: replace all slide panels or chat-specific only? | Chat-adjacent panels first (model picker, analytics) | +| Q14 | Templates with 6A or 6B? | 6B unless planning finds them trivial | ## Research references - [UX/UI Principles — AI cost transparency](https://uxuiprinciples.com/en/principles/ai-cost-transparency) — usage-at-action-time norms - [OpenRouter — reliability failover](https://openrouter.ai/blog/insights/reliability-failover/) — hop transparency patterns +- [Portkey — failover routing strategies](https://portkey.ai/blog/failover-routing-strategies-for-llms-in-production) — log served model - [UIPotion — AI response rendering](https://uipotion.com/potions/patterns/ai-response-rendering) — streaming a11y lifecycle - [NN/G — bottom sheets](https://www.nngroup.com/articles/bottom-sheet/) — mobile overlay UX - [Ars Technica — ChatGPT branching (Sept 2025)](https://arstechnica.com/ai/2025/09/chatgpts-new-branching-feature-is-a-good-reminder-that-ai-chatbots-arent-people/) — branch prior art -- Prior: `docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md` (deferred MCP, execution) +- Prior: `docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md` (after 6A) - Prior: `docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md` (R10–R12 catalog education) diff --git a/docs/chat-ui-plugins.md b/docs/chat-ui-plugins.md index 58e8f23..15c0f65 100644 --- a/docs/chat-ui-plugins.md +++ b/docs/chat-ui-plugins.md @@ -33,7 +33,8 @@ Set `APP_VERSION` when building for cache busting (CI sets this from `github.sha | `discovery-picklist` | Above composer | SearXNG-discovered free chat links (manual open only, dismissible) | | `model-explorer` | Models | Filter/sort `free_models.json`; **Use for chat** sets session model | | `model-picker` | Composer | Dropdown for `free`, `openrouter/free`, and top catalog models | -| `routing-chip` | Messages | Endpoint / model / fallback metadata under assistant replies | +| `routing-chip` | Messages | Expandable failover timeline + usage/latency badge under assistant replies | +| `session-usage` | Above composer | Client-side session totals (tokens when exposed + wall time) | | `message-actions` | Messages | Regenerate, edit user message; stop preserves partial output when present | | `status-strip` | Top bar | Proxy liveness dot + optional daily chat count from `/v1/metrics` | | `turnstile-gate` | Body (optional) | Cloudflare Turnstile widget when `turnstileSiteKey` is in config | @@ -45,6 +46,8 @@ Wave 4 adds **streaming polish** (plain-text tail during SSE, full markdown on c Wave 4B adds **provider tiers** (omnifail route stack in the Tiers panel), **image attachments** (composer tray → multimodal proxy requests), **compare mode** (two-column dual streams), **SearXNG discovery** (suggested free chat links when the tier is enabled — never automated), and an **opt-in local web-UI runner** (`runner/` — OpenAI-shaped SSE over user-configured adapters, see `runner/README.md`). +Wave 6A expands the routing chip into a **failover timeline** (ordered hop attempts with tier skip reasons) and adds a per-reply **usage/latency badge** plus a **session totals** row. Token counts appear only when the route exposes them — never fabricated. + ## Session export, import, and hash links - **Export** — Session ⋮ menu → “Export as Markdown” or “Export as JSON”. Serializes text blocks only; empty chats disable the items. diff --git a/docs/plans/2026-07-25-003-feat-chat-ui-wave5-agent-ux-plan.md b/docs/plans/2026-07-25-003-feat-chat-ui-wave5-agent-ux-plan.md index c209654..56970d1 100644 --- a/docs/plans/2026-07-25-003-feat-chat-ui-wave5-agent-ux-plan.md +++ b/docs/plans/2026-07-25-003-feat-chat-ui-wave5-agent-ux-plan.md @@ -8,16 +8,16 @@ strategy: STRATEGY.md wave: 5 requirements: R44-R57 prior_plan: docs/plans/2026-07-25-002-feat-chat-ui-wave4b-differentiation-plan.md -blocked_by: wave4b +blocked_by: wave6a --- # feat: Chat UI Wave 5 — agent UX trust layer -> **Origin:** [Wave 5 agent UX brainstorm](../brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md) — reasoning blocks, tool-call cards, voice input, optional PWA (5B). **Prerequisite:** Wave 4B merged to `main` before **implementation** starts (planning already complete). +> **Origin:** [Wave 5 agent UX brainstorm](../brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md) — reasoning + tool-call cards (5A); voice + PWA optional (5B). **Prerequisite:** Wave **6A** (usage + failover timeline) merged before **implementation** starts. Wave 4B is already on `main`. Requirements refreshed 2026-07-25 (6A-before-5 sequencing; voice moved to 5B). **This plan predates the refresh** — re-run `ce-plan` against the refreshed Wave 5 doc before implementing, or plan Wave 6A first. ## Summary -Wire **murm-ui's built-in thinking and tools plugins**, extend **FailoverProvider SSE mapping** so reasoning and tool-call stream events reach the renderer, add **composer voice dictation** (Web Speech API), and optionally ship **PWA shell + offline read** as Wave **5B**. +After Wave 6A: wire **murm-ui's built-in thinking and tools plugins**, extend **FailoverProvider SSE mapping** so reasoning and tool-call stream events reach the renderer (**5A**). Optionally ship **composer voice dictation** + **PWA shell / offline read** as Wave **5B**. ## Problem Frame diff --git a/docs/plans/2026-07-25-004-feat-chat-ui-wave6a-transparency-plan.md b/docs/plans/2026-07-25-004-feat-chat-ui-wave6a-transparency-plan.md new file mode 100644 index 0000000..8db8c65 --- /dev/null +++ b/docs/plans/2026-07-25-004-feat-chat-ui-wave6a-transparency-plan.md @@ -0,0 +1,40 @@ +--- +title: "feat: Chat UI Wave 6A — usage badge + failover timeline" +status: completed +date: 2026-07-25 +type: feat +origin: docs/brainstorms/2026-07-25-chat-ui-wave6-transparency-discovery-requirements.md +strategy: STRATEGY.md +wave: 6a +requirements: R58-R64, R88 +prior_plan: docs/plans/2026-07-25-002-feat-chat-ui-wave4b-differentiation-plan.md +--- + +# feat: Chat UI Wave 6A — usage badge + failover timeline + +> **Origin:** [Wave 6 transparency requirements](../brainstorms/2026-07-25-chat-ui-wave6-transparency-discovery-requirements.md) — 6A slice only (usage + failover timeline). Ships before Wave 5. + +## Summary + +Per-reply **usage/latency badge**, expandable **failover timeline** on the routing chip (ordered hops with tier skip reasons), and a **session totals** row. Client metadata only — no new proxy logging backend. + +## Landed units + +| Unit | What shipped | +|------|----------------| +| U1 | `RouteTrace` + orchestrator/proxy hop capture; `CompletionMeta.trace` | +| U2 | SSE usage + TTFT/total; `stream_options.include_usage` on proxy body | +| U3 | Trace + usage threaded into completion meta on success/failure | +| U4 | Routing-chip disclosure + badge + alias note (keyboard reachable) | +| U5 | `session-usage` plugin with client aggregation + session reset | +| U6 | Playwright `failover-timeline.spec.ts`; workflow registration; plugins docs | + +## Verification + +- `cd webui && npm test` (91+) +- `cd webui && npm run build` +- `npx playwright test tests/e2e/failover-timeline.spec.ts` + +## Deferred + +Wave 6B (branching, templates, share), 6C (analytics/artifacts), R86/R87, compare-column badges, Wave 5A after this merge. diff --git a/tests/e2e/failover-timeline.spec.ts b/tests/e2e/failover-timeline.spec.ts new file mode 100644 index 0000000..aed5507 --- /dev/null +++ b/tests/e2e/failover-timeline.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from "@playwright/test"; +import { + DEMO_PROXY, + installFailoverTimelineMocks, + installLocalChatBundle, + installLocalIndexHtml, + installTestConfigMock, + mockProxySse, + waitForAssistantText, +} from "./helpers"; + +test.describe("Wave 6A — failover timeline + usage badge", () => { + test("expand chip shows hops, usage badge, alias note, and session totals", async ({ + page, + }) => { + await installFailoverTimelineMocks(page); + await installLocalChatBundle(page); + await installLocalIndexHtml(page); + await page.goto("./", { waitUntil: "domcontentloaded" }); + await page.evaluate(() => localStorage.clear()); + await page.reload({ waitUntil: "domcontentloaded" }); + + await expect(page.locator("#chatinput")).toBeVisible({ timeout: 45_000 }); + await page.locator("#chatinput").fill("show me the hops"); + await page.locator("#sendbutton").click({ force: true }); + + const reply = await waitForAssistantText(page); + expect(reply).toContain("failover timeline reply"); + + const chip = page.locator(".lf-routing-chip").last(); + await expect(chip).toBeVisible({ timeout: 15_000 }); + await expect(chip.locator(".lf-usage-badge")).toContainText(/tok|TTFT|ms/i); + + await chip.focus(); + await page.keyboard.press("Enter"); + await expect(chip).toHaveAttribute("aria-expanded", "true"); + + const panel = page.locator(".lf-routing-panel").last(); + await expect(panel).toBeVisible(); + await expect(panel.locator(".lf-hop-row")).toHaveCount(3, { timeout: 5_000 }); + // quality_api skip + primary error + secondary success + await expect(panel.locator(".lf-hop-outcome-skip").first()).toBeVisible(); + await expect(panel.locator(".lf-hop-outcome-error").first()).toBeVisible(); + await expect(panel.locator(".lf-hop-outcome-success").last()).toBeVisible(); + await expect(panel.locator(".lf-alias-note")).toContainText(/openrouter\/free/i); + + await expect(page.locator(".lf-session-totals")).toBeVisible(); + await expect(page.locator(".lf-session-totals")).toContainText(/Session:/i); + }); + + test("latency-only badge when usage chunk is absent (R59)", async ({ page }) => { + await installTestConfigMock(page); + await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream; charset=utf-8", + body: mockProxySse("no usage here"), + }); + }); + await installLocalChatBundle(page); + await installLocalIndexHtml(page); + await page.goto("./", { waitUntil: "domcontentloaded" }); + await page.evaluate(() => localStorage.clear()); + await page.reload({ waitUntil: "domcontentloaded" }); + + await page.locator("#chatinput").fill("latency only"); + await page.locator("#sendbutton").click({ force: true }); + await waitForAssistantText(page); + + const badge = page.locator(".lf-usage-badge").last(); + await expect(badge).toBeVisible({ timeout: 15_000 }); + await expect(badge).not.toContainText(/tok/i); + await expect(badge).toContainText(/ms/i); + }); +}); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 5fd289f..793a463 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -25,6 +25,88 @@ export function mockProxySse(content: string): string { return body; } +/** SSE body with a trailing usage chunk (Wave 6A / R58). */ +export function mockProxySseWithUsage( + content: string, + usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } = { + prompt_tokens: 12, + completion_tokens: 4, + total_tokens: 16, + } +): string { + let body = mockProxySse(content); + // Insert usage before [DONE]. + body = body.replace( + "data: [DONE]\n\n", + `data: ${JSON.stringify({ usage })}\n\ndata: [DONE]\n\n` + ); + return body; +} + +/** Dual-endpoint mock: first 503, second succeeds with usage-bearing SSE. */ +export async function installFailoverTimelineMocks( + page: Page, + reply = "failover timeline reply" +): Promise { + await page.route("**/config.js*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/javascript", + body: + "window.LLM_FALLBACKS_CONFIG = " + + JSON.stringify({ + endpoints: [PRIMARY_FAIL_PROXY], + guestToken: "llm-fallbacks-public", + defaultModel: "free", + catalogUrl: + "https://raw.githubusercontent.com/bodecloud/llm_fallbacks/main/configs/free_models.json", + providerUrlsUrl: + "https://raw.githubusercontent.com/bodecloud/llm_fallbacks/main/configs/provider_urls.json", + chatProxyUrl: + "https://raw.githubusercontent.com/bodecloud/llm_fallbacks/main/configs/chat_proxy.json", + maxTokens: 512, + }) + + ";", + }); + }); + + await page.route("**/chat_proxy.json", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + endpoints: [PRIMARY_FAIL_PROXY, SECONDARY_OK_PROXY], + guestToken: "llm-fallbacks-public", + }), + }); + }); + + await page.route("**/free_models.json", async (route) => { + await route.fulfill({ status: 200, contentType: "application/json", body: "[]" }); + }); + await page.route("**/provider_urls.json", async (route) => { + await route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); + }); + + await page.route(`${PRIMARY_FAIL_PROXY}/v1/chat/completions`, async (route) => { + await route.fulfill({ status: 503, body: "upstream unavailable" }); + }); + + await page.route(`${SECONDARY_OK_PROXY}/v1/chat/completions`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream; charset=utf-8", + headers: { + "x-litellm-model-name": "openrouter/free", + "x-llm-fallbacks-endpoint": "secondary-ok.test", + "Access-Control-Expose-Headers": + "x-litellm-model-name,x-llm-fallbacks-endpoint", + }, + body: mockProxySseWithUsage(reply), + }); + }); +} + export async function installDemoProxyMock(page: Page, reply = "42 — zero-config proxy reply") { await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => { await route.fulfill({ diff --git a/webui/shell/chat-overrides.css b/webui/shell/chat-overrides.css index f5a1205..548bfa7 100644 --- a/webui/shell/chat-overrides.css +++ b/webui/shell/chat-overrides.css @@ -645,20 +645,171 @@ body.lf-chat-page::before { white-space: nowrap; } -/* ── Wave 1: routing chip ───────────────────────────────────── */ -.lf-routing-chip { - margin-top: 0.35rem; +/* ── Wave 1 / 6A: routing chip + failover timeline ───────────── */ +.lf-routing-root { + margin-top: 0.4rem; font-size: 0.68rem; letter-spacing: 0.02em; color: #8b8ba3; - opacity: 0.9; } -.lf-routing-chip::before { +.lf-routing-chip { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.55rem; + max-width: 100%; + margin: 0; + padding: 0.2rem 0.45rem; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: inherit; + font: inherit; + letter-spacing: inherit; + text-align: left; + cursor: pointer; + opacity: 0.95; +} + +.lf-routing-chip:hover { + border-color: rgba(157, 78, 221, 0.28); + background: rgba(26, 26, 46, 0.55); +} + +.lf-routing-chip:focus-visible { + outline: 2px solid #c77dff; + outline-offset: 2px; +} + +.lf-routing-summary::before { content: "↳ "; opacity: 0.65; } +.lf-usage-badge { + color: #c4c4d4; + opacity: 0.95; +} + +.lf-routing-chevron { + opacity: 0.7; + font-size: 0.75em; +} + +.lf-routing-chip[aria-expanded="true"] .lf-routing-chevron { + transform: rotate(180deg); +} + +.lf-routing-panel { + margin-top: 0.4rem; + padding: 0.55rem 0.65rem; + border: 1px solid rgba(157, 78, 221, 0.22); + border-radius: 8px; + background: rgba(18, 18, 31, 0.92); +} + +.lf-routing-panel[hidden] { + display: none !important; +} + +.lf-hop-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.lf-hop-row { + border: 1px solid rgba(157, 78, 221, 0.16); + border-radius: 6px; + padding: 0.4rem 0.5rem; + background: rgba(26, 26, 46, 0.55); +} + +.lf-hop-main { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.55rem; + align-items: center; + color: #c4c4d4; +} + +.lf-hop-index { + color: #8b8ba3; + font-variant-numeric: tabular-nums; +} + +.lf-hop-tier { + color: #e8e8ef; + font-weight: 600; +} + +.lf-hop-endpoint, +.lf-hop-model { + color: #8b8ba3; + word-break: break-all; +} + +.lf-hop-outcome { + margin-left: auto; + padding: 0.05rem 0.4rem; + border-radius: 999px; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.lf-hop-outcome-success { + color: #7dcea0; + background: rgba(125, 206, 160, 0.12); +} + +.lf-hop-outcome-skip { + color: #f0c674; + background: rgba(240, 198, 116, 0.12); +} + +.lf-hop-outcome-error { + color: #e88; + background: rgba(238, 136, 136, 0.12); +} + +.lf-hop-reason { + margin: 0.3rem 0 0; + color: #8b8ba3; + font-size: 0.72rem; +} + +.lf-alias-note { + margin: 0.55rem 0 0; + color: #8b8ba3; + font-size: 0.72rem; + line-height: 1.35; +} + +.lf-routing-empty { + margin: 0; + color: #8b8ba3; +} + +/* Wave 6A: session totals row */ +.lf-session-totals { + flex-shrink: 0; + padding: 0.35rem 0.75rem; + border-top: 1px solid rgba(157, 78, 221, 0.18); + background: rgba(18, 18, 31, 0.85); + font-size: 0.72rem; + color: #8b8ba3; + letter-spacing: 0.02em; +} + +.lf-session-totals[hidden] { + display: none !important; +} + /* ── Wave 1: hide unwired ResearchWizard chrome ─────────────── */ #voiceInputBtn, .research-output-container, diff --git a/webui/src/main.ts b/webui/src/main.ts index b60ca27..8a06dce 100644 --- a/webui/src/main.ts +++ b/webui/src/main.ts @@ -15,6 +15,7 @@ import { TierSettingsPlugin } from "./plugins/tier-settings"; import { CompareModePlugin } from "./plugins/compare-mode"; import { DiscoveryPicklistPlugin } from "./plugins/discovery-picklist"; import { ModelExplorerPlugin } from "./plugins/model-explorer"; +import { SessionUsagePlugin } from "./plugins/session-usage"; import { ModelPickerPlugin } from "./plugins/model-picker"; import { RoutingChipPlugin } from "./plugins/routing-chip"; import { MessageActionsPlugin } from "./plugins/message-actions"; @@ -225,6 +226,7 @@ async function bootstrap(): Promise { ModelPickerPlugin(), MessageActionsPlugin(), RoutingChipPlugin(), + SessionUsagePlugin(), StatusStripPlugin(), TurnstileGatePlugin(), FailoverSettingsPlugin({ diff --git a/webui/src/plugins/routing-chip/index.ts b/webui/src/plugins/routing-chip/index.ts index 80ed929..394e4e1 100644 --- a/webui/src/plugins/routing-chip/index.ts +++ b/webui/src/plugins/routing-chip/index.ts @@ -1,13 +1,88 @@ import type { ChatPlugin } from "murm-ui"; import type { ChatEngine } from "murm-ui"; +import { TIER_LABELS } from "../tier-settings/settings"; +import type { RouteHop } from "../../providers/route-trace"; import { COMPLETION_META_EVENT, + FREE_ALIAS_NOTE, formatRoutingChip, + formatUsageBadge, getLastCompletionMeta, + hostnameFromUrl, type CompletionMeta, } from "../../providers/routing-metadata"; const CHIP_CLASS = "lf-routing-chip"; +const ROOT_CLASS = "lf-routing-root"; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function outcomeLabel(outcome: RouteHop["outcome"]): string { + if (outcome === "success") return "ok"; + if (outcome === "skip") return "skip"; + return outcome === "error" ? "error" : outcome; +} + +function hopRow(hop: RouteHop): string { + const tier = TIER_LABELS[hop.tier] ?? hop.tier; + const host = hop.endpoint ? hostnameFromUrl(hop.endpoint) : ""; + const model = hop.model ? escapeHtml(hop.model) : ""; + const reason = hop.reason ? escapeHtml(hop.reason) : ""; + const parts = [ + `#${hop.hopIndex}`, + `${escapeHtml(tier)}`, + ]; + if (host) parts.push(`${escapeHtml(host)}`); + if (model) parts.push(`${model}`); + parts.push( + `${outcomeLabel(hop.outcome)}` + ); + return ` +
  • +
    ${parts.join("")}
    + ${reason ? `

    ${reason}

    ` : ""} +
  • + `; +} + +function renderChip(root: HTMLElement, meta: CompletionMeta): void { + const summary = formatRoutingChip(meta); + const badge = formatUsageBadge(meta); + const hops = meta.trace ?? []; + const panelId = `lf-routing-panel-${Math.random().toString(36).slice(2, 9)}`; + + root.className = ROOT_CLASS; + root.innerHTML = ` + + + `; + + const toggle = root.querySelector(`.${CHIP_CLASS}`); + const panel = root.querySelector(".lf-routing-panel"); + toggle?.addEventListener("click", () => { + if (!toggle || !panel) return; + const open = toggle.getAttribute("aria-expanded") === "true"; + toggle.setAttribute("aria-expanded", String(!open)); + panel.hidden = open; + }); +} export function RoutingChipPlugin(): ChatPlugin { let engine: ChatEngine | null = null; @@ -23,14 +98,12 @@ export function RoutingChipPlugin(): ChatPlugin { const msgEl = domAssistants[domAssistants.length - 1] as HTMLElement | undefined; if (!msgEl) return; - let chip = msgEl.querySelector(`.${CHIP_CLASS}`); - if (!chip) { - chip = document.createElement("div"); - chip.className = CHIP_CLASS; - chip.setAttribute("aria-label", "Routing metadata"); - msgEl.appendChild(chip); + let root = msgEl.querySelector(`.${ROOT_CLASS}`); + if (!root) { + root = document.createElement("div"); + msgEl.appendChild(root); } - chip.textContent = formatRoutingChip(meta); + renderChip(root, meta); } function scheduleAttach(meta: CompletionMeta): void { diff --git a/webui/src/plugins/session-usage/index.ts b/webui/src/plugins/session-usage/index.ts new file mode 100644 index 0000000..cad6bff --- /dev/null +++ b/webui/src/plugins/session-usage/index.ts @@ -0,0 +1,74 @@ +import type { ChatPlugin } from "murm-ui"; +import { + COMPLETION_META_EVENT, + type CompletionMeta, +} from "../../providers/routing-metadata"; +import { + accumulateSessionTotals, + emptySessionTotals, + formatSessionTotals, + type SessionUsageTotals, +} from "./totals"; + +/** + * Client-side session totals row (R60). Aggregates tokens (when exposed) and + * wall time from COMPLETION_META_EVENT; resets on session switch. + */ +export function SessionUsagePlugin(): ChatPlugin { + let host: HTMLElement | null = null; + let totals: SessionUsageTotals = emptySessionTotals(); + let sessionId: string | null = null; + let onMeta: ((event: Event) => void) | null = null; + + const paint = (): void => { + if (!host) return; + host.textContent = formatSessionTotals(totals); + host.hidden = totals.replies === 0; + }; + + const reset = (): void => { + totals = emptySessionTotals(); + paint(); + }; + + return { + name: "session-usage", + onMount(ctx) { + host = document.createElement("div"); + host.className = "lf-session-totals"; + host.setAttribute("aria-live", "polite"); + host.hidden = true; + + const layout = ctx.container.querySelector(".mur-chat-layout-wrapper"); + const form = ctx.container.querySelector(".mur-chat-form-container"); + if (layout && form) { + layout.insertBefore(host, form); + } else { + ctx.container.appendChild(host); + } + + sessionId = ctx.engine.state.currentSessionId; + onMeta = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail) return; + totals = accumulateSessionTotals(totals, detail); + paint(); + }; + window.addEventListener(COMPLETION_META_EVENT, onMeta); + + ctx.engine.subscribe( + (s) => s.currentSessionId, + (id) => { + if (id !== sessionId) { + sessionId = id; + reset(); + } + } + ); + }, + destroy() { + if (onMeta) window.removeEventListener(COMPLETION_META_EVENT, onMeta); + host?.remove(); + }, + }; +} diff --git a/webui/src/plugins/session-usage/session-usage.test.ts b/webui/src/plugins/session-usage/session-usage.test.ts new file mode 100644 index 0000000..c4721c4 --- /dev/null +++ b/webui/src/plugins/session-usage/session-usage.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import type { CompletionMeta } from "../../providers/routing-metadata"; +import { + accumulateSessionTotals, + emptySessionTotals, + formatSessionTotals, +} from "./totals"; + +function meta(partial: Partial): CompletionMeta { + return { endpoint: "https://proxy.test", fallbackCount: 0, ...partial }; +} + +describe("session usage totals", () => { + it("sums tokens and wall time across replies with usage (R60)", () => { + let totals = emptySessionTotals(); + totals = accumulateSessionTotals( + totals, + meta({ usage: { promptTokens: 10, completionTokens: 5 }, totalMs: 100 }) + ); + totals = accumulateSessionTotals( + totals, + meta({ usage: { promptTokens: 20, completionTokens: 8 }, totalMs: 200 }) + ); + expect(totals).toMatchObject({ + replies: 2, + repliesWithUsage: 2, + promptTokens: 30, + completionTokens: 13, + totalMs: 300, + }); + expect(formatSessionTotals(totals)).toBe("Session: 2 replies · 30→13 tok · 300ms"); + }); + + it("labels partial token sums when only some replies expose usage (R59)", () => { + let totals = emptySessionTotals(); + totals = accumulateSessionTotals( + totals, + meta({ usage: { promptTokens: 10, completionTokens: 2 }, totalMs: 50 }) + ); + totals = accumulateSessionTotals(totals, meta({ totalMs: 80 })); + expect(totals.repliesWithUsage).toBe(1); + expect(formatSessionTotals(totals)).toContain("partial"); + expect(formatSessionTotals(totals)).toContain("130ms"); + }); + + it("shows time-only when no reply exposes usage", () => { + let totals = emptySessionTotals(); + totals = accumulateSessionTotals(totals, meta({ totalMs: 40 })); + expect(formatSessionTotals(totals)).toBe("Session: 1 reply · 40ms"); + }); + + it("resets to empty via emptySessionTotals", () => { + expect(formatSessionTotals(emptySessionTotals())).toBe("Session: no replies yet"); + }); +}); diff --git a/webui/src/plugins/session-usage/totals.ts b/webui/src/plugins/session-usage/totals.ts new file mode 100644 index 0000000..4a604fe --- /dev/null +++ b/webui/src/plugins/session-usage/totals.ts @@ -0,0 +1,57 @@ +import type { CompletionMeta, TokenUsage } from "../../providers/routing-metadata"; + +export interface SessionUsageTotals { + replies: number; + repliesWithUsage: number; + promptTokens: number; + completionTokens: number; + totalMs: number; +} + +export function emptySessionTotals(): SessionUsageTotals { + return { + replies: 0, + repliesWithUsage: 0, + promptTokens: 0, + completionTokens: 0, + totalMs: 0, + }; +} + +export function accumulateSessionTotals( + current: SessionUsageTotals, + meta: CompletionMeta +): SessionUsageTotals { + const next = { ...current, replies: current.replies + 1 }; + const wall = meta.totalMs ?? meta.durationMs; + if (wall !== undefined && Number.isFinite(wall)) { + next.totalMs += Math.max(0, wall); + } + const usage: TokenUsage | undefined = meta.usage; + if ( + usage && + (usage.promptTokens !== undefined || usage.completionTokens !== undefined) + ) { + next.repliesWithUsage += 1; + next.promptTokens += usage.promptTokens ?? 0; + next.completionTokens += usage.completionTokens ?? 0; + } + return next; +} + +/** Format totals for the footer row (R60 / R59 consistency). */ +export function formatSessionTotals(totals: SessionUsageTotals): string { + if (totals.replies === 0) return "Session: no replies yet"; + const parts: string[] = [`${totals.replies} repl${totals.replies === 1 ? "y" : "ies"}`]; + if (totals.repliesWithUsage > 0) { + const tokenLabel = + totals.repliesWithUsage < totals.replies + ? `${totals.promptTokens}→${totals.completionTokens} tok (partial)` + : `${totals.promptTokens}→${totals.completionTokens} tok`; + parts.push(tokenLabel); + } + if (totals.totalMs > 0) { + parts.push(`${Math.round(totals.totalMs)}ms`); + } + return `Session: ${parts.join(" · ")}`; +} diff --git a/webui/src/providers/FailoverProvider.tiers.test.ts b/webui/src/providers/FailoverProvider.tiers.test.ts index 355ec95..c9525d6 100644 --- a/webui/src/providers/FailoverProvider.tiers.test.ts +++ b/webui/src/providers/FailoverProvider.tiers.test.ts @@ -13,6 +13,7 @@ vi.mock("../analytics", () => ({ })); import { FailoverProvider } from "./FailoverProvider"; +import { getLastCompletionMeta } from "./routing-metadata"; const config: AppConfig = { endpoints: ["https://proxy.test"], @@ -155,6 +156,66 @@ describe("FailoverProvider tier routing", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("attaches a RouteTrace with skip + success hops on zero-config chat (R61)", async () => { + const fetchMock = vi.fn(async () => + sseResponse([ + JSON.stringify({ choices: [{ delta: { content: "hi" } }] }), + JSON.stringify({ choices: [{ finish_reason: "stop" }] }), + JSON.stringify({ usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 } }), + ]) + ); + vi.stubGlobal("fetch", fetchMock); + + const provider = new FailoverProvider(config); + await provider.streamChat(request(), () => {}); + + const meta = getLastCompletionMeta(); + expect(meta?.trace?.length).toBeGreaterThanOrEqual(2); + expect(meta?.trace?.[0]).toMatchObject({ tier: "quality_api", outcome: "skip" }); + expect(meta?.trace?.some((h) => h.tier === "proxy_failover" && h.outcome === "success")).toBe( + true + ); + expect(meta?.usage).toEqual({ + promptTokens: 2, + completionTokens: 1, + totalTokens: 3, + }); + expect(meta?.totalMs).toBeGreaterThanOrEqual(0); + const sent = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); + expect(sent.stream_options).toEqual({ include_usage: true }); + }); + + it("records an error hop then success when the first proxy endpoint fails", async () => { + const dual: AppConfig = { + ...config, + endpoints: ["https://primary-fail.test", "https://secondary-ok.test"], + }; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("primary-fail")) { + return new Response("upstream unavailable", { status: 503 }); + } + return sseResponse([ + JSON.stringify({ choices: [{ delta: { content: "ok" } }] }), + JSON.stringify({ choices: [{ finish_reason: "stop" }] }), + ]); + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = new FailoverProvider(dual); + await provider.streamChat(request(), () => {}); + + const proxyHops = (getLastCompletionMeta()?.trace ?? []).filter( + (h) => h.tier === "proxy_failover" + ); + expect(proxyHops.length).toBeGreaterThanOrEqual(2); + expect(proxyHops[0].outcome).toBe("error"); + expect(proxyHops[0].errorClass).toBe("cold_start"); + expect(proxyHops[0].hopIndex).toBe(0); + expect(proxyHops.at(-1)?.outcome).toBe("success"); + expect(proxyHops.at(-1)?.hopIndex).toBe(1); + }); + it("sends an image_url part to the proxy for a vision model (R28)", async () => { const fetchMock = vi.fn(async () => sseResponse([ diff --git a/webui/src/providers/FailoverProvider.ts b/webui/src/providers/FailoverProvider.ts index 707afe8..a4bcec5 100644 --- a/webui/src/providers/FailoverProvider.ts +++ b/webui/src/providers/FailoverProvider.ts @@ -19,7 +19,12 @@ import { messagesToPlainText, modelSupportsVision, } from "./message-openai"; -import { setLastCompletionMeta, type CompletionMeta } from "./routing-metadata"; +import { classifyHopError, RouteTrace } from "./route-trace"; +import { + getLastCompletionMeta, + setLastCompletionMeta, + type CompletionMeta, +} from "./routing-metadata"; import { emitOpenAiSseAsStreamEvents, emitTextAsStreamEvents } from "./sse"; import { TierOrchestrator, @@ -100,6 +105,9 @@ export class FailoverProvider implements ChatProvider { private providerUrls: Record = {}; private statusListeners = new Set(); private lastRoute = ""; + /** Active per-request hop trail (Wave 6A). */ + private activeTrace: RouteTrace | null = null; + private requestStartedAt = 0; constructor(initialConfig?: AppConfig) { this.config = initialConfig || readRuntimeConfig(); @@ -197,21 +205,42 @@ export class FailoverProvider implements ChatProvider { let lastError = "All proxy endpoints failed"; let hopIndex = 0; let lastRateLimit: RateLimitInfo | undefined; + const proxyBody = { + ...body, + // Ask proxies for a trailing usage chunk when supported (R58/R64). + stream_options: { include_usage: true }, + }; for (const base of config.endpoints) { this.setStatus(`proxy: ${base} …`); try { - const res = await this.chatViaProxy(base, body, config.guestToken, signal); + const res = await this.chatViaProxy(base, proxyBody, config.guestToken, signal); if (res.ok) { this.lastRoute = `proxy/${base}`; window.LLM_FALLBACKS_ROUTE = this.lastRoute; const headerMeta = readRoutingHeaders(res); + const endpoint = endpointLabel(base, res); + const timing = await emitOpenAiSseAsStreamEvents( + res, + onEvent, + this.requestStartedAt || performance.now() + ); + this.activeTrace?.record({ + tier: "proxy_failover", + endpoint, + model: headerMeta.modelHeader, + outcome: "success", + hopIndex, + }); setLastCompletionMeta({ - endpoint: endpointLabel(base, res), + endpoint, modelHeader: headerMeta.modelHeader, fallbackCount: hopIndex, durationMs: headerMeta.durationMs, + trace: this.activeTrace?.snapshot(), + usage: timing.usage, + ttftMs: timing.ttftMs, + totalMs: timing.totalMs, }); - await emitOpenAiSseAsStreamEvents(res, onEvent); return; } if (!res.ok) { @@ -222,6 +251,18 @@ export class FailoverProvider implements ChatProvider { (res.status === 429 ? parseRetryAfterFromBody(errText) : undefined); const rateScope = res.status === 429 ? parseRateLimitScopeFromBody(errText) : undefined; lastError = `${base}: HTTP ${res.status} — ${errText.slice(0, 160)}`; + const mapped = mapHttpError(res.status, errText, base, { + retryAfterSeconds: retryAfter, + scope: rateScope, + }); + this.activeTrace?.record({ + tier: "proxy_failover", + endpoint: base, + outcome: "error", + errorClass: mapped.kind, + reason: mapped.message, + hopIndex, + }); if (res.status === 429) { lastRateLimit = { retryAfterSeconds: retryAfter, @@ -230,18 +271,22 @@ export class FailoverProvider implements ChatProvider { showRateLimitBanner(retryAfter); } if (!RETRYABLE.has(res.status)) { - throw mapHttpError(res.status, errText, base, { - retryAfterSeconds: retryAfter, - scope: rateScope, - }); + throw mapped; } } } catch (err) { if (signal.aborted) throw err; - if (err instanceof ChatRouteError) { - throw err; - } + // Non-retryable HTTP errors are already recorded above before throw. + if (err instanceof ChatRouteError) throw err; lastError = `${base}: ${err instanceof Error ? err.message : String(err)}`; + this.activeTrace?.record({ + tier: "proxy_failover", + endpoint: base, + outcome: "error", + errorClass: classifyHopError(err), + reason: lastError, + hopIndex, + }); } hopIndex += 1; } @@ -308,9 +353,15 @@ export class FailoverProvider implements ChatProvider { }); this.lastRoute = result.route; window.LLM_FALLBACKS_ROUTE = this.lastRoute; + const totalMs = this.requestStartedAt + ? Math.max(0, performance.now() - this.requestStartedAt) + : undefined; setLastCompletionMeta({ endpoint: result.route, fallbackCount: 0, + trace: this.activeTrace?.snapshot(), + ttftMs: totalMs, + totalMs, }); emitTextAsStreamEvents(result.content, onEvent); } @@ -353,7 +404,16 @@ export class FailoverProvider implements ChatProvider { metaSet = true; this.lastRoute = `web_ui/${settings.webRunnerUrl}`; window.LLM_FALLBACKS_ROUTE = this.lastRoute; - setLastCompletionMeta({ endpoint: this.lastRoute, fallbackCount: 0 }); + const totalMs = this.requestStartedAt + ? Math.max(0, performance.now() - this.requestStartedAt) + : undefined; + setLastCompletionMeta({ + endpoint: this.lastRoute, + fallbackCount: 0, + trace: this.activeTrace?.snapshot(), + ttftMs: totalMs, + totalMs, + }); } onEvent(event); }, @@ -397,27 +457,43 @@ export class FailoverProvider implements ChatProvider { } } - const orchestrator = new TierOrchestrator({ - qualityApi: (req, onEv) => - this.streamWithCompletionTracking((inner) => this.streamQualityApiRoute(req, inner), onEv), - webUi: (req, onEv) => - this.streamWithCompletionTracking((inner) => this.streamWebUiRoute(req, inner), onEv), - searxngDiscovery: (req, onEv) => - this.streamWithCompletionTracking( - (inner) => this.streamSearxngDiscoveryRoute(req, inner), - onEv - ), - proxyFailover: (req, onEv) => - this.streamWithCompletionTracking((inner) => this.streamProxyFailoverRoute(req, inner), onEv), - }); + this.activeTrace = new RouteTrace(); + this.requestStartedAt = performance.now(); + const orchestrator = new TierOrchestrator( + { + qualityApi: (req, onEv) => + this.streamWithCompletionTracking((inner) => this.streamQualityApiRoute(req, inner), onEv), + webUi: (req, onEv) => + this.streamWithCompletionTracking((inner) => this.streamWebUiRoute(req, inner), onEv), + searxngDiscovery: (req, onEv) => + this.streamWithCompletionTracking( + (inner) => this.streamSearxngDiscoveryRoute(req, inner), + onEv + ), + proxyFailover: (req, onEv) => + this.streamWithCompletionTracking( + (inner) => this.streamProxyFailoverRoute(req, inner), + onEv + ), + }, + this.activeTrace + ); try { await orchestrator.streamChat(request, onEvent); + // Handlers set meta mid-flight; refresh with the final hop trail after + // the orchestrator records the winning tier success (if it owns that hop). + const meta = getLastCompletionMeta(); + if (meta && this.activeTrace) { + setLastCompletionMeta({ ...meta, trace: this.activeTrace.snapshot() }); + } } catch (err) { if (err instanceof TierOrchestratorError) { throw this.mapTierFailure(err); } throw err; + } finally { + this.activeTrace = null; } } @@ -425,6 +501,17 @@ export class FailoverProvider implements ChatProvider { // surfacing which tiers were tried and their last error (R40). A no-attempt // failure means every tier skipped or none were enabled. private mapTierFailure(err: TierOrchestratorError): Error { + // Attach the hop trail so UI can still show what was tried on total failure. + if (this.activeTrace && this.activeTrace.length > 0) { + setLastCompletionMeta({ + endpoint: "—", + fallbackCount: Math.max(0, this.activeTrace.length - 1), + trace: this.activeTrace.snapshot(), + totalMs: this.requestStartedAt + ? Math.max(0, performance.now() - this.requestStartedAt) + : undefined, + }); + } if (err.attempts.length === 0) { return mapProxyChainFailure( "No chat routes are available yet. Enable a provider tier in Settings, or wait for the demo proxy to finish deploying." diff --git a/webui/src/providers/route-trace.test.ts b/webui/src/providers/route-trace.test.ts new file mode 100644 index 0000000..2bf586a --- /dev/null +++ b/webui/src/providers/route-trace.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { RouteTrace, classifyHopError } from "./route-trace"; +import { ChatRouteError } from "./errors"; + +describe("RouteTrace", () => { + it("assigns sequential hopIndex values", () => { + const trace = new RouteTrace(); + const a = trace.record({ tier: "quality_api", outcome: "skip", reason: "no key" }); + const b = trace.record({ + tier: "proxy_failover", + outcome: "success", + endpoint: "https://proxy.test", + }); + expect(a.hopIndex).toBe(0); + expect(b.hopIndex).toBe(1); + expect(trace.snapshot()).toHaveLength(2); + }); + + it("honors an explicit hopIndex without colliding", () => { + const trace = new RouteTrace(); + trace.record({ tier: "proxy_failover", outcome: "error", hopIndex: 0 }); + const next = trace.record({ tier: "proxy_failover", outcome: "success" }); + expect(next.hopIndex).toBe(1); + }); + + it("hasTier reports whether any hop for a tier exists", () => { + const trace = new RouteTrace(); + expect(trace.hasTier("proxy_failover")).toBe(false); + trace.record({ tier: "proxy_failover", outcome: "error", endpoint: "https://a.test" }); + expect(trace.hasTier("proxy_failover")).toBe(true); + expect(trace.hasTier("quality_api")).toBe(false); + }); + + it("snapshot returns a copy", () => { + const trace = new RouteTrace(); + trace.record({ tier: "quality_api", outcome: "success" }); + const snap = trace.snapshot(); + snap[0].outcome = "error"; + expect(trace.snapshot()[0].outcome).toBe("success"); + }); +}); + +describe("classifyHopError", () => { + it("uses ChatRouteError.kind when present", () => { + expect(classifyHopError(new ChatRouteError("cold_start", "waking"))).toBe("cold_start"); + }); + + it("falls back to message heuristics", () => { + expect(classifyHopError(new Error("HTTP 503 upstream"))).toBe("cold_start"); + expect(classifyHopError(new Error("429 rate limit"))).toBe("rate_limit"); + }); +}); diff --git a/webui/src/providers/route-trace.ts b/webui/src/providers/route-trace.ts new file mode 100644 index 0000000..f9c9b3d --- /dev/null +++ b/webui/src/providers/route-trace.ts @@ -0,0 +1,62 @@ +/** + * Per-request ordered hop trail for Wave 6A failover timeline (R61, R63). + * Client-only — no new proxy logging backend (R64). + */ +import type { TierId } from "./tiers/types"; + +export type HopOutcome = "success" | "skip" | "error"; + +export interface RouteHop { + tier: TierId; + endpoint?: string; + model?: string; + outcome: HopOutcome; + /** Short error taxonomy when outcome is error/skip (e.g. cold_start, skip). */ + errorClass?: string; + /** Human-readable skip/error reason (R63). */ + reason?: string; + hopIndex: number; + ms?: number; +} + +export type RouteHopInput = Omit & { hopIndex?: number }; + +export class RouteTrace { + private readonly hops: RouteHop[] = []; + private nextIndex = 0; + + record(input: RouteHopInput): RouteHop { + const hopIndex = input.hopIndex ?? this.nextIndex; + this.nextIndex = Math.max(this.nextIndex, hopIndex + 1); + const hop: RouteHop = { ...input, hopIndex }; + this.hops.push(hop); + return hop; + } + + hasTier(tier: TierId): boolean { + return this.hops.some((h) => h.tier === tier); + } + + snapshot(): RouteHop[] { + return this.hops.map((h) => ({ ...h })); + } + + get length(): number { + return this.hops.length; + } +} + +/** Map a thrown error to a short errorClass for timeline pills. */ +export function classifyHopError(err: unknown): string { + if (err && typeof err === "object" && "kind" in err && typeof (err as { kind: unknown }).kind === "string") { + return (err as { kind: string }).kind; + } + if (err instanceof Error) { + const msg = err.message; + if (/429|rate limit/i.test(msg)) return "rate_limit"; + if (/quota|credit|exhausted/i.test(msg)) return "quota"; + if (/503|502|cold start|unavailable/i.test(msg)) return "cold_start"; + if (/401|403|auth|turnstile/i.test(msg)) return "auth"; + } + return "error"; +} diff --git a/webui/src/providers/routing-metadata.test.ts b/webui/src/providers/routing-metadata.test.ts new file mode 100644 index 0000000..cb3cf8f --- /dev/null +++ b/webui/src/providers/routing-metadata.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + FREE_ALIAS_NOTE, + formatRoutingChip, + formatUsageBadge, + type CompletionMeta, +} from "./routing-metadata"; + +describe("formatUsageBadge", () => { + it("shows tokens and latency when usage is present (R58)", () => { + const meta: CompletionMeta = { + endpoint: "https://proxy.test", + fallbackCount: 0, + usage: { promptTokens: 10, completionTokens: 4 }, + ttftMs: 120, + totalMs: 800, + }; + expect(formatUsageBadge(meta)).toBe("10→4 tok · TTFT 120ms · 800ms"); + }); + + it("shows latency only when usage is absent (R59)", () => { + const meta: CompletionMeta = { + endpoint: "https://proxy.test", + fallbackCount: 1, + ttftMs: 50, + totalMs: 400, + }; + expect(formatUsageBadge(meta)).toBe("TTFT 50ms · 400ms"); + }); + + it("returns empty string when nothing is available", () => { + expect(formatUsageBadge({ endpoint: "x", fallbackCount: 0 })).toBe(""); + }); +}); + +describe("formatRoutingChip", () => { + it("keeps the Wave 1 summary shape", () => { + expect( + formatRoutingChip({ + endpoint: "https://demo.proxy.test/v1", + modelHeader: "openrouter/free", + fallbackCount: 1, + }) + ).toBe("demo.proxy.test · openrouter/free · 1 fallback"); + }); +}); + +describe("FREE_ALIAS_NOTE", () => { + it("mentions both free and openrouter/free (R62)", () => { + expect(FREE_ALIAS_NOTE).toMatch(/`free`/); + expect(FREE_ALIAS_NOTE).toMatch(/`openrouter\/free`/); + }); +}); diff --git a/webui/src/providers/routing-metadata.ts b/webui/src/providers/routing-metadata.ts index 4f93319..3754512 100644 --- a/webui/src/providers/routing-metadata.ts +++ b/webui/src/providers/routing-metadata.ts @@ -1,9 +1,26 @@ +import type { RouteHop } from "./route-trace"; + +export interface TokenUsage { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; +} + export interface CompletionMeta { endpoint: string; modelHeader?: string; fallbackCount: number; + /** Server-reported duration when CORS-exposed (secondary to client totalMs). */ durationMs?: number; messageId?: string; + /** Ordered hop trail for the failover timeline (R61/R63). */ + trace?: RouteHop[]; + /** Token usage when the route exposes a usage chunk (R58); never fabricated. */ + usage?: TokenUsage; + /** Client-measured time-to-first-token (R58). */ + ttftMs?: number; + /** Client-measured total stream duration (R58). */ + totalMs?: number; } let lastCompletionMeta: CompletionMeta | null = null; @@ -42,3 +59,28 @@ export function formatRoutingChip(meta: CompletionMeta): string { } return parts.join(" · ") || "—"; } + +/** Compact collapsed badge: tokens when present, else TTFT · total (R58/R59). */ +export function formatUsageBadge(meta: CompletionMeta): string { + const latencyParts: string[] = []; + if (meta.ttftMs !== undefined && Number.isFinite(meta.ttftMs)) { + latencyParts.push(`TTFT ${Math.round(meta.ttftMs)}ms`); + } + const total = meta.totalMs ?? meta.durationMs; + if (total !== undefined && Number.isFinite(total)) { + latencyParts.push(`${Math.round(total)}ms`); + } + + const usage = meta.usage; + if (usage && (usage.promptTokens !== undefined || usage.completionTokens !== undefined)) { + const inTok = usage.promptTokens ?? "?"; + const outTok = usage.completionTokens ?? "?"; + const tokenPart = `${inTok}→${outTok} tok`; + return latencyParts.length ? `${tokenPart} · ${latencyParts.join(" · ")}` : tokenPart; + } + + return latencyParts.join(" · ") || ""; +} + +export const FREE_ALIAS_NOTE = + "`free` is our ranked quality-sorted alias; `openrouter/free` is OpenRouter's own meta-router."; diff --git a/webui/src/providers/sse.test.ts b/webui/src/providers/sse.test.ts new file mode 100644 index 0000000..ab2d9c0 --- /dev/null +++ b/webui/src/providers/sse.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { StreamEvent } from "murm-ui"; +import { emitOpenAiSseAsStreamEvents } from "./sse"; + +function sseResponse(chunks: string[]): Response { + const body = chunks.map((c) => `data: ${c}\n\n`).join("") + "data: [DONE]\n\n"; + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +function collectDeltas(events: StreamEvent[]): string { + return events + .filter((e): e is Extract => e.type === "text_delta") + .map((e) => e.delta) + .join(""); +} + +describe("emitOpenAiSseAsStreamEvents", () => { + it("captures usage from a trailing usage chunk (R58)", async () => { + const events: StreamEvent[] = []; + const result = await emitOpenAiSseAsStreamEvents( + sseResponse([ + JSON.stringify({ choices: [{ delta: { content: "hi" } }] }), + JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] }), + JSON.stringify({ usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } }), + ]), + (e) => events.push(e), + performance.now() - 50 + ); + + expect(collectDeltas(events)).toBe("hi"); + expect(result.usage).toEqual({ + promptTokens: 10, + completionTokens: 3, + totalTokens: 13, + }); + expect(result.ttftMs).toBeGreaterThanOrEqual(0); + expect(result.totalMs).toBeGreaterThanOrEqual(0); + }); + + it("leaves usage undefined when the stream has no usage chunk (R59)", async () => { + const result = await emitOpenAiSseAsStreamEvents( + sseResponse([ + JSON.stringify({ choices: [{ delta: { content: "ok" } }] }), + JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] }), + ]), + () => {}, + performance.now() + ); + expect(result.usage).toBeUndefined(); + expect(result.totalMs).toBeGreaterThanOrEqual(0); + }); + + it("sets TTFT on first content delta, not message_start alone", async () => { + const startedAt = performance.now() - 100; + const result = await emitOpenAiSseAsStreamEvents( + sseResponse([ + JSON.stringify({ choices: [{ delta: {} }] }), + JSON.stringify({ choices: [{ delta: { content: "x" } }] }), + JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] }), + ]), + () => {}, + startedAt + ); + expect(result.ttftMs).toBeGreaterThanOrEqual(90); + }); + + it("ignores malformed usage without dropping latency", async () => { + const result = await emitOpenAiSseAsStreamEvents( + sseResponse([ + JSON.stringify({ choices: [{ delta: { content: "a" } }] }), + JSON.stringify({ usage: { prompt_tokens: "nope" } }), + JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] }), + ]), + () => {}, + performance.now() + ); + expect(result.usage).toBeUndefined(); + expect(result.totalMs).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/webui/src/providers/sse.ts b/webui/src/providers/sse.ts index c0b2804..e14df42 100644 --- a/webui/src/providers/sse.ts +++ b/webui/src/providers/sse.ts @@ -1,3 +1,5 @@ +import type { TokenUsage } from "./routing-metadata"; + /** Minimal SSE parser for OpenAI-compatible streaming responses. */ export async function parseSSE( response: Response, @@ -39,23 +41,64 @@ function randomId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } -export function emitOpenAiSseAsStreamEvents( +export interface StreamTimingResult { + usage?: TokenUsage; + ttftMs?: number; + totalMs?: number; +} + +function parseUsage(raw: unknown): TokenUsage | undefined { + if (!raw || typeof raw !== "object") return undefined; + const u = raw as { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + const promptTokens = typeof u.prompt_tokens === "number" ? u.prompt_tokens : undefined; + const completionTokens = + typeof u.completion_tokens === "number" ? u.completion_tokens : undefined; + const totalTokens = typeof u.total_tokens === "number" ? u.total_tokens : undefined; + if ( + promptTokens === undefined && + completionTokens === undefined && + totalTokens === undefined + ) { + return undefined; + } + return { promptTokens, completionTokens, totalTokens }; +} + +/** + * Map an OpenAI-compatible SSE response to murm-ui StreamEvents and capture + * client TTFT/total plus an optional trailing usage chunk (R58/R59). + */ +export async function emitOpenAiSseAsStreamEvents( response: Response, - onEvent: (event: import("murm-ui").StreamEvent) => void -): Promise { + onEvent: (event: import("murm-ui").StreamEvent) => void, + startedAt = performance.now() +): Promise { let messageStarted = false; let currentMessageId = randomId(); let currentTextBlockId: string | null = null; let finishEmitted = false; + let ttftMs: number | undefined; + let usage: TokenUsage | undefined; + + const markFirstToken = (): void => { + if (ttftMs === undefined) { + ttftMs = Math.max(0, performance.now() - startedAt); + } + }; - return parseSSE(response, (data) => { + await parseSSE(response, (data) => { if (data === "[DONE]") return true; let parsed: { choices?: { - delta?: { content?: string }; + delta?: { content?: string; reasoning_content?: string }; finish_reason?: string | null; }[]; + usage?: unknown; }; try { parsed = JSON.parse(data); @@ -63,6 +106,12 @@ export function emitOpenAiSseAsStreamEvents( return; } + // Trailing usage chunk often has no choices (OpenAI stream_options). + const parsedUsage = parseUsage(parsed.usage); + if (parsedUsage) { + usage = parsedUsage; + } + const choice = parsed.choices?.[0]; if (!choice) return; @@ -76,6 +125,7 @@ export function emitOpenAiSseAsStreamEvents( const delta = choice.delta ?? {}; if (delta.content) { + markFirstToken(); if (!currentTextBlockId) currentTextBlockId = randomId(); onEvent({ type: "text_delta", @@ -98,6 +148,12 @@ export function emitOpenAiSseAsStreamEvents( finishEmitted = true; } }); + + return { + usage, + ttftMs, + totalMs: Math.max(0, performance.now() - startedAt), + }; } export function emitTextAsStreamEvents( diff --git a/webui/src/providers/tiers/orchestrator.test.ts b/webui/src/providers/tiers/orchestrator.test.ts index ad918fe..26556a1 100644 --- a/webui/src/providers/tiers/orchestrator.test.ts +++ b/webui/src/providers/tiers/orchestrator.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChatRequest, StreamEvent } from "murm-ui"; import { STORAGE_KEYS, saveJson } from "../../storage-keys"; import { defaultProviderTierSettings } from "./defaults"; +import { RouteTrace } from "../route-trace"; import { TierOrchestrator, searxngTierUnavailable, @@ -177,4 +178,64 @@ describe("TierOrchestrator", () => { await orchestrator.streamChat(baseRequest, () => {}); }); + + it("records skip then success hops on the shared RouteTrace (R61)", async () => { + saveJson(STORAGE_KEYS.providerTiers, { + ...defaultProviderTierSettings(), + tiers: [ + { id: "quality_api", enabled: true }, + { id: "web_ui", enabled: false }, + { id: "searxng_discovery", enabled: false }, + { id: "proxy_failover", enabled: true }, + ], + }); + const trace = new RouteTrace(); + const orchestrator = new TierOrchestrator( + { + qualityApi: vi.fn(async () => { + throw new TierSkipError("quality_api", "no key"); + }), + webUi: vi.fn(), + searxngDiscovery: vi.fn(), + proxyFailover: vi.fn(async (_req, onEvent: (e: StreamEvent) => void) => { + onEvent({ type: "text_delta", delta: "ok" }); + }), + }, + trace + ); + + await orchestrator.streamChat(baseRequest, () => {}); + + const hops = trace.snapshot(); + expect(hops.map((h) => h.outcome)).toEqual(["skip", "success"]); + expect(hops[0].tier).toBe("quality_api"); + expect(hops[0].reason).toMatch(/no key/); + expect(hops[1].tier).toBe("proxy_failover"); + expect(hops[1].hopIndex).toBe(1); + }); + + it("does not double-record success when the handler already wrote hops", async () => { + saveJson(STORAGE_KEYS.providerTiers, defaultProviderTierSettings()); + const trace = new RouteTrace(); + const orchestrator = new TierOrchestrator( + { + qualityApi: vi.fn(async () => { + throw new TierSkipError("quality_api", "no key"); + }), + webUi: vi.fn(), + searxngDiscovery: vi.fn(), + proxyFailover: vi.fn(async () => { + trace.record({ + tier: "proxy_failover", + endpoint: "https://proxy.test", + outcome: "success", + }); + }), + }, + trace + ); + + await orchestrator.streamChat(baseRequest, () => {}); + expect(trace.snapshot().filter((h) => h.tier === "proxy_failover")).toHaveLength(1); + }); }); diff --git a/webui/src/providers/tiers/orchestrator.ts b/webui/src/providers/tiers/orchestrator.ts index 0bd4b46..ea14b2a 100644 --- a/webui/src/providers/tiers/orchestrator.ts +++ b/webui/src/providers/tiers/orchestrator.ts @@ -1,4 +1,5 @@ import type { ChatRequest, StreamEvent } from "murm-ui"; +import { classifyHopError, type RouteTrace } from "../route-trace"; import { loadProviderTierSettings } from "./settings"; import type { TierAttempt, TierId } from "./types"; import { TierOrchestratorError, TierSkipError } from "./types"; @@ -19,7 +20,10 @@ function formatAttemptError(err: unknown): string { } export class TierOrchestrator { - constructor(private readonly handlers: TierHandlers) {} + constructor( + private readonly handlers: TierHandlers, + private readonly trace?: RouteTrace + ) {} async streamChat( request: ChatRequest, @@ -36,13 +40,33 @@ export class TierOrchestrator { try { await handler(request, onEvent); + // Handlers like proxy_failover may already have recorded endpoint hops. + if (this.trace && !this.trace.hasTier(entry.id)) { + this.trace.record({ tier: entry.id, outcome: "success" }); + } return; } catch (err) { if (err instanceof TierSkipError) { attempts.push({ tier: entry.id, error: err.message }); + this.trace?.record({ + tier: entry.id, + outcome: "skip", + errorClass: "skip", + reason: err.message, + }); continue; } attempts.push({ tier: entry.id, error: formatAttemptError(err) }); + // Proxy loop records its own endpoint hops; only add a tier-level error + // when the handler left no trail for this tier. + if (this.trace && !this.trace.hasTier(entry.id)) { + this.trace.record({ + tier: entry.id, + outcome: "error", + errorClass: classifyHopError(err), + reason: formatAttemptError(err), + }); + } if (request.signal.aborted) throw err; } }