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}
+
+
`;
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}
+
+
`;
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 {