From e35cf9fbaee7e8faac741c848ece644202df2f4b Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 25 Jul 2026 01:21:16 -0500 Subject: [PATCH] feat(webui): Wave 4 streaming polish, import, and shortcuts Closes the remaining "basic chat" gaps: plain-text streaming tail via murm-ui patch, conversation import symmetry with export, copy session link, empty-state copy, and keyboard shortcuts sheet. --- .github/workflows/deploy-pages.yml | 2 +- CONCEPTS.md | 1 + docs/assets/chat.js | 290 ++++++- docs/assets/shell/chat-overrides.css | 157 +++- ...07-25-chat-ui-wave4-polish-requirements.md | 113 +++ docs/chat-ui-plugins.md | 8 +- ...7-25-001-feat-chat-ui-wave4-polish-plan.md | 307 ++++++++ tests/e2e/import-session.spec.ts | 67 ++ tests/e2e/shortcuts-sheet.spec.ts | 33 + tests/e2e/streaming-polish.spec.ts | 42 + webui/package-lock.json | 740 ++++++++++++++++++ webui/package.json | 4 +- webui/patches/murm-ui+0.2.0.patch | 34 + webui/shell/chat-overrides.css | 157 +++- webui/src/import-session.test.ts | 57 ++ webui/src/import-session.ts | 107 +++ webui/src/main.ts | 69 +- webui/src/plugins/shortcuts-sheet/index.ts | 125 +++ webui/src/plugins/status-strip/index.ts | 11 + 19 files changed, 2308 insertions(+), 16 deletions(-) create mode 100644 docs/brainstorms/2026-07-25-chat-ui-wave4-polish-requirements.md create mode 100644 docs/plans/2026-07-25-001-feat-chat-ui-wave4-polish-plan.md create mode 100644 tests/e2e/import-session.spec.ts create mode 100644 tests/e2e/shortcuts-sheet.spec.ts create mode 100644 tests/e2e/streaming-polish.spec.ts create mode 100644 webui/patches/murm-ui+0.2.0.patch create mode 100644 webui/src/import-session.test.ts create mode 100644 webui/src/import-session.ts create mode 100644 webui/src/plugins/shortcuts-sheet/index.ts diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 475cd15..39f3fea 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -127,7 +127,7 @@ jobs: run: | cd webui && npm ci && npm run build cd .. - npx playwright test tests/e2e/pages-chat.spec.ts tests/e2e/failover-dual-endpoint.spec.ts tests/e2e/model-picker.spec.ts tests/e2e/message-actions.spec.ts tests/e2e/health-panel.spec.ts tests/e2e/turnstile-gate.spec.ts tests/e2e/catalog-explorer.spec.ts tests/e2e/export-session.spec.ts tests/e2e/hash-routing.spec.ts + npx playwright test tests/e2e/pages-chat.spec.ts tests/e2e/failover-dual-endpoint.spec.ts tests/e2e/model-picker.spec.ts tests/e2e/message-actions.spec.ts tests/e2e/health-panel.spec.ts tests/e2e/turnstile-gate.spec.ts tests/e2e/catalog-explorer.spec.ts tests/e2e/export-session.spec.ts tests/e2e/hash-routing.spec.ts tests/e2e/import-session.spec.ts tests/e2e/shortcuts-sheet.spec.ts tests/e2e/streaming-polish.spec.ts - name: Run live Pages chat e2e (real proxy) env: diff --git a/CONCEPTS.md b/CONCEPTS.md index 3ed65ad..1ec6a22 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -40,6 +40,7 @@ Shared vocabulary for the static chat gateway and Python library. | **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 | +| **Conversation import** | File-picker restore of exported Markdown/JSON into a new local session (symmetry with session export) | ## Learnings index diff --git a/docs/assets/chat.js b/docs/assets/chat.js index 711545d..dbc11c3 100644 --- a/docs/assets/chat.js +++ b/docs/assets/chat.js @@ -3098,7 +3098,6 @@ function isSafeUrl(url, allowedPrefixes) { } // node_modules/murm-ui/dist/components/message-node.js -var MARKDOWN_THROTTLE_MS = 70; var MessageNode = class { constructor(msg, config) { this.config = config; @@ -3229,17 +3228,22 @@ var MessageNode = class { clearTimeout(state.timer); state.timer = void 0; } + state.plainEl = void 0; state.renderSeq++; void this.applyMarkdown(block.id, block.text, state.renderSeq); return; } - if (state.timer) - return; - state.timer = window.setTimeout(() => { + if (state.timer) { + clearTimeout(state.timer); state.timer = void 0; - state.renderSeq++; - void this.applyMarkdown(block.id, block.text, state.renderSeq); - }, MARKDOWN_THROTTLE_MS); + } + if (!state.plainEl) { + state.container.replaceChildren(); + state.plainEl = document.createElement("div"); + state.plainEl.className = "mur-streaming-text"; + state.container.appendChild(state.plainEl); + } + state.plainEl.textContent = block.text; } renderFileBlock(block, container) { if (container.hasChildNodes()) @@ -5500,6 +5504,16 @@ function showRateLimitBanner(seconds) { const suffix = seconds !== void 0 && seconds > 0 ? ` Try again in ${seconds} second${seconds === 1 ? "" : "s"}.` : " Wait and try again."; mount.innerHTML = `Rate limited.${suffix}`; } +function showStatusMessage(text, durationMs = 3500) { + const mount = document.getElementById("lfStatusStrip"); + if (!mount) return; + mount.innerHTML = `${text}`; + window.setTimeout(() => { + if (mount.querySelector(".lf-status-text")?.textContent === text) { + void refreshStatusStrip(mount); + } + }, durationMs); +} // src/providers/errors.ts var ChatRouteError = class extends Error { @@ -6813,6 +6827,210 @@ function downloadBlob(filename, content, mime) { URL.revokeObjectURL(url); } +// src/import-session.ts +var ImportError = class extends Error { + constructor(message) { + super(message); + this.name = "ImportError"; + } +}; +function newMessage(role, text) { + const id = crypto.randomUUID(); + const now = Date.now(); + return { + id, + role, + blocks: [{ id: crypto.randomUUID(), type: "text", text }], + runId: id, + createdAt: now, + updatedAt: now + }; +} +function parseJsonExport(text) { + let parsed; + try { + parsed = JSON.parse(text); + } catch { + throw new ImportError("Invalid JSON \u2014 could not parse file."); + } + if (!parsed || typeof parsed !== "object") { + throw new ImportError("Invalid JSON \u2014 expected an object with a messages array."); + } + const payload = parsed; + if (!Array.isArray(payload.messages) || payload.messages.length === 0) { + throw new ImportError("Invalid JSON \u2014 messages array is missing or empty."); + } + const messages = []; + for (const item of payload.messages) { + if (!item || typeof item !== "object") continue; + const role = item.role; + const msgText = typeof item.text === "string" ? item.text.trim() : ""; + if (role !== "user" && role !== "assistant") { + throw new ImportError(`Invalid JSON \u2014 unknown role "${String(role)}".`); + } + if (!msgText) continue; + messages.push(newMessage(role, msgText)); + } + if (messages.length === 0) { + throw new ImportError("No messages with text found in JSON export."); + } + return messages; +} +function parseMarkdownExport(text) { + const trimmed = text.trim(); + if (!trimmed) { + throw new ImportError("Markdown file is empty."); + } + const sections = trimmed.split(/^##\s+(User|Assistant)\s*$/im); + if (sections.length < 3) { + throw new ImportError("Could not find ## User / ## Assistant headings in Markdown."); + } + const messages = []; + for (let i = 1; i < sections.length; i += 2) { + const roleLabel = sections[i]?.toLowerCase(); + const content = (sections[i + 1] ?? "").trim(); + if (!content) continue; + const role = roleLabel === "user" ? "user" : "assistant"; + messages.push(newMessage(role, content)); + } + if (messages.length === 0) { + throw new ImportError("No message content found under headings."); + } + return messages; +} +function parseExportText(text, filename) { + if (filename.toLowerCase().endsWith(".json")) { + return parseJsonExport(text); + } + return parseMarkdownExport(text); +} +async function importMessagesFromFile(file) { + const text = await file.text(); + return parseExportText(text, file.name); +} +function importTitleFromFile(text, filename) { + if (filename.toLowerCase().endsWith(".json")) { + try { + const parsed = JSON.parse(text); + const title = parsed.title?.trim(); + if (title) return title; + } catch { + } + } + const base = filename.replace(/\.(md|json)$/i, ""); + const slug = base.replace(/^llm-fallbacks-/, "").replace(/-\d{4}-\d{2}-\d{2}$/, ""); + return slug.replace(/-/g, " ").trim() || void 0; +} + +// src/plugins/shortcuts-sheet/index.ts +var HINT_KEY = "llm_fallbacks_shortcuts_hint_dismissed"; +var SHORTCUTS = [ + { keys: "Enter", action: "Send message" }, + { keys: "Shift + Enter", action: "New line in composer" }, + { keys: "Esc", action: "Stop generation (when streaming)" }, + { keys: "/", action: "Focus composer" }, + { keys: "?", action: "Show this shortcuts sheet" } +]; +function isTypingTarget(target) { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable; +} +function ensureModal() { + let modal = document.getElementById("lfShortcutsModal"); + if (modal) return modal; + modal = document.createElement("div"); + modal.id = "lfShortcutsModal"; + modal.className = "lf-shortcuts-modal"; + modal.hidden = true; + modal.innerHTML = ` +
+ + `; + const list = modal.querySelector(".lf-shortcuts-list"); + for (const { keys, action } of SHORTCUTS) { + const li = document.createElement("li"); + li.innerHTML = `${keys}${action}`; + list.appendChild(li); + } + document.body.appendChild(modal); + return modal; +} +function openModal() { + const modal = ensureModal(); + modal.hidden = false; + modal.querySelector(".lf-shortcuts-close")?.focus(); +} +function closeModal() { + const modal = document.getElementById("lfShortcutsModal"); + if (modal) modal.hidden = true; +} +function ensureHint() { + let hint = document.getElementById("lfShortcutsHint"); + if (hint) return hint; + hint = document.createElement("div"); + hint.id = "lfShortcutsHint"; + hint.className = "lf-shortcuts-hint"; + hint.innerHTML = ` + Tip: press ? for keyboard shortcuts + + `; + const mount = document.getElementById("chatMount"); + if (mount) { + mount.appendChild(hint); + } else { + document.body.appendChild(hint); + } + return hint; +} +function ShortcutsSheetPlugin() { + return { + name: "shortcuts-sheet", + onMount() { + ensureModal(); + const modal = document.getElementById("lfShortcutsModal"); + modal.addEventListener("click", (e) => { + const target = e.target; + if (target.dataset.close || target.classList.contains("lf-shortcuts-close")) { + closeModal(); + } + }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + closeModal(); + return; + } + if (e.key === "?" && !e.ctrlKey && !e.metaKey && !e.altKey && !isTypingTarget(e.target)) { + e.preventDefault(); + openModal(); + } + }); + if (localStorage.getItem(HINT_KEY) !== "1") { + const hint = ensureHint(); + hint.hidden = false; + hint.querySelector(".lf-shortcuts-hint-dismiss")?.addEventListener("click", () => { + hint.hidden = true; + localStorage.setItem(HINT_KEY, "1"); + }); + } + const footerLink = document.createElement("button"); + footerLink.type = "button"; + footerLink.className = "lf-shortcuts-footer-link"; + footerLink.textContent = "Shortcuts"; + footerLink.title = "Keyboard shortcuts (?)"; + footerLink.addEventListener("click", () => openModal()); + const credits = document.querySelector(".credits-actions"); + credits?.appendChild(footerLink); + } + }; +} + // src/main.ts async function loadCatalog(config) { let catalog = []; @@ -6860,6 +7078,38 @@ async function bootstrap() { provider.setCatalog(catalog, providerUrls); let catalogRef2 = catalog; let providerUrlsRef = providerUrls; + const importInput = document.createElement("input"); + importInput.type = "file"; + importInput.accept = ".md,.json,text/markdown,application/json"; + importInput.hidden = true; + document.body.appendChild(importInput); + let importEngine = null; + importInput.addEventListener("change", async () => { + const file = importInput.files?.[0]; + importInput.value = ""; + if (!file || !importEngine) return; + if (importEngine.state.generatingMessageId) { + showStatusMessage("Wait for the current reply to finish before importing."); + return; + } + try { + const text = await file.text(); + const messages = await importMessagesFromFile(file); + await importEngine.sessions.create(); + const ok = await importEngine.setMessages(messages); + if (!ok) { + throw new ImportError("Could not import while a reply is generating."); + } + const title = importTitleFromFile(text, file.name); + if (title) { + await importEngine.sessions.updateTitle(importEngine.state.currentSessionId, title); + } + showStatusMessage(`Imported ${messages.length} message${messages.length === 1 ? "" : "s"}.`); + } catch (err) { + const msg = err instanceof ImportError ? err.message : "Import failed."; + showStatusMessage(msg); + } + }); const ui = new ChatUI({ container: "#chatMount", provider, @@ -6905,6 +7155,28 @@ async function bootstrap() { "application/json;charset=utf-8" ); } + }, + { + id: "import-conversation", + label: "Import conversation", + disabled: ctx.engine.state.generatingMessageId !== null, + onClick: () => { + importInput.click(); + } + }, + { + id: "copy-session-link", + label: "Copy session link", + onClick: async () => { + const hash = `#/chat/${encodeURIComponent(ctx.session.id)}`; + const url = `${window.location.origin}${window.location.pathname}${window.location.search}${hash}`; + try { + await navigator.clipboard.writeText(url); + showStatusMessage("Session link copied (local browser only)."); + } catch { + window.prompt("Copy session link:", url); + } + } } ]; }, @@ -6935,9 +7207,11 @@ async function bootstrap() { ModelExplorerPlugin({ getCatalog: () => catalogRef2, getCatalogUrl: () => readRuntimeConfig().catalogUrl - }) + }), + ShortcutsSheetPlugin() ] }); + importEngine = ui.engine; wireChatInputIds(document.querySelector("#chatMount")); const observer = new MutationObserver(() => wireChatInputIds(document.querySelector("#chatMount"))); observer.observe(document.querySelector("#chatMount"), { childList: true, subtree: true }); diff --git a/docs/assets/shell/chat-overrides.css b/docs/assets/shell/chat-overrides.css index 2802d5e..ad20d36 100644 --- a/docs/assets/shell/chat-overrides.css +++ b/docs/assets/shell/chat-overrides.css @@ -151,7 +151,7 @@ body.lf-chat-page::before { } #chatMount.mur-app-embedded.mur-chat-empty .mur-chat-layout-wrapper::before { - content: "Free model chat"; + content: "Ranked free models, zero config"; display: block; position: absolute; top: 36%; @@ -167,7 +167,7 @@ body.lf-chat-page::before { } #chatMount.mur-app-embedded.mur-chat-empty .mur-chat-layout-wrapper::after { - content: "Zero-config demo — ranked free alias via edge proxy. Optional BYOK for browser-direct routes."; + content: "Daily-updated catalog via edge proxy. Pick a model above or send a message. Optional BYOK for direct routes."; display: block; position: absolute; top: 44%; @@ -807,3 +807,156 @@ body.lf-chat-page::before { .explorer-table-wrap td { vertical-align: middle; } + +/* ── Wave 4: streaming polish + shortcuts ───────────────────── */ +#chatMount .mur-streaming-text { + white-space: pre-wrap; + word-break: break-word; + font-family: inherit; + line-height: 1.55; + contain: content; +} + +#chatMount .mur-message-assistant.mur-generating .mur-content-block { + min-height: 1.5em; +} + +.lf-shortcuts-modal { + position: fixed; + inset: 0; + z-index: 10050; + display: flex; + align-items: center; + justify-content: center; +} + +.lf-shortcuts-modal[hidden] { + display: none !important; +} + +.lf-shortcuts-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.62); +} + +.lf-shortcuts-panel { + position: relative; + z-index: 1; + width: min(22rem, 92vw); + padding: 1.25rem 1.35rem; + border-radius: 12px; + background: #1a1a2e; + border: 1px solid rgba(157, 78, 221, 0.28); + box-shadow: var(--mur-shadow-modal, 0 10px 28px rgba(0, 0, 0, 0.45)); +} + +.lf-shortcuts-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.85rem; +} + +.lf-shortcuts-header h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + color: #e8e8ef; +} + +.lf-shortcuts-close { + border: none; + background: transparent; + color: #8b8ba3; + font-size: 1.35rem; + line-height: 1; + cursor: pointer; +} + +.lf-shortcuts-list { + list-style: none; + margin: 0; + padding: 0; +} + +.lf-shortcuts-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.45rem 0; + border-bottom: 1px solid rgba(157, 78, 221, 0.12); + font-size: 0.88rem; + color: #c4c4d4; +} + +.lf-shortcuts-list li:last-child { + border-bottom: none; +} + +.lf-shortcuts-list kbd { + display: inline-block; + padding: 0.15rem 0.45rem; + border-radius: 5px; + background: rgba(157, 78, 221, 0.16); + border: 1px solid rgba(157, 78, 221, 0.28); + color: #e4c7ff; + font-family: inherit; + font-size: 0.78rem; +} + +.lf-shortcuts-hint { + position: absolute; + bottom: 5.5rem; + left: 50%; + transform: translateX(-50%); + z-index: 5; + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.45rem 0.75rem; + border-radius: 8px; + background: rgba(26, 26, 46, 0.92); + border: 1px solid rgba(157, 78, 221, 0.25); + color: #c4c4d4; + font-size: 0.82rem; + pointer-events: auto; +} + +.lf-shortcuts-hint[hidden] { + display: none !important; +} + +.lf-shortcuts-hint kbd { + padding: 0.1rem 0.35rem; + border-radius: 4px; + background: rgba(157, 78, 221, 0.2); + border: 1px solid rgba(157, 78, 221, 0.3); + font-size: 0.75rem; +} + +.lf-shortcuts-hint-dismiss { + border: none; + background: transparent; + color: #8b8ba3; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; +} + +.lf-shortcuts-footer-link { + border: none; + background: transparent; + color: #8b8ba3; + font-size: 0.78rem; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + padding: 0 0.35rem; +} + +.lf-shortcuts-footer-link:hover { + color: #c77dff; +} + diff --git a/docs/brainstorms/2026-07-25-chat-ui-wave4-polish-requirements.md b/docs/brainstorms/2026-07-25-chat-ui-wave4-polish-requirements.md new file mode 100644 index 0000000..4ac6136 --- /dev/null +++ b/docs/brainstorms/2026-07-25-chat-ui-wave4-polish-requirements.md @@ -0,0 +1,113 @@ +--- +title: Chat UI Wave 4 — streaming polish & UX micro +date: 2026-07-25 +status: confirmed +priority_wave: wave4-polish +origin: docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md +prior_waves: [wave1, wave2, wave3] +strategy: STRATEGY.md +--- + +# Chat UI Wave 4 — streaming polish & UX micro + +## Summary + +Close the remaining “feels basic” gaps **without** multimodal upload or model compare. Prioritize the original **R5** streaming-markdown polish, then low-cost UX micro: **import conversation**, clearer **empty state**, and a **keyboard-shortcuts** affordance. Stays static-first and murm-ui–compatible. + +## Problem + +Waves 1–3 (R1–R4, R6–R18, R10–R14) shipped table-stakes chat UX. Visitors still perceive jank during long SSE replies because assistant markdown re-renders the full block on each throttle tick. Export exists but import does not — sessions feel trapped. First-run empty state and shortcuts are easy to miss on the embedded shell. + +## Requirements + +### Streaming & rendering (R5 carryover) + +| ID | Requirement | +|----|-------------| +| R19 | Assistant **streaming text** renders without visible full-message flicker or layout jump during active SSE — completed blocks stay stable while only the tail updates. | +| R20 | Code blocks and tables **do not re-highlight or re-layout** on every token once their fence/table row is closed. | +| R21 | Streaming remains **accessible**: `aria-live="polite"` on the feed; completion announced without re-reading the entire message. | + +### Session portability (symmetry with R13) + +| ID | Requirement | +|----|-------------| +| R22 | Users **import** a previously exported Markdown or JSON transcript into a **new** IndexedDB session (file picker; no server upload). | +| R23 | Import preserves **user/assistant turn order** and plain text; malformed files show a clear error without corrupting existing sessions. | +| R24 | Sidebar offers **Copy session link** when hash routing is enabled (`#/chat/{id}`), with tooltip that the link is same-browser only (see `docs/CAVEATS.md`). | + +### UX micro + +| ID | Requirement | +|----|-------------| +| R25 | **Empty state** names the ranked-`free` value prop and one primary action (“Send a message” / “Pick a model”) without ResearchWizard dead chrome. | +| R26 | **Keyboard shortcuts** sheet (e.g. `?` or footer link): Enter send, Shift+Enter newline, Escape stop generation, `/` focus composer — matches murm-ui behavior where applicable. | +| R27 | Optional **compact shortcut hints** on first visit (dismissible, `localStorage` flag); no modal blocking send. | + +## Approaches considered + +### A. Throttle + tail-only DOM (recommended) + +Reduce perceived flicker by ensuring only the **active streaming block** mutates DOM; rely on murm-ui throttle where present and add a first-party plugin only if tail isolation is insufficient. **Pros:** Smallest diff; no markdown library swap. **Cons:** May require murm-ui patch or version bump if root cause is full `syncDOMChildren` replace. + +### B. Incremental markdown renderer swap + +Adopt an incremental block parser (industry pattern: parse deltas, memoize closed blocks) via murm-ui extension or fork. **Pros:** Best long-term perf. **Cons:** Higher carrying cost; couples us to renderer maintenance. + +### C. UX-only (skip R5) + +Ship R22–R27 only. **Pros:** Fast. **Cons:** Leaves the most visible “cheap chat UI” signal unfixed. + +**Recommendation:** **A first**, spike B only if A cannot meet R19–R20 in one wave. Ship R22–R27 in the same PR for cohesive “polish” release. + +## Scope boundaries + +**In scope:** `webui/`, Playwright e2e, `docs/CAVEATS.md`, `docs/chat-ui-plugins.md`. + +**Out of scope (unchanged from July 24 brainstorm):** + +- Vision/file upload, side-by-side model compare, tool-call/reasoning UI, PWA offline shell +- Cloud session sync, user accounts, Open WebUI embedding +- Full TypeScript port of Python discovery + +**Explicitly deferred:** + +- Conversation **merge** (import into existing session with dedupe) +- Custom themes beyond existing dark shell +- Voice input, MCP panels (remain hidden) + +## Success criteria + +- Side-by-side manual test: 500+ token streamed reply shows no full-body flash; code fence stable after closing backticks. +- Import round-trip: export MD → import → messages visible and sendable in new session. +- Playwright: import happy path + hash link copy smoke (where testable). +- First-time visitor reads empty-state copy and finds `?` shortcuts without opening Failover settings. + +## Key decisions + +| ID | Decision | Rationale | +|----|----------|-----------| +| K1 | Wave 4A before multimodal/compare | User priority; lowest risk vs STRATEGY static budget | +| K2 | Import creates **new** session | Avoids IndexedDB merge bugs; matches export as portable artifact | +| K3 | R5 fix prefers murm-ui-compatible path | Forking murm-ui is last resort | +| K4 | Copy link complements R14 | Makes hash routing discoverable without implying cloud share | + +## Dependencies & assumptions + +- Waves 1–3 merged and deployed (PRs #13, #14) before Wave 4 ships to `main`. +- murm-ui `MessageNode` uses throttled full-block markdown today; R19–R20 may need upstream coordination or pinned version bump. +- Export JSON schema from Wave 3 is the import contract. + +## Outstanding questions + +| ID | Question | Default | +|----|----------|---------| +| Q1 | Patch murm-ui in-repo vs npm bump? | Try npm bump / plugin first; patch only if blocked | +| Q2 | Import MD: strict format only vs best-effort `## User`/`## Assistant`? | Best-effort parser with strict JSON path | +| Q3 | Shortcuts sheet modal vs slide panel? | Small modal overlay (shell pattern) | + +## Research references + +- [Jason Laster — Chat UI best practices (2026)](https://www.jasonlaster.com/posts/2026-04-25-chat-ui) +- [Performant AI markdown renderer](https://tigerabrodi.blog/how-to-build-a-performant-ai-markdown-renderer) +- Prior: `docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md` (R5, deferred list) diff --git a/docs/chat-ui-plugins.md b/docs/chat-ui-plugins.md index e2d12cb..283e9ca 100644 --- a/docs/chat-ui-plugins.md +++ b/docs/chat-ui-plugins.md @@ -34,12 +34,17 @@ Set `APP_VERSION` when building for cache busting (CI sets this from `github.sha | `message-actions` | Messages | Regenerate, edit user message; stop preserves partial output when present | | `status-strip` | Top bar | Proxy liveness dot + optional daily chat count from `/v1/metrics` | | `turnstile-gate` | Body (optional) | Cloudflare Turnstile widget when `turnstileSiteKey` is in config | +| `shortcuts-sheet` | Footer / `?` key | Keyboard shortcuts modal + dismissible first-visit hint | 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 +Wave 4 adds **streaming polish** (plain-text tail during SSE, full markdown on completion — `patch-package` on murm-ui), **conversation import** (symmetry with export), **copy session link**, empty-state copy, and the shortcuts sheet. + +## Session export, import, and hash links - **Export** — Session ⋮ menu → “Export as Markdown” or “Export as JSON”. Serializes text blocks only; empty chats disable the items. +- **Import** — Session ⋮ menu → “Import conversation”. Creates a **new** session from exported `.md` or `.json`; does not merge into the active session. +- **Copy session link** — Session ⋮ menu → copies `#/chat/{id}` URL (local browser only). - **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). @@ -64,6 +69,7 @@ ResearchWizard includes MCP config UI backed by a server. Static Pages cannot ho | `llm_fallbacks_guest_token` | Bearer token for proxy auth | | `llm_fallbacks_default_model` | Default chat model (usually `free`) | | `llm_fallbacks_api_keys` | Optional BYOK map | +| `llm_fallbacks_shortcuts_hint_dismissed` | `1` after user dismisses first-visit shortcuts hint | Zero-config values seed from `docs/config.js` on first visit (`seedZeroConfigFromPageConfig` in `webui/src/config.ts`). diff --git a/docs/plans/2026-07-25-001-feat-chat-ui-wave4-polish-plan.md b/docs/plans/2026-07-25-001-feat-chat-ui-wave4-polish-plan.md new file mode 100644 index 0000000..7f98aa0 --- /dev/null +++ b/docs/plans/2026-07-25-001-feat-chat-ui-wave4-polish-plan.md @@ -0,0 +1,307 @@ +--- +title: "feat: Chat UI Wave 4 — streaming polish & UX micro" +status: completed +date: 2026-07-25 +type: feat +origin: docs/brainstorms/2026-07-25-chat-ui-wave4-polish-requirements.md +strategy: STRATEGY.md +wave: 4 +requirements: R19-R27 +prior_plan: docs/plans/2026-07-24-006-feat-chat-ui-wave3-catalog-export-plan.md +--- + +# feat: Chat UI Wave 4 — streaming polish & UX micro + +> **Origin:** [Wave 4 polish brainstorm](../brainstorms/2026-07-25-chat-ui-wave4-polish-requirements.md) — closes original **R5** carryover plus session import and UX micro. Waves 1–3 in PRs [#13](https://github.com/bodecloud/llm_fallbacks/pull/13) / [#14](https://github.com/bodecloud/llm_fallbacks/pull/14). + +## Summary + +Fix streaming markdown jank, add **import conversation** (symmetry with Wave 3 export), **copy session link**, clearer **empty state**, and a **keyboard shortcuts** sheet — all client-only, static-first. + +## Problem Frame + +After Waves 1–3 the demo has model picker, routing chip, catalog badges, export, and hash links. It still reads as “basic” because long SSE replies flash when murm-ui re-parses the full markdown block every ~70ms, import is missing, and first-run affordances are buried in the embedded shell. + +## Requirements (Wave 4 traceability) + +| ID | Source | Wave 4 requirement | +|----|--------|-------------------| +| R19 | Wave 4 | Streaming assistant text without full-message flicker | +| R20 | Wave 4 | Closed code blocks/tables stable during stream | +| R21 | Wave 4 | Accessible streaming (`aria-live` unchanged or improved) | +| R22 | Wave 4 | Import exported MD/JSON into **new** session | +| R23 | Wave 4 | Import errors are clear; no corruption of existing sessions | +| R24 | Wave 4 | Sidebar **Copy session link** with local-only tooltip | +| R25 | Wave 4 | Empty state names ranked-`free` value + primary CTA | +| R26 | Wave 4 | `?` shortcuts sheet (Enter, Shift+Enter, Esc, `/`) | +| R27 | Wave 4 | Dismissible first-visit shortcut hint | + +**Out of scope:** vision upload, model compare, tool/reasoning UI, PWA, cloud sync (see origin doc). + +## Key Technical Decisions + +| ID | Decision | Rationale | +|----|----------|-----------| +| KTD1 | **Spike murm-ui upgrade first** for R19–R20 | `murm-ui@^0.2.0` `MessageNode` throttles then `marked.parse` + `syncDOMChildren` on full content; upstream fix preferred over fork | +| KTD2 | **If upgrade insufficient: `patch-package` on murm-ui** | Incremental tail render (block memoization or higher throttle + skip re-highlight) without forking repo; document patch in `webui/README` or `docs/chat-ui-plugins.md` | +| KTD3 | **`import-session.ts` mirrors `export-session.ts`** | Shared `messageText` shape; JSON is strict schema; MD is best-effort `## User` / `## Assistant` (origin Q2 default) | +| KTD4 | **Import flow: `engine.sessions.create()` → `engine.setMessages()`** | murm-ui exposes both; avoids merge into active session (origin K2) | +| KTD5 | **Sidebar menu for import + copy link** | Reuse Wave 3 `sidebarMenu` pattern in `webui/src/main.ts` | +| KTD6 | **Copy link via `navigator.clipboard` + toast/status** | Hash URL from `AppRouter.hrefFor(id)` or `location.hash`; tooltip cites CAVEATS | +| KTD7 | **Shortcuts: small modal overlay** | Origin Q3 default; shell already uses modals (`#sysMask`) | +| KTD8 | **Branch from `main` after Wave 3 merge** | Avoid stacking four waves; rebase if #14 lands first | + +## High-Level Technical Design + +```mermaid +flowchart TB + subgraph stream [Streaming R19-R21] + SSE[SSE tokens] + MN[murm-ui MessageNode] + Fix[Upgrade or patch] + SSE --> MN + Fix --> MN + end + + subgraph portable [Sessions R22-R24] + Export[export-session.ts] + Import[import-session.ts] + Menu[sidebarMenu] + Export -.symmetry.-> Import + Import --> Engine[ChatEngine.setMessages] + Menu --> Import + Menu --> CopyLink[clipboard hash URL] + end + + subgraph micro [UX R25-R27] + Empty[empty-state copy CSS] + Shortcuts[shortcuts plugin modal] + end +``` + +## Implementation Units + +### U1. Streaming flicker spike + fix (R19–R21) + +**Goal:** Long streamed replies feel stable; closed markdown structures do not re-layout every token. + +**Requirements:** R19, R20, R21 + +**Dependencies:** None (blocks visual acceptance of polish release) + +**Files:** +- `webui/package.json` — murm-ui version bump if upstream fix exists +- `webui/patches/` (new, if `patch-package` needed) — murm-ui message-node patch +- `webui/shell/chat-overrides.css` — optional `contain` / min-height on streaming assistant blocks +- `webui/src/plugins/streaming-polish/index.ts` (new, only if DOM-level mitigation needed without patch) + +**Approach:** +1. Reproduce flicker with mocked long SSE (code fence + list) in manual test. +2. Check latest `murm-ui` release notes / diff for `message-node.js` streaming changes. +3. **Path A (preferred):** bump dependency; verify R19–R20 manually. +4. **Path B:** `patch-package` to (a) increase throttle modestly, (b) skip `applyMarkdown` when plain-text tail unchanged, or (c) append text node for in-progress paragraph before full markdown pass — pick smallest change that passes acceptance. +5. Verify `aria-live` on feed unchanged (`webui/src/main.ts` `wireChatInputIds`). + +**Patterns to follow:** Industry guidance — throttle DOM not stream; memoize closed blocks (see origin research links). + +**Test scenarios:** +- Mock 500+ token stream with fenced code → no full-body white flash mid-stream. +- After closing ` ``` `, code block does not re-highlight on subsequent tokens. +- Stream completes → final markdown matches non-stream render. + +**Verification:** Manual + optional Playwright in U6 (`streaming-polish.spec.ts` if stable selectors exist). + +--- + +### U2. Import session serializers (R22–R23) + +**Goal:** Parse Wave 3 export formats into murm-ui `Message[]`. + +**Requirements:** R22, R23 + +**Dependencies:** None + +**Files:** +- `webui/src/import-session.ts` (new) +- `webui/src/import-session.test.ts` (new) +- `webui/src/export-session.ts` — extract shared `messageText` / types if duplication grows + +**Approach:** +- `parseJsonExport(text)` → validate `{ messages: [{ role, text }] }`; map to `Message` with new `uuid` ids via murm-ui pattern or `crypto.randomUUID`. +- `parseMarkdownExport(text)` → split on `## User` / `## Assistant` headings (case-insensitive); best-effort; reject empty. +- `importMessagesFromFile(file)` → detect `.json` vs `.md` by extension/MIME; throw `ImportError` with user-facing message. + +**Test scenarios:** +- Round-trip: `toJson` output → `parseJsonExport` → same roles/order. +- Wave 3 MD sample with two turns → two messages. +- Invalid JSON → throws with clear message. +- Empty MD → throws; no partial state. + +**Verification:** `cd webui && npm test` + +--- + +### U3. Sidebar import + copy session link (R22–R24) + +**Goal:** Session menu gains Import and Copy link alongside Export. + +**Requirements:** R22, R23, R24 + +**Dependencies:** U2 + +**Files:** +- `webui/src/main.ts` — extend `sidebarMenu` +- `webui/src/import-session.ts` +- `docs/CAVEATS.md` — copy-link tooltip alignment (if not already sufficient) + +**Approach:** +- Add hidden `` triggered from menu item **Import conversation**. +- On file read: `engine.sessions.create()` then `engine.setMessages(parsed)`; set title from export meta or filename slug. +- On failure: `engine.clearError()`-safe banner or `alert`/`status` strip message — do not touch other sessions. +- **Copy session link:** build URL from `window.location.pathname + '#/chat/' + encodeURIComponent(session.id)`; `navigator.clipboard.writeText`; brief status in `status-strip` or inline toast class. +- Disable import while generation active (`engine.state.isGenerating` if exposed, or guard via plugin). + +**Patterns to follow:** Wave 3 export items in `webui/src/main.ts` `sidebarMenu`. + +**Test scenarios:** +- Covers AE: export MD → import → user message visible in new session (Playwright U6). +- Copy link → clipboard contains `#/chat/`. +- Malformed file → error shown; prior session messages unchanged. + +**Verification:** Playwright `tests/e2e/import-session.spec.ts` + +--- + +### U4. Empty state polish (R25) + +**Goal:** First visit communicates ranked-`free` demo value and next action. + +**Requirements:** R25 + +**Dependencies:** None + +**Files:** +- `webui/shell/chat-overrides.css` — refine `::before` / `::after` on empty layout +- `webui/src/plugins/empty-state-hint/index.ts` (new, optional) — only if CSS pseudo-elements insufficient + +**Approach:** +- Update empty-state hero copy: mention **daily ranked catalog**, **zero-config proxy**, **optional BYOK**. +- Add visible hint: “Pick a model above” or link to `#explorerSetting` if shell button exists. +- Keep ResearchWizard dead chrome hidden (R18 carryover). + +**Test scenarios:** +- Fresh localStorage → empty chat shows updated copy (Playwright smoke). + +**Verification:** Visual + `tests/e2e/empty-state.spec.ts` (optional lightweight) + +--- + +### U5. Keyboard shortcuts sheet (R26–R27) + +**Goal:** Discoverable shortcuts without blocking send. + +**Requirements:** R26, R27 + +**Dependencies:** None + +**Files:** +- `webui/src/plugins/shortcuts-sheet/index.ts` (new) +- `webui/shell/chat-overrides.css` +- `webui/src/main.ts` — register plugin + +**Approach:** +- Global `?` key (when not typing in input) opens modal listing: Enter send, Shift+Enter newline, Esc stop (if murm-ui supports), `/` focus composer. +- Footer link “Shortcuts” as secondary affordance. +- First visit: compact dismissible banner (`localStorage` key `llm_fallbacks_shortcuts_hint_dismissed`); `localStorage` only, matches project pattern. + +**Patterns to follow:** Shell modal pattern (`#sysMask`, panel-header classes). + +**Test scenarios:** +- Press `?` → modal visible with Enter/Shift+Enter text. +- Dismiss hint → flag set; reload → hint hidden. + +**Verification:** Playwright `tests/e2e/shortcuts-sheet.spec.ts` + +--- + +### U6. E2E, docs, build (R19–R27) + +**Goal:** CI coverage and operator docs. + +**Requirements:** All + +**Dependencies:** U1–U5 + +**Files:** +- `tests/e2e/import-session.spec.ts` (new) +- `tests/e2e/shortcuts-sheet.spec.ts` (new) +- `tests/e2e/streaming-polish.spec.ts` (new, if U1 yields testable signal) +- `.github/workflows/deploy-pages.yml` — add specs to mocked e2e job +- `docs/chat-ui-plugins.md` +- `CONCEPTS.md` — **Conversation import** (already drafted; commit with wave) + +**Test scenarios:** +- Import round-trip after export (mocked provider). +- Shortcuts modal opens. +- (Optional) Stream long mocked reply; assert no regression in assistant text presence. + +**Verification:** `cd webui && npm test && npm run build`; Playwright wave 4 specs green. + +## Sequencing + +```mermaid +flowchart LR + U1[U1 streaming] --> U6[U6 e2e docs] + U2[U2 import parse] --> U3[U3 sidebar] + U3 --> U6 + U4[U4 empty state] --> U6 + U5[U5 shortcuts] --> U6 +``` + +**Recommended order:** U1 (spike early) → U2 → U3 → U4 → U5 → U6 + +U4/U5 parallel with U2/U3 after U1 spike confirms patch path. + +## Scope Boundaries + +**In scope:** `webui/`, Playwright, docs updates. + +**Out of scope:** multimodal, compare, tool UI, murm-ui fork as standalone project. + +### Deferred to Follow-Up Work + +- Wave 4B vision upload / Wave 4C model compare (separate brainstorm if pursued) +- Import merge into existing session +- Upstream murm-ui PR for incremental markdown (if we ship patch-package locally) + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| murm-ui has no hook for streaming render | patch-package or CSS mitigation; document upgrade path | +| `setMessages` after `create()` race | await `sessions.create()`; verify in Playwright | +| Wave 3 not merged | branch from `feat/chat-ui-wave3` or rebase on `main` post-merge | +| Clipboard API denied | fallback `prompt` with pre-selected URL for copy link | + +## Acceptance Examples + +- **AE1.** 500+ token stream with code block: no full-message flash; fence stable after close. +- **AE2.** Export JSON → Import → new session shows same turns; user can send follow-up. +- **AE3.** Copy session link puts `#/chat/{id}` on clipboard; CAVEATS explains local-only. +- **AE4.** Empty state mentions ranked catalog; `?` opens shortcuts without opening Server settings. + +## Open Questions + +| ID | Question | Default for planning | +|----|----------|---------------------| +| Q1 | murm-ui bump vs patch-package | Try bump first (U1 spike) | +| Q2 | MD import strictness | Best-effort headings (origin default) | +| Q3 | Modal vs slide panel for shortcuts | Modal (origin default) | + +## Sources / Research + +- [Wave 4 requirements](../brainstorms/2026-07-25-chat-ui-wave4-polish-requirements.md) +- [Jason Laster — Chat UI best practices (2026)](https://www.jasonlaster.com/posts/2026-04-25-chat-ui) +- `webui/node_modules/murm-ui/dist/components/message-node.js` — `MARKDOWN_THROTTLE_MS = 70`, full `marked.parse` path +- `webui/node_modules/murm-ui/dist/core/chat-engine.d.ts` — `setMessages()` +- Wave 3: `docs/plans/2026-07-24-006-feat-chat-ui-wave3-catalog-export-plan.md` diff --git a/tests/e2e/import-session.spec.ts b/tests/e2e/import-session.spec.ts new file mode 100644 index 0000000..8817015 --- /dev/null +++ b/tests/e2e/import-session.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from "@playwright/test"; +import { + DEMO_PROXY, + installLocalChatBundle, + installTestConfigMock, + waitForAssistantText, +} from "./helpers"; + +async function openSessionMenu(page: import("@playwright/test").Page) { + await page.evaluate(() => { + document.querySelector("#chatMount")?.classList.remove("mur-sidebar-closed"); + localStorage.setItem("mur_sidebar_closed", "false"); + }); + await page.locator(".mur-sidebar-item-link").first().focus(); + await page.locator(".mur-sidebar-options-btn").first().dispatchEvent("click"); +} + +test.describe("Wave 4 — import session", () => { + test.beforeEach(async ({ page }) => { + await installTestConfigMock(page); + await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream; charset=utf-8", + body: + 'data: {"choices":[{"index":0,"delta":{"content":"imported ok"},"finish_reason":null}]}\n\n' + + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n' + + "data: [DONE]\n\n", + }); + }); + 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("imports exported markdown into a new session", async ({ page }) => { + await page.locator("#chatinput").fill("seed message"); + await page.locator("#sendbutton").click(); + await waitForAssistantText(page, 30_000); + + const downloadPromise = page.waitForEvent("download", { timeout: 15_000 }); + await openSessionMenu(page); + await page.getByRole("menuitem", { name: "Export as Markdown" }).click(); + const download = await downloadPromise; + const exportPath = await download.path(); + expect(exportPath).toBeTruthy(); + + await page.locator(".mur-new-chat-btn").click(); + await expect(page.locator(".mur-message")).toHaveCount(0, { timeout: 10_000 }); + + await openSessionMenu(page); + const importItem = page.getByRole("menuitem", { name: "Import conversation" }); + await expect(importItem).toBeVisible({ timeout: 5_000 }); + + const fileInput = page.locator('input[type="file"][accept*=".md"]'); + await fileInput.setInputFiles(exportPath!); + + await expect(page.locator(".mur-message-user").getByText("seed message")).toBeVisible({ + timeout: 10_000, + }); + await expect(page.locator(".mur-message-assistant").getByText("imported ok")).toBeVisible({ + timeout: 10_000, + }); + }); +}); diff --git a/tests/e2e/shortcuts-sheet.spec.ts b/tests/e2e/shortcuts-sheet.spec.ts new file mode 100644 index 0000000..c1060fa --- /dev/null +++ b/tests/e2e/shortcuts-sheet.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test"; +import { + installLocalChatBundle, + installTestConfigMock, +} from "./helpers"; + +test.describe("Wave 4 — shortcuts sheet", () => { + test.beforeEach(async ({ page }) => { + await installTestConfigMock(page); + await installLocalChatBundle(page); + await page.goto("./", { waitUntil: "domcontentloaded" }); + await page.evaluate(() => { + localStorage.clear(); + localStorage.setItem("llm_fallbacks_shortcuts_hint_dismissed", "1"); + }); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator("#chatinput")).toBeVisible({ timeout: 45_000 }); + }); + + test("opens shortcuts modal on ?", async ({ page }) => { + await page.keyboard.press("?"); + const modal = page.locator("#lfShortcutsModal"); + await expect(modal).toBeVisible(); + await expect(modal.getByText("Enter", { exact: true })).toBeVisible(); + await expect(modal.getByText("Send message")).toBeVisible(); + await expect(modal.getByText("Shift + Enter")).toBeVisible(); + }); + + test("footer link opens shortcuts", async ({ page }) => { + await page.getByRole("button", { name: "Shortcuts" }).click(); + await expect(page.locator("#lfShortcutsModal")).toBeVisible(); + }); +}); diff --git a/tests/e2e/streaming-polish.spec.ts b/tests/e2e/streaming-polish.spec.ts new file mode 100644 index 0000000..a6c1ab4 --- /dev/null +++ b/tests/e2e/streaming-polish.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from "@playwright/test"; +import { + DEMO_PROXY, + installLocalChatBundle, + installTestConfigMock, + mockProxySse, + waitForAssistantText, +} from "./helpers"; + +test.describe("Wave 4 — streaming polish", () => { + test.beforeEach(async ({ page }) => { + await installTestConfigMock(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("streams long reply with plain-text tail then final markdown", async ({ page }) => { + const longReply = + "Here is code:\n\n```js\nconst x = 1;\n```\n\nDone streaming."; + await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream; charset=utf-8", + body: mockProxySse(longReply), + }); + }); + + await page.locator("#chatinput").fill("stream test"); + await page.locator("#sendbutton").click(); + + const assistant = page.locator(".mur-message-assistant").last(); + await waitForAssistantText(page, 30_000); + await expect(assistant).not.toHaveClass(/mur-generating/); + await expect(assistant.getByText("Done streaming.")).toBeVisible(); + await expect(assistant.locator("pre code, .mur-content-block code")).toBeVisible({ + timeout: 10_000, + }); + }); +}); diff --git a/webui/package-lock.json b/webui/package-lock.json index 1544ef9..ea7974d 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -7,11 +7,13 @@ "": { "name": "llm-fallbacks-webui", "version": "0.1.0", + "hasInstallScript": true, "dependencies": { "murm-ui": "^0.2.0" }, "devDependencies": { "esbuild": "^0.25.0", + "patch-package": "^8.0.1", "typescript": "^5.8.0", "vitest": "^3.0.0" } @@ -994,6 +996,29 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1004,6 +1029,19 @@ "node": ">=12" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1014,6 +1052,56 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1031,6 +1119,23 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -1041,6 +1146,57 @@ "node": ">= 16" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1069,6 +1225,59 @@ "node": ">=6" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1076,6 +1285,19 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1156,6 +1378,44 @@ } } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1171,6 +1431,177 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1178,6 +1609,59 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1207,6 +1691,53 @@ "node": ">= 20" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1245,6 +1776,73 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1356,6 +1954,60 @@ "fsevents": "~2.3.2" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1363,6 +2015,16 @@ "dev": true, "license": "ISC" }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1400,6 +2062,19 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1461,6 +2136,29 @@ "node": ">=14.0.0" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1475,6 +2173,16 @@ "node": ">=14.17" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -2130,6 +2838,22 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2146,6 +2870,22 @@ "engines": { "node": ">=8" } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/webui/package.json b/webui/package.json index f194f7a..9c03692 100644 --- a/webui/package.json +++ b/webui/package.json @@ -7,13 +7,15 @@ "sync:shell": "bash scripts/sync-researchwizard-shell.sh", "build": "node esbuild.config.mjs", "dev": "node esbuild.config.mjs --watch", - "test": "node --experimental-vm-modules node_modules/vitest/vitest.mjs run" + "test": "node --experimental-vm-modules node_modules/vitest/vitest.mjs run", + "postinstall": "patch-package" }, "dependencies": { "murm-ui": "^0.2.0" }, "devDependencies": { "esbuild": "^0.25.0", + "patch-package": "^8.0.1", "typescript": "^5.8.0", "vitest": "^3.0.0" } diff --git a/webui/patches/murm-ui+0.2.0.patch b/webui/patches/murm-ui+0.2.0.patch new file mode 100644 index 0000000..a2aef1a --- /dev/null +++ b/webui/patches/murm-ui+0.2.0.patch @@ -0,0 +1,34 @@ +diff --git a/node_modules/murm-ui/dist/components/message-node.js b/node_modules/murm-ui/dist/components/message-node.js +index db876cc..aab7639 100644 +--- a/node_modules/murm-ui/dist/components/message-node.js ++++ b/node_modules/murm-ui/dist/components/message-node.js +@@ -140,17 +140,23 @@ export class MessageNode { + clearTimeout(state.timer); + state.timer = undefined; + } ++ state.plainEl = undefined; + state.renderSeq++; + void this.applyMarkdown(block.id, block.text, state.renderSeq); + return; + } +- if (state.timer) +- return; +- state.timer = window.setTimeout(() => { ++ // Streaming: plain-text tail — defer markdown until generation completes (llm-fallbacks patch) ++ if (state.timer) { ++ clearTimeout(state.timer); + state.timer = undefined; +- state.renderSeq++; +- void this.applyMarkdown(block.id, block.text, state.renderSeq); +- }, MARKDOWN_THROTTLE_MS); ++ } ++ if (!state.plainEl) { ++ state.container.replaceChildren(); ++ state.plainEl = document.createElement("div"); ++ state.plainEl.className = "mur-streaming-text"; ++ state.container.appendChild(state.plainEl); ++ } ++ state.plainEl.textContent = block.text; + } + renderFileBlock(block, container) { + if (container.hasChildNodes()) diff --git a/webui/shell/chat-overrides.css b/webui/shell/chat-overrides.css index 2802d5e..ad20d36 100644 --- a/webui/shell/chat-overrides.css +++ b/webui/shell/chat-overrides.css @@ -151,7 +151,7 @@ body.lf-chat-page::before { } #chatMount.mur-app-embedded.mur-chat-empty .mur-chat-layout-wrapper::before { - content: "Free model chat"; + content: "Ranked free models, zero config"; display: block; position: absolute; top: 36%; @@ -167,7 +167,7 @@ body.lf-chat-page::before { } #chatMount.mur-app-embedded.mur-chat-empty .mur-chat-layout-wrapper::after { - content: "Zero-config demo — ranked free alias via edge proxy. Optional BYOK for browser-direct routes."; + content: "Daily-updated catalog via edge proxy. Pick a model above or send a message. Optional BYOK for direct routes."; display: block; position: absolute; top: 44%; @@ -807,3 +807,156 @@ body.lf-chat-page::before { .explorer-table-wrap td { vertical-align: middle; } + +/* ── Wave 4: streaming polish + shortcuts ───────────────────── */ +#chatMount .mur-streaming-text { + white-space: pre-wrap; + word-break: break-word; + font-family: inherit; + line-height: 1.55; + contain: content; +} + +#chatMount .mur-message-assistant.mur-generating .mur-content-block { + min-height: 1.5em; +} + +.lf-shortcuts-modal { + position: fixed; + inset: 0; + z-index: 10050; + display: flex; + align-items: center; + justify-content: center; +} + +.lf-shortcuts-modal[hidden] { + display: none !important; +} + +.lf-shortcuts-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.62); +} + +.lf-shortcuts-panel { + position: relative; + z-index: 1; + width: min(22rem, 92vw); + padding: 1.25rem 1.35rem; + border-radius: 12px; + background: #1a1a2e; + border: 1px solid rgba(157, 78, 221, 0.28); + box-shadow: var(--mur-shadow-modal, 0 10px 28px rgba(0, 0, 0, 0.45)); +} + +.lf-shortcuts-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.85rem; +} + +.lf-shortcuts-header h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + color: #e8e8ef; +} + +.lf-shortcuts-close { + border: none; + background: transparent; + color: #8b8ba3; + font-size: 1.35rem; + line-height: 1; + cursor: pointer; +} + +.lf-shortcuts-list { + list-style: none; + margin: 0; + padding: 0; +} + +.lf-shortcuts-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.45rem 0; + border-bottom: 1px solid rgba(157, 78, 221, 0.12); + font-size: 0.88rem; + color: #c4c4d4; +} + +.lf-shortcuts-list li:last-child { + border-bottom: none; +} + +.lf-shortcuts-list kbd { + display: inline-block; + padding: 0.15rem 0.45rem; + border-radius: 5px; + background: rgba(157, 78, 221, 0.16); + border: 1px solid rgba(157, 78, 221, 0.28); + color: #e4c7ff; + font-family: inherit; + font-size: 0.78rem; +} + +.lf-shortcuts-hint { + position: absolute; + bottom: 5.5rem; + left: 50%; + transform: translateX(-50%); + z-index: 5; + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.45rem 0.75rem; + border-radius: 8px; + background: rgba(26, 26, 46, 0.92); + border: 1px solid rgba(157, 78, 221, 0.25); + color: #c4c4d4; + font-size: 0.82rem; + pointer-events: auto; +} + +.lf-shortcuts-hint[hidden] { + display: none !important; +} + +.lf-shortcuts-hint kbd { + padding: 0.1rem 0.35rem; + border-radius: 4px; + background: rgba(157, 78, 221, 0.2); + border: 1px solid rgba(157, 78, 221, 0.3); + font-size: 0.75rem; +} + +.lf-shortcuts-hint-dismiss { + border: none; + background: transparent; + color: #8b8ba3; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; +} + +.lf-shortcuts-footer-link { + border: none; + background: transparent; + color: #8b8ba3; + font-size: 0.78rem; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + padding: 0 0.35rem; +} + +.lf-shortcuts-footer-link:hover { + color: #c77dff; +} + diff --git a/webui/src/import-session.test.ts b/webui/src/import-session.test.ts new file mode 100644 index 0000000..48b8216 --- /dev/null +++ b/webui/src/import-session.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { toJson, toMarkdown } from "./export-session"; +import { + ImportError, + parseJsonExport, + parseMarkdownExport, + parseExportText, +} from "./import-session"; + +describe("import-session", () => { + it("round-trips JSON export", () => { + const messages = [ + { id: "1", role: "user" as const, blocks: [{ type: "text" as const, text: "Hello" }] }, + { + id: "2", + role: "assistant" as const, + blocks: [{ type: "text" as const, text: "Hi there" }], + }, + ]; + const json = toJson(messages, { id: "sess-1", title: "Test" }); + const parsed = parseJsonExport(json); + expect(parsed).toHaveLength(2); + expect(parsed[0].role).toBe("user"); + expect(parsed[1].role).toBe("assistant"); + expect(parsed[0].blocks[0]).toMatchObject({ type: "text", text: "Hello" }); + expect(parsed[1].blocks[0]).toMatchObject({ type: "text", text: "Hi there" }); + }); + + it("parses markdown with two turns", () => { + const md = toMarkdown( + [ + { id: "1", role: "user", blocks: [{ type: "text", text: "Question?" }] }, + { id: "2", role: "assistant", blocks: [{ type: "text", text: "Answer." }] }, + ], + { id: "x", title: "Chat" } + ); + const parsed = parseMarkdownExport(md); + expect(parsed).toHaveLength(2); + expect(parsed[0].blocks[0]).toMatchObject({ text: "Question?" }); + expect(parsed[1].blocks[0]).toMatchObject({ text: "Answer." }); + }); + + it("rejects invalid JSON", () => { + expect(() => parseJsonExport("{")).toThrow(ImportError); + expect(() => parseJsonExport('{"messages":[]}')).toThrow(/empty/i); + }); + + it("rejects empty markdown", () => { + expect(() => parseMarkdownExport(" ")).toThrow(/empty/i); + expect(() => parseMarkdownExport("# Title only")).toThrow(/headings/i); + }); + + it("detects format by extension", () => { + const json = parseExportText('{"messages":[{"role":"user","text":"Hi"}]}', "x.json"); + expect(json).toHaveLength(1); + }); +}); diff --git a/webui/src/import-session.ts b/webui/src/import-session.ts new file mode 100644 index 0000000..d611806 --- /dev/null +++ b/webui/src/import-session.ts @@ -0,0 +1,107 @@ +import type { Message } from "murm-ui"; + +export class ImportError extends Error { + constructor(message: string) { + super(message); + this.name = "ImportError"; + } +} + +interface JsonExportPayload { + title?: string | null; + messages?: Array<{ role?: string; text?: string }>; +} + +function newMessage(role: "user" | "assistant", text: string): Message { + const id = crypto.randomUUID(); + const now = Date.now(); + return { + id, + role, + blocks: [{ id: crypto.randomUUID(), type: "text", text }], + runId: id, + createdAt: now, + updatedAt: now, + }; +} + +export function parseJsonExport(text: string): Message[] { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new ImportError("Invalid JSON — could not parse file."); + } + if (!parsed || typeof parsed !== "object") { + throw new ImportError("Invalid JSON — expected an object with a messages array."); + } + const payload = parsed as JsonExportPayload; + if (!Array.isArray(payload.messages) || payload.messages.length === 0) { + throw new ImportError("Invalid JSON — messages array is missing or empty."); + } + const messages: Message[] = []; + for (const item of payload.messages) { + if (!item || typeof item !== "object") continue; + const role = item.role; + const msgText = typeof item.text === "string" ? item.text.trim() : ""; + if (role !== "user" && role !== "assistant") { + throw new ImportError(`Invalid JSON — unknown role "${String(role)}".`); + } + if (!msgText) continue; + messages.push(newMessage(role, msgText)); + } + if (messages.length === 0) { + throw new ImportError("No messages with text found in JSON export."); + } + return messages; +} + +export function parseMarkdownExport(text: string): Message[] { + const trimmed = text.trim(); + if (!trimmed) { + throw new ImportError("Markdown file is empty."); + } + const sections = trimmed.split(/^##\s+(User|Assistant)\s*$/im); + if (sections.length < 3) { + throw new ImportError("Could not find ## User / ## Assistant headings in Markdown."); + } + const messages: Message[] = []; + for (let i = 1; i < sections.length; i += 2) { + const roleLabel = sections[i]?.toLowerCase(); + const content = (sections[i + 1] ?? "").trim(); + if (!content) continue; + const role = roleLabel === "user" ? "user" : "assistant"; + messages.push(newMessage(role, content)); + } + if (messages.length === 0) { + throw new ImportError("No message content found under headings."); + } + return messages; +} + +export function parseExportText(text: string, filename: string): Message[] { + if (filename.toLowerCase().endsWith(".json")) { + return parseJsonExport(text); + } + return parseMarkdownExport(text); +} + +export async function importMessagesFromFile(file: File): Promise { + const text = await file.text(); + return parseExportText(text, file.name); +} + +export function importTitleFromFile(text: string, filename: string): string | undefined { + if (filename.toLowerCase().endsWith(".json")) { + try { + const parsed = JSON.parse(text) as { title?: string | null }; + const title = parsed.title?.trim(); + if (title) return title; + } catch { + /* fall through */ + } + } + const base = filename.replace(/\.(md|json)$/i, ""); + const slug = base.replace(/^llm-fallbacks-/, "").replace(/-\d{4}-\d{2}-\d{2}$/, ""); + return slug.replace(/-/g, " ").trim() || undefined; +} diff --git a/webui/src/main.ts b/webui/src/main.ts index 8fc3142..31db069 100644 --- a/webui/src/main.ts +++ b/webui/src/main.ts @@ -1,4 +1,4 @@ -import { ChatUI, IndexedDBStorage } from "murm-ui/with-css"; +import { ChatUI, IndexedDBStorage, type ChatEngine } from "murm-ui/with-css"; import { CopyPlugin } from "murm-ui/plugins/copy"; import { loadRuntimeConfig, @@ -28,6 +28,13 @@ import { toJson, toMarkdown, } from "./export-session"; +import { + ImportError, + importMessagesFromFile, + importTitleFromFile, +} from "./import-session"; +import { ShortcutsSheetPlugin } from "./plugins/shortcuts-sheet"; +import { showStatusMessage } from "./plugins/status-strip"; async function loadCatalog(config: AppConfig): Promise<{ catalog: CatalogEntry[]; @@ -88,6 +95,41 @@ async function bootstrap(): Promise { let catalogRef = catalog; let providerUrlsRef = providerUrls; + const importInput = document.createElement("input"); + importInput.type = "file"; + importInput.accept = ".md,.json,text/markdown,application/json"; + importInput.hidden = true; + document.body.appendChild(importInput); + + let importEngine: ChatEngine | null = null; + + importInput.addEventListener("change", async () => { + const file = importInput.files?.[0]; + importInput.value = ""; + if (!file || !importEngine) return; + if (importEngine.state.generatingMessageId) { + showStatusMessage("Wait for the current reply to finish before importing."); + return; + } + try { + const text = await file.text(); + const messages = await importMessagesFromFile(file); + await importEngine.sessions.create(); + const ok = await importEngine.setMessages(messages); + if (!ok) { + throw new ImportError("Could not import while a reply is generating."); + } + const title = importTitleFromFile(text, file.name); + if (title) { + await importEngine.sessions.updateTitle(importEngine.state.currentSessionId, title); + } + showStatusMessage(`Imported ${messages.length} message${messages.length === 1 ? "" : "s"}.`); + } catch (err) { + const msg = err instanceof ImportError ? err.message : "Import failed."; + showStatusMessage(msg); + } + }); + const ui = new ChatUI({ container: "#chatMount", provider, @@ -134,6 +176,28 @@ async function bootstrap(): Promise { ); }, }, + { + id: "import-conversation", + label: "Import conversation", + disabled: ctx.engine.state.generatingMessageId !== null, + onClick: () => { + importInput.click(); + }, + }, + { + id: "copy-session-link", + label: "Copy session link", + onClick: async () => { + const hash = `#/chat/${encodeURIComponent(ctx.session.id)}`; + const url = `${window.location.origin}${window.location.pathname}${window.location.search}${hash}`; + try { + await navigator.clipboard.writeText(url); + showStatusMessage("Session link copied (local browser only)."); + } catch { + window.prompt("Copy session link:", url); + } + }, + }, ]; }, plugins: (engine) => [ @@ -164,9 +228,12 @@ async function bootstrap(): Promise { getCatalog: () => catalogRef, getCatalogUrl: () => readRuntimeConfig().catalogUrl, }), + ShortcutsSheetPlugin(), ], }); + importEngine = ui.engine; + wireChatInputIds(document.querySelector("#chatMount")!); const observer = new MutationObserver(() => wireChatInputIds(document.querySelector("#chatMount")!)); diff --git a/webui/src/plugins/shortcuts-sheet/index.ts b/webui/src/plugins/shortcuts-sheet/index.ts new file mode 100644 index 0000000..7aaea0a --- /dev/null +++ b/webui/src/plugins/shortcuts-sheet/index.ts @@ -0,0 +1,125 @@ +import type { ChatPlugin } from "murm-ui"; + +const HINT_KEY = "llm_fallbacks_shortcuts_hint_dismissed"; + +const SHORTCUTS = [ + { keys: "Enter", action: "Send message" }, + { keys: "Shift + Enter", action: "New line in composer" }, + { keys: "Esc", action: "Stop generation (when streaming)" }, + { keys: "/", action: "Focus composer" }, + { keys: "?", action: "Show this shortcuts sheet" }, +]; + +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable; +} + +function ensureModal(): HTMLElement { + let modal = document.getElementById("lfShortcutsModal"); + if (modal) return modal; + + modal = document.createElement("div"); + modal.id = "lfShortcutsModal"; + modal.className = "lf-shortcuts-modal"; + modal.hidden = true; + modal.innerHTML = ` +
+ + `; + const list = modal.querySelector(".lf-shortcuts-list")!; + for (const { keys, action } of SHORTCUTS) { + const li = document.createElement("li"); + li.innerHTML = `${keys}${action}`; + list.appendChild(li); + } + document.body.appendChild(modal); + return modal; +} + +function openModal(): void { + const modal = ensureModal(); + modal.hidden = false; + modal.querySelector(".lf-shortcuts-close")?.focus(); +} + +function closeModal(): void { + const modal = document.getElementById("lfShortcutsModal"); + if (modal) modal.hidden = true; +} + +function ensureHint(): HTMLElement { + let hint = document.getElementById("lfShortcutsHint"); + if (hint) return hint; + + hint = document.createElement("div"); + hint.id = "lfShortcutsHint"; + hint.className = "lf-shortcuts-hint"; + hint.innerHTML = ` + Tip: press ? for keyboard shortcuts + + `; + const mount = document.getElementById("chatMount"); + if (mount) { + mount.appendChild(hint); + } else { + document.body.appendChild(hint); + } + return hint; +} + +export function ShortcutsSheetPlugin(): ChatPlugin { + return { + name: "shortcuts-sheet", + onMount() { + ensureModal(); + + const modal = document.getElementById("lfShortcutsModal")!; + modal.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + if (target.dataset.close || target.classList.contains("lf-shortcuts-close")) { + closeModal(); + } + }); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + closeModal(); + return; + } + if (e.key === "?" && !e.ctrlKey && !e.metaKey && !e.altKey && !isTypingTarget(e.target)) { + e.preventDefault(); + openModal(); + } + }); + + if (localStorage.getItem(HINT_KEY) !== "1") { + const hint = ensureHint(); + hint.hidden = false; + hint.querySelector(".lf-shortcuts-hint-dismiss")?.addEventListener("click", () => { + hint.hidden = true; + localStorage.setItem(HINT_KEY, "1"); + }); + } + + const footerLink = document.createElement("button"); + footerLink.type = "button"; + footerLink.className = "lf-shortcuts-footer-link"; + footerLink.textContent = "Shortcuts"; + footerLink.title = "Keyboard shortcuts (?)"; + footerLink.addEventListener("click", () => openModal()); + + const credits = document.querySelector(".credits-actions"); + credits?.appendChild(footerLink); + }, + }; +} + +export { openModal as openShortcutsModal, closeModal as closeShortcutsModal }; diff --git a/webui/src/plugins/status-strip/index.ts b/webui/src/plugins/status-strip/index.ts index 05e823c..fa14440 100644 --- a/webui/src/plugins/status-strip/index.ts +++ b/webui/src/plugins/status-strip/index.ts @@ -79,3 +79,14 @@ export function showRateLimitBanner(seconds?: number): void { : " Wait and try again."; mount.innerHTML = `Rate limited.${suffix}`; } + +export function showStatusMessage(text: string, durationMs = 3500): void { + const mount = document.getElementById("lfStatusStrip"); + if (!mount) return; + mount.innerHTML = `${text}`; + window.setTimeout(() => { + if (mount.querySelector(".lf-status-text")?.textContent === text) { + void refreshStatusStrip(mount); + } + }, durationMs); +}