From d509740485642f6d68886355222ae19a8e32fbb4 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 25 Jul 2026 00:29:00 -0500 Subject: [PATCH] feat(webui): Wave 3 catalog enrichment, export, and hash routing Surface ranked-catalog metadata in the explorer and picker, add session export from the sidebar, and enable shareable local hash links so the demo feels differentiated without a backend. --- .github/workflows/deploy-pages.yml | 2 +- CONCEPTS.md | 2 + configs/README.md | 2 +- docs/CAVEATS.md | 1 + docs/assets/chat.js | 208 +++++++++++++- docs/assets/shell/chat-overrides.css | 66 ++++- docs/chat-ui-plugins.md | 8 + ...-feat-chat-ui-wave3-catalog-export-plan.md | 270 ++++++++++++++++++ tests/e2e/catalog-explorer.spec.ts | 64 +++++ tests/e2e/export-session.spec.ts | 56 ++++ tests/e2e/hash-routing.spec.ts | 49 ++++ tests/e2e/model-picker.spec.ts | 12 +- webui/shell/chat-overrides.css | 66 ++++- webui/src/catalog-display.test.ts | 42 +++ webui/src/catalog-display.ts | 48 ++++ webui/src/export-session.test.ts | 41 +++ webui/src/export-session.ts | 71 +++++ webui/src/main.ts | 49 +++- webui/src/model-selection.ts | 4 + webui/src/plugins/model-explorer/index.ts | 48 +++- webui/src/plugins/model-picker/index.ts | 39 ++- webui/src/providers/browser-router.ts | 7 + 22 files changed, 1116 insertions(+), 39 deletions(-) create mode 100644 docs/plans/2026-07-24-006-feat-chat-ui-wave3-catalog-export-plan.md create mode 100644 tests/e2e/catalog-explorer.spec.ts create mode 100644 tests/e2e/export-session.spec.ts create mode 100644 tests/e2e/hash-routing.spec.ts create mode 100644 webui/src/catalog-display.test.ts create mode 100644 webui/src/catalog-display.ts create mode 100644 webui/src/export-session.test.ts create mode 100644 webui/src/export-session.ts diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 733cdde..475cd15 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 + 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 - name: Run live Pages chat e2e (real proxy) env: diff --git a/CONCEPTS.md b/CONCEPTS.md index 57ed00b..3ed65ad 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -38,6 +38,8 @@ Shared vocabulary for the static chat gateway and Python library. | **Model selector** | Composer picker over `free_models.json`; separate from Failover endpoint settings | | **Endpoint health probe** | Client-side GET to `/health` (or LiteLLM `/health/liveliness` on Render); ok / slow / fail | | **Turnstile session** | Optional bot check at Worker; 1h KV pass per IP after successful siteverify | +| **Session export** | Sidebar menu download of current chat as Markdown or JSON (client-only; no server copy) | +| **Hash session link** | URL `#/chat/{sessionId}` restores a session from local IndexedDB on the same browser/profile | ## Learnings index diff --git a/configs/README.md b/configs/README.md index f43c02d..0f7f1c6 100644 --- a/configs/README.md +++ b/configs/README.md @@ -36,7 +36,7 @@ Free models sorted by `quality_score` (descending). Example entry: Local-only providers (`ollama`, `vllm`, `lmstudio`, `xinference`) are excluded. -The [GitHub Pages chat UI](../docs/index.html) loads this file for the model browser. +The [GitHub Pages chat UI](../docs/index.html) loads this file for the model browser. The Models panel and composer picker show `quality_score`, formatted `context_length`, and capability badges (`vision`, `tools`, `schema`) derived from the fields above. ### `free_models_ids.txt` diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index 80640bb..6883c43 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -15,6 +15,7 @@ Honest limits for the public chat demo and the Python library. | **Usage stats** | We count sessions and completions without storing message text. This is not full product analytics. | | **Routing headers** | The routing chip reads `x-llm-fallbacks-endpoint` and LiteLLM headers from proxy responses. After edge changes, redeploy the Worker (`Deploy Proxies` workflow) for production to expose them. | | **Turnstile** | Optional bot check when `TURNSTILE_SECRET` is set on the Worker and `turnstileSiteKey` is in `docs/config.js`. Skipped in local dev when secrets are absent. | +| **Hash session links** | `#/chat/{id}` only works when that session exists in your browser’s IndexedDB. Copy the link on another device or after clearing site data and you get a new empty chat — use **Export as Markdown/JSON** to share transcripts. | ## Library and CI diff --git a/docs/assets/chat.js b/docs/assets/chat.js index 80dde14..711545d 100644 --- a/docs/assets/chat.js +++ b/docs/assets/chat.js @@ -5179,6 +5179,9 @@ function modelOptionLabel(entry) { const score = entry.quality_score !== void 0 ? ` \xB7 ${entry.quality_score.toFixed(1)}` : ""; return `${entry.id}${score}`; } +function findCatalogEntry(id) { + return catalogRef.find((entry) => entry.id === id); +} // src/providers/browser-router.ts var RETRYABLE = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]); @@ -6223,6 +6226,46 @@ function ByokSettingsPlugin(deps) { }; } +// src/catalog-display.ts +function formatContextLength(value) { + if (value === void 0 || value <= 0) return "\u2014"; + if (value >= 1e6) { + const millions = value / 1e6; + return millions >= 10 ? `${Math.round(millions)}M` : `${millions.toFixed(1)}M`; + } + if (value >= 1e3) { + const thousands = value / 1e3; + return thousands >= 100 ? `${Math.round(thousands)}K` : `${thousands.toFixed(0)}K`; + } + return String(value); +} +function capabilityBadges(entry) { + const badges = []; + if (entry.supports_vision) badges.push("vision"); + if (entry.supports_function_calling || entry.supports_tool_choice) badges.push("tools"); + if (entry.supports_response_schema) badges.push("schema"); + return badges; +} +function badgeLabel(badge) { + if (badge === "vision") return "vision"; + if (badge === "tools") return "tools"; + return "schema"; +} +function catalogSummaryLine(entry) { + const parts = []; + if (entry.quality_score !== void 0) { + parts.push(`score ${entry.quality_score.toFixed(1)}`); + } + const ctx = formatContextLength(entry.context_length); + if (ctx !== "\u2014") parts.push(`ctx ${ctx}`); + const badges = capabilityBadges(entry); + if (badges.length) parts.push(badges.map(badgeLabel).join(", ")); + return parts.length ? parts.join(" \xB7 ") : entry.id; +} +function renderCapabilityBadgesHtml(entry) { + return capabilityBadges(entry).map((b2) => `${badgeLabel(b2)}`).join(""); +} + // src/plugins/model-explorer/filters.ts function getColumns(catalog) { if (!catalog.length) return []; @@ -6279,6 +6322,13 @@ function sortRows(catalog, column, direction) { } // src/plugins/model-explorer/index.ts +var TABLE_COLUMNS = [ + { key: "id", label: "id" }, + { key: "provider", label: "provider" }, + { key: "quality_score", label: "quality_score" }, + { key: "context_length", label: "context", display: "context" }, + { key: "capabilities", label: "capabilities", display: "capabilities" } +]; function ModelExplorerPlugin(deps) { return { name: "model-explorer", @@ -6328,15 +6378,27 @@ function ModelExplorerPlugin(deps) { columnEl.appendChild(opt); } } + function cellHtml(row, col) { + if (col.display === "context") { + return escapeHtml(formatContextLength(row.context_length)); + } + if (col.display === "capabilities") { + return renderCapabilityBadgesHtml(row) || "\u2014"; + } + if (col.key === "provider") { + const provider = row.provider ?? String(row.id).split("/")[0] ?? ""; + return escapeHtml(provider); + } + return escapeHtml(String(row[col.key] ?? "")); + } function renderTable(rows) { - const cols = ["id", "provider", "mode", "quality_score"].filter( - (c) => rows.length === 0 || c in rows[0] - ); - thead.innerHTML = `${cols.map( - (c) => `${c}${sortColumn === c ? sortDir === "asc" ? " \u25B2" : " \u25BC" : ""}` + thead.innerHTML = `${TABLE_COLUMNS.map( + (c) => `${c.label}${sortColumn === c.key ? sortDir === "asc" ? " \u25B2" : " \u25BC" : ""}` ).join("")}Use`; tbody.innerHTML = rows.slice(0, 200).map( - (row, rowIdx) => `${cols.map((c) => `${escapeHtml(String(row[c] ?? ""))}`).join("")}` + (row, rowIdx) => `${TABLE_COLUMNS.map( + (c) => `${cellHtml(row, c)}` + ).join("")}` ).join(""); statusEl.textContent = `${rows.length} model(s) shown${rows.length > 200 ? " (first 200)" : ""}`; thead.querySelectorAll("th").forEach((th) => { @@ -6346,7 +6408,7 @@ function ModelExplorerPlugin(deps) { if (sortColumn === col) sortDir = sortDir === "asc" ? "desc" : "asc"; else { sortColumn = col; - sortDir = "desc"; + sortDir = col === "quality_score" || col === "context_length" ? "desc" : "asc"; } renderTable(sortRows(rows, sortColumn, sortDir)); }); @@ -6396,9 +6458,22 @@ function escapeHtml(s) { } // src/plugins/model-picker/index.ts -var HELP_TEXT = "`free` = our ranked chain; `openrouter/free` = OpenRouter meta-router"; +var R11_TEXT = "`free` = our ranked chain; `openrouter/free` = OpenRouter meta-router"; +var RANK_HELP_URL = "https://github.com/bodecloud/llm_fallbacks#quality-scoring"; function ModelPickerPlugin() { let selectEl = null; + let detailEl = null; + function syncDetail() { + if (!detailEl) return; + const active = getActiveModel(); + const pinned = getPinnedModels().find((p) => p.id === active); + if (pinned) { + detailEl.textContent = pinned.label; + return; + } + const entry = findCatalogEntry(active); + detailEl.textContent = entry ? catalogSummaryLine(entry) : active; + } function syncSelect() { if (!selectEl) return; const active = getActiveModel(); @@ -6442,24 +6517,35 @@ function ModelPickerPlugin() { const wrapper = document.createElement("div"); wrapper.className = "lf-model-picker-row"; wrapper.innerHTML = ` - -

${HELP_TEXT}

+
+ +

+
+
+

${R11_TEXT}

+ Why this rank? +
`; form.insertBefore(wrapper, form.firstChild); selectEl = wrapper.querySelector(".lf-model-picker-select"); + detailEl = wrapper.querySelector("#lf-model-detail"); if (!selectEl) return; populateOptions(); + syncDetail(); selectEl.addEventListener("change", () => { setActiveModel(selectEl.value); + syncDetail(); }); window.addEventListener(MODEL_CHANGED_EVENT, syncSelect); window.addEventListener(MODEL_CHANGED_EVENT, populateOptions); + window.addEventListener(MODEL_CHANGED_EVENT, syncDetail); }, destroy() { window.removeEventListener(MODEL_CHANGED_EVENT, syncSelect); + window.removeEventListener(MODEL_CHANGED_EVENT, syncDetail); } }; } @@ -6674,6 +6760,59 @@ function bindTopBarButtons() { }); } +// src/export-session.ts +function messageText(message) { + return message.blocks.filter((b2) => b2.type === "text").map((b2) => b2.type === "text" ? b2.text : "").join("").trim(); +} +function toMarkdown(messages, meta) { + const title = meta?.title?.trim() || "Chat export"; + const lines = [`# ${title}`, "", `_Exported from llm-fallbacks_`, ""]; + for (const message of messages) { + const text = messageText(message); + if (!text) continue; + const heading = message.role === "user" ? "## User" : "## Assistant"; + lines.push(heading, "", text, ""); + } + return lines.join("\n").trimEnd() + "\n"; +} +function toJson(messages, meta) { + return JSON.stringify( + { + id: meta.id, + title: meta.title ?? null, + exportedAt: (/* @__PURE__ */ new Date()).toISOString(), + messages: messages.map((m2) => ({ + id: m2.id, + role: m2.role, + text: messageText(m2) + })) + }, + null, + 2 + ); +} +function slugifyTitle(title) { + const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40); + return slug || "chat"; +} +function exportFilename(ext, title) { + const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10); + const slug = slugifyTitle(title || "chat"); + return `llm-fallbacks-${slug}-${date}.${ext}`; +} +function downloadBlob(filename, content, mime) { + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.rel = "noopener"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + // src/main.ts async function loadCatalog(config) { let catalog = []; @@ -6727,7 +6866,48 @@ async function bootstrap() { storage: new IndexedDBStorage(), fullscreen: false, enableSidebar: true, - routing: false, + routing: { type: "hash", pathPrefix: "#/chat/" }, + sidebarMenu: (defaults, ctx) => { + const messages = ctx.engine.state.messages; + const hasMessages = messages.length > 0; + return [ + ...defaults, + { + id: "export-md", + label: "Export as Markdown", + disabled: !hasMessages, + onClick: () => { + if (!hasMessages) return; + const content = toMarkdown(messages, { + id: ctx.session.id, + title: ctx.session.title + }); + downloadBlob( + exportFilename("md", ctx.session.title), + content, + "text/markdown;charset=utf-8" + ); + } + }, + { + id: "export-json", + label: "Export as JSON", + disabled: !hasMessages, + onClick: () => { + if (!hasMessages) return; + const content = toJson(messages, { + id: ctx.session.id, + title: ctx.session.title + }); + downloadBlob( + exportFilename("json", ctx.session.title), + content, + "application/json;charset=utf-8" + ); + } + } + ]; + }, plugins: (engine) => [ CopyPlugin(), ModelPickerPlugin(), diff --git a/docs/assets/shell/chat-overrides.css b/docs/assets/shell/chat-overrides.css index 5274155..2802d5e 100644 --- a/docs/assets/shell/chat-overrides.css +++ b/docs/assets/shell/chat-overrides.css @@ -567,12 +567,46 @@ body.lf-chat-page::before { .lf-model-picker-row { display: flex; flex-wrap: wrap; - align-items: center; + align-items: flex-start; gap: 0.5rem 1rem; padding: 0.35rem 0.5rem 0.5rem; border-bottom: 1px solid rgba(157, 78, 221, 0.12); } +.lf-model-picker-main { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1 1 14rem; + min-width: min(18rem, 70vw); +} + +.lf-model-detail { + margin: 0; + font-size: 0.72rem; + color: #9d9db8; + opacity: 0.9; +} + +.lf-model-picker-info { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.75rem; + flex: 1 1 12rem; +} + +.lf-model-rank-link { + font-size: 0.72rem; + color: #c77dff; + text-decoration: none; + white-space: nowrap; +} + +.lf-model-rank-link:hover { + text-decoration: underline; +} + .lf-model-picker-label { display: flex; align-items: center; @@ -743,3 +777,33 @@ body.lf-chat-page::before { max-width: 100%; } } + +/* ── Wave 3: catalog badges + explorer ──────────────────────── */ +.lf-cap-badge { + display: inline-block; + margin: 0 0.15rem 0.15rem 0; + padding: 0.1rem 0.35rem; + border-radius: 4px; + font-size: 0.65rem; + letter-spacing: 0.02em; + text-transform: uppercase; + background: rgba(157, 78, 221, 0.18); + color: #d4b8ff; + border: 1px solid rgba(157, 78, 221, 0.25); +} + +.lf-cap-vision { + background: rgba(56, 189, 248, 0.12); + color: #7dd3fc; + border-color: rgba(56, 189, 248, 0.28); +} + +.lf-cap-tools { + background: rgba(74, 222, 128, 0.1); + color: #86efac; + border-color: rgba(74, 222, 128, 0.25); +} + +.explorer-table-wrap td { + vertical-align: middle; +} diff --git a/docs/chat-ui-plugins.md b/docs/chat-ui-plugins.md index 7b6902c..e2d12cb 100644 --- a/docs/chat-ui-plugins.md +++ b/docs/chat-ui-plugins.md @@ -35,6 +35,14 @@ Set `APP_VERSION` when building for cache busting (CI sets this from `github.sha | `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 | +Wave 3 adds **catalog enrichment** (context + capability badges in Models panel and composer subtitle), **session export** (sidebar menu → Markdown/JSON), and **hash routing** (`#/chat/{sessionId}` via murm-ui `AppRouter`). + +## Session export and hash links + +- **Export** — Session ⋮ menu → “Export as Markdown” or “Export as JSON”. Serializes text blocks only; empty chats disable the items. +- **Hash routing** — Enabled in `webui/src/main.ts` with `routing: { type: "hash", pathPrefix: "#/chat/" }`. Links are local-only; see [CAVEATS.md](CAVEATS.md). +- **Why this rank?** — Composer link to [README quality scoring](https://github.com/bodecloud/llm_fallbacks#quality-scoring). + Plugins register murm-ui hooks **and** optional slide panels via `registerShellPanel(id, initFn)` — see `webui/src/shell-panels.ts`. ## Add a plugin diff --git a/docs/plans/2026-07-24-006-feat-chat-ui-wave3-catalog-export-plan.md b/docs/plans/2026-07-24-006-feat-chat-ui-wave3-catalog-export-plan.md new file mode 100644 index 0000000..262fa29 --- /dev/null +++ b/docs/plans/2026-07-24-006-feat-chat-ui-wave3-catalog-export-plan.md @@ -0,0 +1,270 @@ +--- +title: "feat: Chat UI Wave 3 — catalog differentiation, export, hash routing" +status: active +date: 2026-07-24 +type: feat +origin: docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md +strategy: STRATEGY.md +wave: 3 +requirements: R10-R14 +prior_plan: docs/plans/2026-07-24-005-feat-chat-ui-wave2-trust-ops-plan.md +--- + +# feat: Chat UI Wave 3 — catalog differentiation, export, hash routing + +> **Origin:** [Chat UI improvements brainstorm](../brainstorms/2026-07-24-chat-ui-improvements-requirements.md) — Wave C (differentiation). Waves A/B are shipped or in PR #13. + +## Summary + +Make the ranked-catalog story visible and the demo shareable without a backend: **richer model metadata** in explorer and picker, a **“why this rank?”** link to README scoring, **export current chat** as Markdown/JSON, and **hash deep links** to sessions via built-in murm-ui routing. + +## Problem Frame + +After Waves 1–2 the chat behaves like a real product (picker, routing chip, health, Turnstile). It still feels generic vs Open WebUI/LibreChat because the **daily ranked catalog** is hidden in a sparse table, sessions are trapped in IndexedDB, and URLs do not survive refresh. STRATEGY keeps static Pages — Wave 3 is client-only differentiation. + +## Requirements (Wave 3 traceability) + +| ID | Source | Wave 3 requirement | +|----|--------|-------------------| +| R10 | Brainstorm | Model entries show **quality score**, **context window**, and **key capabilities** (vision, tools) from `free_models.json` | +| R11 | Brainstorm | UI explains **`free` vs `openrouter/free`** in one sentence from the model selector | +| R12 | Brainstorm | Optional **“why this rank?”** tooltip/link to README quality scoring (no Python port) | +| R13 | Brainstorm | **Export conversation** as Markdown or JSON download from current IndexedDB session | +| R14 | Brainstorm | **Hash routing** `#/chat/{sessionId}` when murm-ui routing enabled — no server sync | + +**Explicitly out of scope (brainstorm deferred):** side-by-side model compare, vision file upload, tool-call/reasoning UI, PWA offline shell, full TS port of discovery. + +**Carryover note:** R11 partial — Wave 1 picker already shows one-line help (`free` vs `openrouter/free`). Wave 3 polishes discoverability (info control + persistent link). + +## Key Technical Decisions + +| ID | Decision | Rationale | +|----|----------|-----------| +| KTD1 | **Extend `CatalogEntry`** with optional artifact fields | `configs/free_models.json` already has `context_length`, `supports_vision`, `supports_function_calling`, etc.; type today only declares `id`, `mode`, `quality_score` | +| KTD2 | **`catalog-display.ts` helper** for badges and formatted context | Single formatter for explorer rows, picker subtitle, and tests — avoids duplicated capability logic | +| KTD3 | **Explorer: capability badges + context column** | Table stays scannable; badges (`vision`, `tools`) as compact pills; context as human-readable (e.g. `1M`, `128K`, `—`) | +| KTD4 | **Picker: subtitle under select** for active model | Shows score + context + badges for selected id without widening dropdown labels | +| KTD5 | **R11/R12: info cluster next to picker** | Short alias sentence + external link to README `#quality-scoring` anchor on GitHub raw/docs | +| KTD6 | **Export via `sidebarMenu` + `export-session.ts`** | murm-ui `sidebarMenu(defaults, ctx)` adds “Export Markdown / JSON”; serializes `ctx.engine.state.messages` + session meta | +| KTD7 | **Enable murm-ui hash router** | `routing: { type: "hash", pathPrefix: "/chat" }` in `ChatUI` config; replaces `routing: false` in `webui/src/main.ts` | +| KTD8 | **R14: no import/share upload** | Hash links work only when recipient has same session in their IndexedDB (document in CAVEATS); export (R13) is the portable share path | +| KTD9 | **Defer model compare** | Brainstorm explicitly deferred; exceeds static budget | + +## High-Level Technical Design + +```mermaid +flowchart TB + subgraph catalog [Catalog UX] + JSON["free_models.json"] + CD[catalog-display.ts] + Explorer[model-explorer] + Picker[model-picker] + JSON --> CD + CD --> Explorer + CD --> Picker + end + + subgraph sessions [Sessions] + Engine[ChatEngine] + IDB[(IndexedDBStorage)] + Export[export-session.ts] + Router[AppRouter hash /chat] + Engine --> IDB + Export --> Engine + Router --> Engine + end +``` + +## Implementation Units + +### U1. Catalog types + display helpers (R10 foundation) + +**Goal:** Typed access to artifact fields and shared formatters. + +**Files:** +- `webui/src/providers/browser-router.ts` — extend `CatalogEntry` +- `webui/src/catalog-display.ts` (new) +- `webui/src/catalog-display.test.ts` (new) + +**Approach:** +- Add optional fields: `context_length`, `max_output_tokens`, `supports_vision`, `supports_function_calling`, `supports_tool_choice`, `quality_score` (already used). +- Export `formatContextLength(n)`, `capabilityBadges(entry)`, `catalogSummaryLine(entry)`. + +**Test scenarios:** +- `1050000` → `1.0M` or similar compact form +- Entry with vision + tools → badges `["vision","tools"]` +- Missing fields → graceful `—` / empty badges + +**Verification:** `cd webui && npm test` + +--- + +### U2. Model Explorer enrichment (R10) + +**Goal:** Explorer table shows differentiation at a glance. + +**Files:** +- `webui/src/plugins/model-explorer/index.ts` +- `webui/shell/chat-overrides.css` — badge styles + +**Approach:** +- Default columns: `id`, `provider`, `quality_score`, `context`, `capabilities`, `Use`. +- `context` cell uses `formatContextLength`; `capabilities` renders badge spans. +- Optional row expand or `title` tooltip with full summary via `catalogSummaryLine` (keep table compact). + +**Test scenarios:** +- Render row with mock entry shows score + context + vision badge. +- Sort by quality_score still works. + +**Verification:** Vitest on render helper if extracted; Playwright explorer spec (U6). + +--- + +### U3. Model picker subtitle + R11/R12 polish (R10–R12) + +**Goal:** Composer shows why ranked catalog matters; alias help is obvious. + +**Files:** +- `webui/src/plugins/model-picker/index.ts` +- `webui/shell/chat-overrides.css` + +**Approach:** +- Add `#lf-model-detail` subtitle updating on `MODEL_CHANGED_EVENT` with `catalogSummaryLine(activeModel)`. +- Replace plain help paragraph with: + - One-line R11 copy (keep existing sentence; tighten if needed). + - Link “Why this rank?” → `https://github.com/bodecloud/llm_fallbacks#quality-scoring` (or repo-relative docs path if embedded). +- Pinned models (`free`, `openrouter/free`) show summary without catalog lookup. + +**Test scenarios:** +- Select catalog model → subtitle shows score + context. +- Help link has accessible label and opens in new tab. + +**Verification:** Extend `tests/e2e/model-picker.spec.ts` or new `catalog-display.spec.ts`. + +--- + +### U4. Export session plugin (R13) + +**Goal:** Download current chat as Markdown or JSON. + +**Files:** +- `webui/src/export-session.ts` (new) +- `webui/src/export-session.test.ts` (new) +- `webui/src/plugins/export-session/index.ts` (new) — or wire via `sidebarMenu` in `webui/src/main.ts` +- `webui/src/main.ts` + +**Approach:** +- `toMarkdown(messages, meta)` — user/assistant turns, plain text from text blocks. +- `toJson(session)` — `{ id, title, exportedAt, messages }`. +- Trigger: murm-ui `sidebarMenu` adds items “Export as Markdown” / “Export as JSON” calling `downloadBlob(filename, content)`. +- Disable when `messages.length === 0`. +- Filename pattern: `llm-fallbacks-{slug(title)}-{date}.md`. + +**Test scenarios:** +- Two-message session → MD contains user + assistant lines. +- JSON parse round-trip preserves roles. +- Empty session → menu items disabled or no-op with hint. + +**Verification:** Vitest on serializers; Playwright export spec (U6). + +--- + +### U5. Hash routing enablement (R14) + +**Goal:** Shareable session URLs on same device; refresh restores session when ID exists locally. + +**Files:** +- `webui/src/main.ts` +- `docs/CAVEATS.md` — hash link limitation (local-only) +- `tests/e2e/hash-routing.spec.ts` (new) + +**Approach:** +- Change `routing: false` → `routing: { type: "hash", pathPrefix: "/chat" }`. +- murm-ui syncs URL on session switch; `initialSessionId` from router on load. +- Document: link works for same browser/profile with that session in IndexedDB; otherwise starts new chat — use Export for cross-device share. + +**Test scenarios:** +- Create chat, note URL contains `#/chat/`. +- Reload page with hash → same session messages visible (mocked provider). +- Unknown session id in hash → new empty session without crash. + +**Verification:** Playwright hash-routing spec. + +--- + +### U6. E2E, docs, CONCEPTS + +**Goal:** CI coverage and operator docs. + +**Files:** +- `tests/e2e/catalog-explorer.spec.ts` (new) +- `tests/e2e/export-session.spec.ts` (new) +- `tests/e2e/hash-routing.spec.ts` (new) +- `.github/workflows/deploy-pages.yml` — add specs to mocked e2e job +- `docs/chat-ui-plugins.md` +- `CONCEPTS.md` — **Session export**, **Hash session link** (if not present) +- `configs/README.md` — note artifact fields surfaced in UI + +**Test scenarios:** +- AE-W3a: Explorer shows capability badge on mocked catalog row. +- AE-W3b: Export MD download triggered (Playwright download event). +- AE-W3c: Hash URL survives reload with messages. + +**Verification:** Full mocked e2e green in deploy-pages CI. + +## Sequencing + +```mermaid +flowchart LR + U1[U1 catalog-display] --> U2[U2 explorer] + U1 --> U3[U3 picker R11-R12] + U1 --> U6[U6 e2e docs] + U4[U4 export] --> U6 + U5[U5 hash routing] --> U6 + U2 --> U6 + U3 --> U6 +``` + +**Recommended order:** U1 → U2 → U3 → U4 → U5 → U6 + +Export and routing are independent; routing last avoids e2e flake while other UI lands. + +## Scope Boundaries + +**In scope:** `webui/`, Playwright e2e, docs/CAVEATS, CONCEPTS. + +**Out of scope:** edge changes, compare mode, file upload, cloud session sync, README scoring algorithm changes. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Wave 2 not merged yet | Branch from `main`; rebase on `feat/chat-ui-wave2` or merge PR #13 first | +| Large `free_models.json` slows explorer | Keep 200-row cap; badges are string ops only | +| Hash links confuse users expecting cloud share | CAVEATS + export as primary share path | +| murm-ui routing breaks embedded layout | Test mobile + desktop; hash prefix `/chat` matches brainstorm Q2 default | + +## Acceptance Examples + +- **AE1.** Visitor opens Models panel and sees quality score, context, and vision/tools badges without reading raw JSON. +- **AE2.** Picker shows “Why this rank?” linking to README quality scoring. +- **AE3.** User exports chat as `.md` and gets readable transcript. +- **AE4.** Copying URL after chat and refreshing reopens the same session (same browser). +- **AE5.** `free` vs `openrouter/free` explanation visible from composer without opening Server settings. + +## Open Questions + +| ID | Question | Owner | Default | +|----|----------|-------|---------| +| Q1 | Export in sidebar menu vs top bar? | U4 | Sidebar menu (murm-ui native) | +| Q2 | README link: github.com vs pages URL? | U3 | GitHub README `#quality-scoring` anchor | +| Q3 | Show `max_output_tokens` in explorer? | U2 | Omit unless column space; tooltip only | + +## Sources / Research + +- `docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md` — Wave C, R10–R14 +- `configs/free_models.json` — artifact schema (verified fields) +- `README.md` — Quality scoring section +- `webui/node_modules/murm-ui/dist/router.d.ts` — `{ type: "hash", pathPrefix }` +- `webui/src/plugins/model-picker/index.ts` — existing R11 help text +- Prior waves: `docs/plans/2026-07-24-004-feat-chat-ui-wave1-ux-plan.md`, `docs/plans/2026-07-24-005-feat-chat-ui-wave2-trust-ops-plan.md` diff --git a/tests/e2e/catalog-explorer.spec.ts b/tests/e2e/catalog-explorer.spec.ts new file mode 100644 index 0000000..6bad221 --- /dev/null +++ b/tests/e2e/catalog-explorer.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from "@playwright/test"; +import { + DEMO_PROXY, + installLocalChatBundle, + installTestConfigMock, +} from "./helpers"; + +const MOCK_CATALOG = [ + { + id: "google/gemini-2.0-flash", + provider: "google", + quality_score: 88.5, + context_length: 1_050_000, + supports_vision: true, + supports_function_calling: true, + }, +]; + +async function installCatalogMock(page: import("@playwright/test").Page) { + await page.route("**/free_models.json", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(MOCK_CATALOG), + }); + }); +} + +async function installProxyMock(page: import("@playwright/test").Page) { + await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream; charset=utf-8", + body: + 'data: {"choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":null}]}\n\n' + + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n' + + "data: [DONE]\n\n", + }); + }); +} + +test.describe("Wave 3 — catalog explorer", () => { + test.beforeEach(async ({ page }) => { + await installTestConfigMock(page); + await installCatalogMock(page); + await installProxyMock(page); + await installLocalChatBundle(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 }); + }); + + test("explorer shows context and capability badges", async ({ page }) => { + await page.locator("#explorerSetting").click(); + await expect(page.locator("#explorer-table")).toBeVisible({ timeout: 15_000 }); + + const row = page.locator("#explorer-table tbody tr").first(); + await expect(row).toContainText("google/gemini-2.0-flash"); + await expect(row.locator("td").nth(3)).toHaveText("1.1M"); + await expect(row.locator(".lf-cap-vision")).toBeVisible(); + await expect(row.locator(".lf-cap-tools")).toBeVisible(); + }); +}); diff --git a/tests/e2e/export-session.spec.ts b/tests/e2e/export-session.spec.ts new file mode 100644 index 0000000..74e0aaa --- /dev/null +++ b/tests/e2e/export-session.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from "@playwright/test"; +import { + DEMO_PROXY, + installLocalChatBundle, + installTestConfigMock, + waitForAssistantText, +} from "./helpers"; + +async function installProxyMock(page: import("@playwright/test").Page) { + await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream; charset=utf-8", + body: + 'data: {"choices":[{"index":0,"delta":{"content":"export test reply"},"finish_reason":null}]}\n\n' + + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n' + + "data: [DONE]\n\n", + }); + }); +} + +test.describe("Wave 3 — export session", () => { + test.beforeEach(async ({ page }) => { + await installTestConfigMock(page); + await installProxyMock(page); + await installLocalChatBundle(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 }); + }); + + test("exports markdown from sidebar menu", async ({ page }) => { + await page.locator("#chatinput").fill("export me"); + await page.locator("#sendbutton").click(); + await waitForAssistantText(page, 30_000); + + await page.evaluate(() => { + document.querySelector("#chatMount")?.classList.remove("mur-sidebar-closed"); + localStorage.setItem("mur_sidebar_closed", "false"); + }); + + const downloadPromise = page.waitForEvent("download", { timeout: 15_000 }); + await page.locator(".mur-sidebar-item-link").first().focus(); + await page.locator(".mur-sidebar-options-btn").first().dispatchEvent("click"); + await expect(page.getByRole("menuitem", { name: "Export as Markdown" })).toBeVisible({ + timeout: 5_000, + }); + await page.getByRole("menuitem", { name: "Export as Markdown" }).click(); + const download = await downloadPromise; + + expect(download.suggestedFilename()).toMatch(/llm-fallbacks-.*\.md$/); + const path = await download.path(); + expect(path).toBeTruthy(); + }); +}); diff --git a/tests/e2e/hash-routing.spec.ts b/tests/e2e/hash-routing.spec.ts new file mode 100644 index 0000000..88be1f9 --- /dev/null +++ b/tests/e2e/hash-routing.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from "@playwright/test"; +import { + DEMO_PROXY, + installLocalChatBundle, + installTestConfigMock, + waitForAssistantText, +} from "./helpers"; + +async function installProxyMock(page: import("@playwright/test").Page) { + await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream; charset=utf-8", + body: + 'data: {"choices":[{"index":0,"delta":{"content":"hash routing ok"},"finish_reason":null}]}\n\n' + + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n' + + "data: [DONE]\n\n", + }); + }); +} + +test.describe("Wave 3 — hash routing", () => { + test.beforeEach(async ({ page }) => { + await installTestConfigMock(page); + await installProxyMock(page); + await installLocalChatBundle(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 }); + }); + + test("hash URL survives reload with messages", async ({ page }) => { + await page.locator("#chatinput").fill("persist this chat"); + await page.locator("#sendbutton").click(); + await waitForAssistantText(page, 30_000); + + await expect.poll(() => page.url()).toMatch(/#\/chat\//); + + const url = page.url(); + await page.goto(url, { waitUntil: "domcontentloaded" }); + await expect(page.locator(".mur-message-user").last()).toContainText("persist this chat", { + timeout: 30_000, + }); + await expect(page.locator(".mur-message-assistant").last()).toContainText(/hash routing ok/i, { + timeout: 30_000, + }); + }); +}); diff --git a/tests/e2e/model-picker.spec.ts b/tests/e2e/model-picker.spec.ts index d120d8d..34a3899 100644 --- a/tests/e2e/model-picker.spec.ts +++ b/tests/e2e/model-picker.spec.ts @@ -7,7 +7,7 @@ import { } from "./helpers"; const MOCK_CATALOG = [ - { id: "groq/llama-3.3-70b", provider: "groq", quality_score: 9.2 }, + { id: "groq/llama-3.3-70b", provider: "groq", quality_score: 9.2, context_length: 128000, supports_vision: true }, { id: "google/gemini-2.0-flash", provider: "google", quality_score: 8.5 }, ]; @@ -78,6 +78,16 @@ test.describe("Wave 1 — model picker", () => { await page.locator("#sendbutton").click(); await expect.poll(() => capturedModel, { timeout: 30_000 }).toBe("groq/llama-3.3-70b"); }); + + test("shows model detail subtitle and rank link", async ({ page }) => { + const picker = page.locator(".lf-model-picker-select"); + await picker.selectOption("groq/llama-3.3-70b"); + const detail = page.locator("#lf-model-detail"); + await expect(detail).toContainText(/score|ctx|vision/i); + const rankLink = page.locator(".lf-model-rank-link"); + await expect(rankLink).toHaveAttribute("href", /quality-scoring/); + await expect(rankLink).toHaveAttribute("target", "_blank"); + }); }); test.describe("Wave 1 — routing chip", () => { diff --git a/webui/shell/chat-overrides.css b/webui/shell/chat-overrides.css index 5274155..2802d5e 100644 --- a/webui/shell/chat-overrides.css +++ b/webui/shell/chat-overrides.css @@ -567,12 +567,46 @@ body.lf-chat-page::before { .lf-model-picker-row { display: flex; flex-wrap: wrap; - align-items: center; + align-items: flex-start; gap: 0.5rem 1rem; padding: 0.35rem 0.5rem 0.5rem; border-bottom: 1px solid rgba(157, 78, 221, 0.12); } +.lf-model-picker-main { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1 1 14rem; + min-width: min(18rem, 70vw); +} + +.lf-model-detail { + margin: 0; + font-size: 0.72rem; + color: #9d9db8; + opacity: 0.9; +} + +.lf-model-picker-info { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.75rem; + flex: 1 1 12rem; +} + +.lf-model-rank-link { + font-size: 0.72rem; + color: #c77dff; + text-decoration: none; + white-space: nowrap; +} + +.lf-model-rank-link:hover { + text-decoration: underline; +} + .lf-model-picker-label { display: flex; align-items: center; @@ -743,3 +777,33 @@ body.lf-chat-page::before { max-width: 100%; } } + +/* ── Wave 3: catalog badges + explorer ──────────────────────── */ +.lf-cap-badge { + display: inline-block; + margin: 0 0.15rem 0.15rem 0; + padding: 0.1rem 0.35rem; + border-radius: 4px; + font-size: 0.65rem; + letter-spacing: 0.02em; + text-transform: uppercase; + background: rgba(157, 78, 221, 0.18); + color: #d4b8ff; + border: 1px solid rgba(157, 78, 221, 0.25); +} + +.lf-cap-vision { + background: rgba(56, 189, 248, 0.12); + color: #7dd3fc; + border-color: rgba(56, 189, 248, 0.28); +} + +.lf-cap-tools { + background: rgba(74, 222, 128, 0.1); + color: #86efac; + border-color: rgba(74, 222, 128, 0.25); +} + +.explorer-table-wrap td { + vertical-align: middle; +} diff --git a/webui/src/catalog-display.test.ts b/webui/src/catalog-display.test.ts new file mode 100644 index 0000000..ae6ee0c --- /dev/null +++ b/webui/src/catalog-display.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + capabilityBadges, + catalogSummaryLine, + formatContextLength, +} from "./catalog-display"; +import type { CatalogEntry } from "./providers/browser-router"; + +describe("catalog-display", () => { + it("formats millions context", () => { + expect(formatContextLength(1_050_000)).toBe("1.1M"); + }); + + it("formats thousands context", () => { + expect(formatContextLength(128_000)).toBe("128K"); + }); + + it("returns dash for missing context", () => { + expect(formatContextLength(0)).toBe("—"); + }); + + it("builds capability badges", () => { + const entry: CatalogEntry = { + id: "test/model", + supports_vision: true, + supports_function_calling: true, + }; + expect(capabilityBadges(entry)).toEqual(["vision", "tools"]); + }); + + it("builds summary line", () => { + const entry: CatalogEntry = { + id: "groq/llama", + quality_score: 9.2, + context_length: 128_000, + supports_vision: true, + }; + expect(catalogSummaryLine(entry)).toMatch(/score 9\.2/); + expect(catalogSummaryLine(entry)).toMatch(/128K/); + expect(catalogSummaryLine(entry)).toMatch(/vision/); + }); +}); diff --git a/webui/src/catalog-display.ts b/webui/src/catalog-display.ts new file mode 100644 index 0000000..8516d49 --- /dev/null +++ b/webui/src/catalog-display.ts @@ -0,0 +1,48 @@ +import type { CatalogEntry } from "./providers/browser-router"; + +export type CapabilityBadge = "vision" | "tools" | "schema"; + +export function formatContextLength(value: number | undefined): string { + if (value === undefined || value <= 0) return "—"; + if (value >= 1_000_000) { + const millions = value / 1_000_000; + return millions >= 10 ? `${Math.round(millions)}M` : `${millions.toFixed(1)}M`; + } + if (value >= 1000) { + const thousands = value / 1000; + return thousands >= 100 ? `${Math.round(thousands)}K` : `${thousands.toFixed(0)}K`; + } + return String(value); +} + +export function capabilityBadges(entry: CatalogEntry): CapabilityBadge[] { + const badges: CapabilityBadge[] = []; + if (entry.supports_vision) badges.push("vision"); + if (entry.supports_function_calling || entry.supports_tool_choice) badges.push("tools"); + if (entry.supports_response_schema) badges.push("schema"); + return badges; +} + +export function badgeLabel(badge: CapabilityBadge): string { + if (badge === "vision") return "vision"; + if (badge === "tools") return "tools"; + return "schema"; +} + +export function catalogSummaryLine(entry: CatalogEntry): string { + const parts: string[] = []; + if (entry.quality_score !== undefined) { + parts.push(`score ${entry.quality_score.toFixed(1)}`); + } + const ctx = formatContextLength(entry.context_length); + if (ctx !== "—") parts.push(`ctx ${ctx}`); + const badges = capabilityBadges(entry); + if (badges.length) parts.push(badges.map(badgeLabel).join(", ")); + return parts.length ? parts.join(" · ") : entry.id; +} + +export function renderCapabilityBadgesHtml(entry: CatalogEntry): string { + return capabilityBadges(entry) + .map((b) => `${badgeLabel(b)}`) + .join(""); +} diff --git a/webui/src/export-session.test.ts b/webui/src/export-session.test.ts new file mode 100644 index 0000000..ca1b76f --- /dev/null +++ b/webui/src/export-session.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { exportFilename, toJson, toMarkdown } from "./export-session"; + +describe("export-session", () => { + it("serializes markdown with roles", () => { + const md = toMarkdown( + [ + { + id: "1", + role: "user", + blocks: [{ type: "text", text: "Hello" }], + }, + { + id: "2", + role: "assistant", + blocks: [{ type: "text", text: "Hi there" }], + }, + ], + { id: "sess-1", title: "Test chat" } + ); + expect(md).toContain("# Test chat"); + expect(md).toContain("## User"); + expect(md).toContain("Hello"); + expect(md).toContain("## Assistant"); + expect(md).toContain("Hi there"); + }); + + it("serializes json", () => { + const json = toJson( + [{ id: "1", role: "user", blocks: [{ type: "text", text: "Ping" }] }], + { id: "abc", title: "Ping" } + ); + const parsed = JSON.parse(json) as { id: string; messages: { role: string }[] }; + expect(parsed.id).toBe("abc"); + expect(parsed.messages[0].role).toBe("user"); + }); + + it("builds filename", () => { + expect(exportFilename("md", "My Chat!")).toMatch(/^llm-fallbacks-my-chat-\d{4}-\d{2}-\d{2}\.md$/); + }); +}); diff --git a/webui/src/export-session.ts b/webui/src/export-session.ts new file mode 100644 index 0000000..6b690c8 --- /dev/null +++ b/webui/src/export-session.ts @@ -0,0 +1,71 @@ +import type { Message } from "murm-ui"; + +export interface ExportSessionMeta { + id: string; + title?: string; +} + +function messageText(message: Message): string { + return message.blocks + .filter((b) => b.type === "text") + .map((b) => (b.type === "text" ? b.text : "")) + .join("") + .trim(); +} + +export function toMarkdown(messages: readonly Message[], meta?: ExportSessionMeta): string { + const title = meta?.title?.trim() || "Chat export"; + const lines = [`# ${title}`, "", `_Exported from llm-fallbacks_`, ""]; + for (const message of messages) { + const text = messageText(message); + if (!text) continue; + const heading = message.role === "user" ? "## User" : "## Assistant"; + lines.push(heading, "", text, ""); + } + return lines.join("\n").trimEnd() + "\n"; +} + +export function toJson(messages: readonly Message[], meta: ExportSessionMeta): string { + return JSON.stringify( + { + id: meta.id, + title: meta.title ?? null, + exportedAt: new Date().toISOString(), + messages: messages.map((m) => ({ + id: m.id, + role: m.role, + text: messageText(m), + })), + }, + null, + 2 + ); +} + +export function slugifyTitle(title: string): string { + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); + return slug || "chat"; +} + +export function exportFilename(ext: "md" | "json", title?: string): string { + const date = new Date().toISOString().slice(0, 10); + const slug = slugifyTitle(title || "chat"); + return `llm-fallbacks-${slug}-${date}.${ext}`; +} + +export function downloadBlob(filename: string, content: string, mime: string): void { + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.rel = "noopener"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} diff --git a/webui/src/main.ts b/webui/src/main.ts index f7f2dd6..8fc3142 100644 --- a/webui/src/main.ts +++ b/webui/src/main.ts @@ -22,6 +22,12 @@ import { trackSessionEvent, } from "./analytics"; import { bindTopBarButtons, initShellPanels } from "./shell-panels"; +import { + downloadBlob, + exportFilename, + toJson, + toMarkdown, +} from "./export-session"; async function loadCatalog(config: AppConfig): Promise<{ catalog: CatalogEntry[]; @@ -88,7 +94,48 @@ async function bootstrap(): Promise { storage: new IndexedDBStorage(), fullscreen: false, enableSidebar: true, - routing: false, + routing: { type: "hash", pathPrefix: "#/chat/" }, + sidebarMenu: (defaults, ctx) => { + const messages = ctx.engine.state.messages; + const hasMessages = messages.length > 0; + return [ + ...defaults, + { + id: "export-md", + label: "Export as Markdown", + disabled: !hasMessages, + onClick: () => { + if (!hasMessages) return; + const content = toMarkdown(messages, { + id: ctx.session.id, + title: ctx.session.title, + }); + downloadBlob( + exportFilename("md", ctx.session.title), + content, + "text/markdown;charset=utf-8" + ); + }, + }, + { + id: "export-json", + label: "Export as JSON", + disabled: !hasMessages, + onClick: () => { + if (!hasMessages) return; + const content = toJson(messages, { + id: ctx.session.id, + title: ctx.session.title, + }); + downloadBlob( + exportFilename("json", ctx.session.title), + content, + "application/json;charset=utf-8" + ); + }, + }, + ]; + }, plugins: (engine) => [ CopyPlugin(), ModelPickerPlugin(), diff --git a/webui/src/model-selection.ts b/webui/src/model-selection.ts index 9b4ae90..c2d5ddb 100644 --- a/webui/src/model-selection.ts +++ b/webui/src/model-selection.ts @@ -53,3 +53,7 @@ export function modelOptionLabel(entry: CatalogEntry): string { entry.quality_score !== undefined ? ` · ${entry.quality_score.toFixed(1)}` : ""; return `${entry.id}${score}`; } + +export function findCatalogEntry(id: string): CatalogEntry | undefined { + return catalogRef.find((entry) => entry.id === id); +} diff --git a/webui/src/plugins/model-explorer/index.ts b/webui/src/plugins/model-explorer/index.ts index d512df8..c10c1a4 100644 --- a/webui/src/plugins/model-explorer/index.ts +++ b/webui/src/plugins/model-explorer/index.ts @@ -1,8 +1,21 @@ import type { ChatPlugin } from "murm-ui"; +import { + formatContextLength, + renderCapabilityBadgesHtml, + catalogSummaryLine, +} from "../../catalog-display"; import { getColumns, applyFilter, sortRows, type FilterState } from "./filters"; import type { CatalogEntry } from "../../providers/browser-router"; import { setActiveModel } from "../../model-selection"; +const TABLE_COLUMNS = [ + { key: "id", label: "id" }, + { key: "provider", label: "provider" }, + { key: "quality_score", label: "quality_score" }, + { key: "context_length", label: "context", display: "context" as const }, + { key: "capabilities", label: "capabilities", display: "capabilities" as const }, +] as const; + export function ModelExplorerPlugin(deps: { getCatalog: () => CatalogEntry[]; getCatalogUrl: () => string; @@ -59,23 +72,32 @@ export function ModelExplorerPlugin(deps: { } } + function cellHtml(row: CatalogEntry, col: (typeof TABLE_COLUMNS)[number]): string { + if (col.display === "context") { + return escapeHtml(formatContextLength(row.context_length)); + } + if (col.display === "capabilities") { + return renderCapabilityBadgesHtml(row) || "—"; + } + if (col.key === "provider") { + const provider = row.provider ?? String(row.id).split("/")[0] ?? ""; + return escapeHtml(provider); + } + return escapeHtml(String((row as Record)[col.key] ?? "")); + } + function renderTable(rows: CatalogEntry[]) { - const cols = ["id", "provider", "mode", "quality_score"].filter( - (c) => rows.length === 0 || c in (rows[0] as object) - ); - thead.innerHTML = `${cols - .map( - (c) => - `${c}${sortColumn === c ? (sortDir === "asc" ? " ▲" : " ▼") : ""}` - ) - .join("")}Use`; + thead.innerHTML = `${TABLE_COLUMNS.map( + (c) => + `${c.label}${sortColumn === c.key ? (sortDir === "asc" ? " ▲" : " ▼") : ""}` + ).join("")}Use`; tbody.innerHTML = rows .slice(0, 200) .map( (row, rowIdx) => - `${cols - .map((c) => `${escapeHtml(String((row as Record)[c] ?? ""))}`) - .join("")}` + `${TABLE_COLUMNS.map( + (c) => `${cellHtml(row, c)}` + ).join("")}` ) .join(""); statusEl.textContent = `${rows.length} model(s) shown${rows.length > 200 ? " (first 200)" : ""}`; @@ -86,7 +108,7 @@ export function ModelExplorerPlugin(deps: { if (sortColumn === col) sortDir = sortDir === "asc" ? "desc" : "asc"; else { sortColumn = col; - sortDir = "desc"; + sortDir = col === "quality_score" || col === "context_length" ? "desc" : "asc"; } renderTable(sortRows(rows, sortColumn, sortDir)); }); diff --git a/webui/src/plugins/model-picker/index.ts b/webui/src/plugins/model-picker/index.ts index 695d1e7..c982287 100644 --- a/webui/src/plugins/model-picker/index.ts +++ b/webui/src/plugins/model-picker/index.ts @@ -1,5 +1,7 @@ import type { ChatPlugin } from "murm-ui"; +import { catalogSummaryLine } from "../../catalog-display"; import { + findCatalogEntry, getActiveModel, getCatalogModels, getPinnedModels, @@ -8,11 +10,25 @@ import { setActiveModel, } from "../../model-selection"; -const HELP_TEXT = +const R11_TEXT = "`free` = our ranked chain; `openrouter/free` = OpenRouter meta-router"; +const RANK_HELP_URL = "https://github.com/bodecloud/llm_fallbacks#quality-scoring"; export function ModelPickerPlugin(): ChatPlugin { let selectEl: HTMLSelectElement | null = null; + let detailEl: HTMLElement | null = null; + + function syncDetail(): void { + if (!detailEl) return; + const active = getActiveModel(); + const pinned = getPinnedModels().find((p) => p.id === active); + if (pinned) { + detailEl.textContent = pinned.label; + return; + } + const entry = findCatalogEntry(active); + detailEl.textContent = entry ? catalogSummaryLine(entry) : active; + } function syncSelect(): void { if (!selectEl) return; @@ -60,27 +76,38 @@ export function ModelPickerPlugin(): ChatPlugin { const wrapper = document.createElement("div"); wrapper.className = "lf-model-picker-row"; wrapper.innerHTML = ` - -

${HELP_TEXT}

+
+ +

+
+
+

${R11_TEXT}

+ Why this rank? +
`; form.insertBefore(wrapper, form.firstChild); selectEl = wrapper.querySelector(".lf-model-picker-select"); + detailEl = wrapper.querySelector("#lf-model-detail"); if (!selectEl) return; populateOptions(); + syncDetail(); selectEl.addEventListener("change", () => { setActiveModel(selectEl!.value); + syncDetail(); }); window.addEventListener(MODEL_CHANGED_EVENT, syncSelect); window.addEventListener(MODEL_CHANGED_EVENT, populateOptions); + window.addEventListener(MODEL_CHANGED_EVENT, syncDetail); }, destroy() { window.removeEventListener(MODEL_CHANGED_EVENT, syncSelect); + window.removeEventListener(MODEL_CHANGED_EVENT, syncDetail); }, }; } diff --git a/webui/src/providers/browser-router.ts b/webui/src/providers/browser-router.ts index da686b8..fe0d222 100644 --- a/webui/src/providers/browser-router.ts +++ b/webui/src/providers/browser-router.ts @@ -32,8 +32,15 @@ export type ApiKeys = Record; export interface CatalogEntry { id: string; + provider?: string; mode?: string; quality_score?: number; + context_length?: number; + max_output_tokens?: number; + supports_vision?: boolean; + supports_function_calling?: boolean; + supports_tool_choice?: boolean; + supports_response_schema?: boolean; } export function loadKeys(): ApiKeys {