diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 39f3fea..97eb277 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -127,7 +127,7 @@ jobs: run: | cd webui && npm ci && npm run build cd .. - npx playwright test tests/e2e/pages-chat.spec.ts tests/e2e/failover-dual-endpoint.spec.ts tests/e2e/model-picker.spec.ts tests/e2e/message-actions.spec.ts tests/e2e/health-panel.spec.ts tests/e2e/turnstile-gate.spec.ts tests/e2e/catalog-explorer.spec.ts tests/e2e/export-session.spec.ts tests/e2e/hash-routing.spec.ts tests/e2e/import-session.spec.ts tests/e2e/shortcuts-sheet.spec.ts tests/e2e/streaming-polish.spec.ts + npx playwright test tests/e2e/pages-chat.spec.ts tests/e2e/failover-dual-endpoint.spec.ts tests/e2e/model-picker.spec.ts tests/e2e/message-actions.spec.ts tests/e2e/health-panel.spec.ts tests/e2e/turnstile-gate.spec.ts tests/e2e/catalog-explorer.spec.ts tests/e2e/export-session.spec.ts tests/e2e/hash-routing.spec.ts tests/e2e/import-session.spec.ts tests/e2e/shortcuts-sheet.spec.ts tests/e2e/streaming-polish.spec.ts tests/e2e/compare-mode.spec.ts tests/e2e/vision-attach.spec.ts tests/e2e/tier-settings.spec.ts - name: Run live Pages chat e2e (real proxy) env: diff --git a/AGENTS.md b/AGENTS.md index d634d87..e561f87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,6 +189,7 @@ When reviewing or submitting changes: 14. **Bootstrap merge:** `FailoverProvider` must use `loadRuntimeConfig()` (merged `chat_proxy.json` endpoints), not raw `readRuntimeConfig()` alone — stale single-endpoint localStorage hides secondaries until cleared (see `docs/CAVEATS.md`) 15. **Cloudflare auth 10000:** regenerate CF API token with Workers Scripts Edit; when deploy skips, `WORKER_URL` secret keeps Pages building (see `docs/solutions/workflow-issues/github-pages-webui-deploy-and-secrets.md`) 16. **Render deploy-mode YAML:** omit `DATABASE_URL` env var entirely — do not set empty string; deploy generator excludes `database_url` and `allowed_routes` +17. murm-ui streaming flicker: keep `webui/patches/murm-ui+0.2.0.patch` (plain-text tail until complete) via `postinstall` patch-package — bump alone was insufficient (see `docs/solutions/tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md`) ## Compound Engineering diff --git a/CONCEPTS.md b/CONCEPTS.md index 1ec6a22..58d9018 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -41,6 +41,10 @@ Shared vocabulary for the static chat gateway and Python library. | **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) | +| **Provider tier** | User-ordered stage in the omnifail stack (quality API, headless web UI, SearXNG discovery, proxy failover) | +| **Compare mode** | Two-column chat UI: same prompt to two independently configured sources | +| **Web UI provider tier** | Optional headless-browser route against free web chat UIs; requires user-run runner; off by default on public demo | +| **SearXNG discovery tier** | Metasearch pass to suggest candidate free web chat URLs when API/web tiers exhaust | ## Learnings index diff --git a/STRATEGY.md b/STRATEGY.md index d3716e6..824110c 100644 --- a/STRATEGY.md +++ b/STRATEGY.md @@ -1,6 +1,6 @@ --- name: llm-fallbacks -last_updated: 2026-07-24 +last_updated: 2026-07-25 --- # Strategy @@ -15,7 +15,7 @@ Developers want free LLM chat without manually tracking which models exist, whic Endpoint bootstrap is three-layer: Pages CI writes `docs/config.js`, the browser fetches `chat_proxy.json` at runtime, and the committed artifact lists dual proxy URLs for failover. -High availability on free tiers means **best-effort failover with cold-start penalties**, not paid uptime. We say that plainly. +High availability on free tiers means **best-effort failover with cold-start penalties**, not paid uptime. Exhaust free, legal routes the user opted into before giving up; cold starts, quotas, and ToS limits still apply. We say that plainly. ## Who it's for @@ -33,13 +33,13 @@ Open-source builders and power users who want a demo-quality free LLM gateway ti ### Static public chat (GitHub Pages) -Minimal chat SPA as the repo homepage. Uses `free_models.json` for the model browser. Calls proxies only. +Static chat SPA as the repo homepage. Uses `free_models.json` for the model browser. Zero-config path uses ranked catalog + edge/container proxies only. Optional BYOK and user-run companions never embed repo-owned keys. Demo job: make ranked free failover tangible — not become Open WebUI. _Keeps secrets off the static surface and makes the project tangible to visitors._ ### Edge + container proxy HA -Cloudflare Worker primary (CORS, guest auth, rate limits, short fallback chain) plus **Render LiteLLM** as v1 secondary. Both driven by generated configs. Secondary redeploy via Render API when deploy hooks are unavailable; `chat_proxy.json` preserves dual endpoints across Worker-only CI runs. +Cloudflare Worker primary (CORS, guest auth, rate limits, short fallback chain) plus **Render LiteLLM** as v1 secondary. Both driven by generated configs. Secondary redeploy via Render API when deploy hooks are unavailable; `chat_proxy.json` preserves dual endpoints across Worker-only CI runs. Public $0 HA remains Worker + Render LiteLLM; user-run runners are opt-in power-user extensions, not part of the dual-proxy HA story. _Runtime routing and keys cannot live in the browser; reuses `litellm_config_free.yaml` and `free` alias work._ @@ -61,6 +61,7 @@ _Without a living remediation track, operator runbooks drift from production._ - Full TypeScript port of llm-fallbacks discovery - True multi-region DNS HA on $0 - Browser-direct provider calls with repo-owned keys on the public homepage +- Agent gateway features (MCP marketplace, tool execution loops, cloud session sync) — demo stays display/routing-first ## Messaging diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md index 6883c43..d8d7bcc 100644 --- a/docs/CAVEATS.md +++ b/docs/CAVEATS.md @@ -16,6 +16,10 @@ Honest limits for the public chat demo and the Python library. | **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. | +| **Provider tiers vs free-tier limits** | The **Tiers** panel is the omnifail *route stack* (direct/BYOK → optional local runner → SearXNG → cloud proxy). That is separate from cloud *free-tier* rate limits and quotas on OpenRouter / Workers. Reordering tiers changes which route we try first; it does not raise provider quotas. | +| **Bootstrap merge** | Chat endpoints come from `loadRuntimeConfig()` (page `config.js` + `chat_proxy.json` merged with localStorage). If you once saved a single endpoint, that stale localStorage value can hide newer secondaries until you clear site data or re-save Server settings — see AGENTS.md pitfall 14. | +| **Web-UI runner & SearXNG** | Both are **opt-in** and empty by default on the public homepage. You run them locally; you own target-site and SearXNG terms of service. We do not harvest credentials. Exhausting enabled tiers still fails honestly — this is best-effort free HA, not “never fail.” | +| **Vision export** | Session Markdown/JSON export is **text-only**. Attached images are not serialized into export files (thumbnails stay in IndexedDB until you clear site data). | ## Library and CI diff --git a/docs/assets/chat.css b/docs/assets/chat.css index 089f907..17d5187 100644 --- a/docs/assets/chat.css +++ b/docs/assets/chat.css @@ -1269,4 +1269,112 @@ height: 16px; color: inherit; } + +/* node_modules/murm-ui/dist/plugins/attachment/attachment.css */ +.mur-attachment-previews { + display: flex; + gap: 8px; + padding: 4px 8px 12px 8px; + overflow-x: auto; + width: 100%; + max-width: 768px; + pointer-events: auto; +} +.mur-attachment-previews[hidden] { + display: none; +} +.mur-attachment-preview-item { + position: relative; + display: inline-block; + flex-shrink: 0; +} +.mur-attachment-preview-item.mur-attachment-processing { + opacity: 0.68; +} +.mur-attachment-preview-item img { + height: 48px; + border-radius: 6px; + object-fit: cover; +} +.mur-file-preview { + height: 48px; + padding: 0 12px; + background: var(--mur-surface); + border-radius: 6px; + display: flex; + align-items: center; + font-size: 0.85rem; + color: var(--mur-text-muted); + border: 1px solid var(--mur-border); +} +.mur-attachment-preview-item.mur-attachment-error .mur-file-preview { + color: var(--mur-danger-text); + border-color: var(--mur-danger-border); + background: var(--mur-danger-bg); +} +.mur-attachment-spinner { + width: 14px; + height: 14px; + border: 2px solid var(--mur-border); + border-top-color: var(--mur-text-muted); + border-radius: 50%; + animation: mur-attachment-spin 0.8s linear infinite; + margin-right: 8px; + flex-shrink: 0; +} +.mur-attachment-drag-active .mur-chat-form { + border-color: var(--mur-primary); + box-shadow: 0 0 0 3px var(--mur-attachment-drag-ring); +} +@keyframes mur-attachment-spin { + to { + transform: rotate(360deg); + } +} +.mur-attachment-remove-btn { + position: absolute; + top: -6px; + right: -6px; + background: var(--mur-text-secondary); + color: var(--mur-inverse-text); + border: none; + border-radius: 50%; + width: 20px; + height: 20px; + font-size: 14px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--mur-shadow-attachment); + opacity: 0.8; +} +.mur-attachment-remove-btn:hover { + background: var(--mur-danger); + opacity: 1; +} +.mur-message-attachments { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.5rem; +} +.mur-attachment-image { + max-width: 100%; + max-height: 300px; + border-radius: 0.5rem; + object-fit: contain; + background-color: var(--mur-surface); +} +.mur-attachment-file-pill { + display: inline-flex; + align-items: center; + padding: 0.5rem 0.75rem; + background: var(--mur-surface); + border-radius: 0.5rem; + font-size: 0.85rem; + color: var(--mur-text-muted); + border: 1px solid var(--mur-border); +} /*# sourceMappingURL=chat.css.map */ diff --git a/docs/assets/chat.js b/docs/assets/chat.js index dbc11c3..1355df1 100644 --- a/docs/assets/chat.js +++ b/docs/assets/chat.js @@ -1382,6 +1382,7 @@ function syncDOMNode(target, source) { var ICON_COPY = ``; var ICON_CHECK = ``; var ICON_EDIT = ``; +var ICON_PAPERCLIP = ``; var ICON_CHEVRON = ``; var ICON_MORE_VERTICAL = ``; var ICON_PIN = ``; @@ -5012,12 +5013,319 @@ function CopyPlugin() { }; } +// node_modules/murm-ui/dist/plugins/attachment/attachment-plugin.js +var DEFAULT_ACCEPTED_TYPES = "image/*,text/*,.csv,.json,.md"; +var TEXT_FILE_EXTENSIONS = /* @__PURE__ */ new Set(["csv", "json", "md"]); +function AttachmentPlugin(config) { + var _a, _b; + const maxSize = (_a = config === null || config === void 0 ? void 0 : config.maxFileSize) !== null && _a !== void 0 ? _a : 20 * 1024 * 1024; + const acceptedTypes = (_b = config === null || config === void 0 ? void 0 : config.acceptedTypes) !== null && _b !== void 0 ? _b : DEFAULT_ACCEPTED_TYPES; + let queue = []; + let fileInput; + let previewContainer; + let attachBtn; + let inputContext = null; + let dragDepth = 0; + let destroyed = false; + const syncSubmitState = () => inputContext === null || inputContext === void 0 ? void 0 : inputContext.requestSubmitStateSync(); + const renderPreviews = () => { + if (!previewContainer) + return; + previewContainer.innerHTML = ""; + previewContainer.hidden = queue.length === 0; + queue.forEach((item) => { + var _a2, _b2; + const previewItem = el("div", `mur-attachment-preview-item mur-attachment-${item.state}`); + previewItem.setAttribute("data-attachment-state", item.state); + if (item.state === "processing") { + previewItem.appendChild(el("div", "mur-file-preview", null, [ + el("span", "mur-attachment-spinner"), + el("span", "", { textContent: (_a2 = item.statusText) !== null && _a2 !== void 0 ? _a2 : "Processing..." }) + ])); + } else if (item.state === "error") { + previewItem.appendChild(el("div", "mur-file-preview", { textContent: (_b2 = item.error) !== null && _b2 !== void 0 ? _b2 : "Unsupported type" })); + } else { + renderReadyPreview(item, previewItem); + } + const removeBtn = el("button", "mur-attachment-remove-btn", { + innerHTML: "\xD7", + type: "button", + onclick: () => { + queue = queue.filter((queuedItem) => queuedItem.id !== item.id); + renderPreviews(); + syncSubmitState(); + } + }); + removeBtn.setAttribute("aria-label", `Remove ${item.fileName}`); + previewItem.appendChild(removeBtn); + previewContainer.appendChild(previewItem); + }); + }; + const queueFiles = (files) => { + for (const file of files) { + void queueFile(file); + } + }; + const queueFile = async (file) => { + var _a2, _b2; + const item = { + id: uuidv7(), + fileName: file.name || "Untitled file", + mimeType: file.type || "application/octet-stream", + state: "processing", + statusText: (config === null || config === void 0 ? void 0 : config.uploadFile) ? "Uploading..." : "Processing..." + }; + queue.push(item); + renderPreviews(); + syncSubmitState(); + if (file.size > maxSize) { + updateItemError(item.id, "File too large"); + (_a2 = config === null || config === void 0 ? void 0 : config.onSizeExceeded) === null || _a2 === void 0 ? void 0 : _a2.call(config, file, maxSize); + return; + } + try { + const block = await processFile(file); + updateItemReady(item.id, block); + } catch (error) { + const message = error instanceof Error ? error.message : "Unsupported type"; + updateItemError(item.id, message); + if (message === "Unsupported type") { + (_b2 = config === null || config === void 0 ? void 0 : config.onUnsupportedFile) === null || _b2 === void 0 ? void 0 : _b2.call(config, file); + } + } + }; + const updateItemReady = (id, block) => { + const item = queue.find((queuedItem) => queuedItem.id === id); + if (!item || destroyed) + return; + item.state = "ready"; + item.block = block; + item.mimeType = getBlockMimeType(block, item.mimeType); + item.statusText = void 0; + item.error = void 0; + renderPreviews(); + syncSubmitState(); + }; + const updateItemError = (id, error) => { + const item = queue.find((queuedItem) => queuedItem.id === id); + if (!item || destroyed) + return; + item.state = "error"; + item.error = error; + item.statusText = void 0; + renderPreviews(); + syncSubmitState(); + }; + const processFile = async (file) => { + var _a2, _b2; + const handler = (_a2 = config === null || config === void 0 ? void 0 : config.fileHandlers) === null || _a2 === void 0 ? void 0 : _a2.find((candidate) => candidate.accepts(file)); + if (handler) { + return handler.process(file); + } + if (config === null || config === void 0 ? void 0 : config.uploadFile) { + const uploaded = await config.uploadFile(file); + return { + id: uuidv7(), + type: "file", + mimeType: uploaded.type, + name: (_b2 = uploaded.name) !== null && _b2 !== void 0 ? _b2 : file.name, + data: uploaded.data + }; + } + if (file.type.startsWith("image/")) { + return { + id: uuidv7(), + type: "file", + mimeType: file.type, + name: file.name, + data: await readFile(file, "data-url") + }; + } + if (isTextLikeFile(file)) { + return { + id: uuidv7(), + type: "file", + mimeType: file.type || mimeTypeFromName(file.name), + name: file.name, + data: await readFile(file, "text") + }; + } + throw new Error("Unsupported type"); + }; + const onFileInputChange = () => { + queueFiles(Array.from(fileInput.files || [])); + fileInput.value = ""; + }; + const onDragEnter = (event) => { + if (!hasDraggedFiles(event)) + return; + event.preventDefault(); + dragDepth++; + inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.classList.add("mur-attachment-drag-active"); + }; + const onDragOver = (event) => { + if (!hasDraggedFiles(event)) + return; + event.preventDefault(); + }; + const onDragLeave = (event) => { + if (!hasDraggedFiles(event)) + return; + event.preventDefault(); + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) { + inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.classList.remove("mur-attachment-drag-active"); + } + }; + const onDrop = (event) => { + var _a2; + if (!hasDraggedFiles(event)) + return; + event.preventDefault(); + dragDepth = 0; + inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.classList.remove("mur-attachment-drag-active"); + queueFiles(Array.from(((_a2 = event.dataTransfer) === null || _a2 === void 0 ? void 0 : _a2.files) || [])); + }; + const onPaste = (event) => { + var _a2; + const files = Array.from(((_a2 = event.clipboardData) === null || _a2 === void 0 ? void 0 : _a2.files) || []); + if (files.length === 0) + return; + if (!hasClipboardText(event)) { + event.preventDefault(); + } + queueFiles(files); + }; + return { + name: "attachments", + onInputMount: (ctx) => { + inputContext = ctx; + destroyed = false; + previewContainer = el("div", "mur-attachment-previews"); + previewContainer.hidden = true; + fileInput = el("input", "", { type: "file", hidden: true, multiple: true, accept: acceptedTypes }); + attachBtn = el("button", "mur-form-icon-btn", { + type: "button", + innerHTML: ICON_PAPERCLIP, + onclick: () => fileInput.click() + }); + attachBtn.setAttribute("aria-label", "Attach files"); + attachBtn.title = "Attach files"; + ctx.form.prepend(attachBtn); + if (config === null || config === void 0 ? void 0 : config.previewMountSelector) { + const selectorRoot = config.previewMountSelectorScope === "document" ? document : ctx.container; + const customTarget = selectorRoot.querySelector(config.previewMountSelector); + if (customTarget) { + customTarget.appendChild(previewContainer); + } else { + console.error(`AttachmentPlugin: Could not find element matching previewMountSelector "${config.previewMountSelector}". Image previews will not be visible.`); + } + } else { + ctx.form.before(previewContainer); + } + ctx.form.appendChild(fileInput); + fileInput.addEventListener("change", onFileInputChange); + ctx.container.addEventListener("dragenter", onDragEnter); + ctx.container.addEventListener("dragover", onDragOver); + ctx.container.addEventListener("dragleave", onDragLeave); + ctx.container.addEventListener("drop", onDrop); + ctx.input.addEventListener("paste", onPaste); + }, + hasPendingData: () => queue.some((item) => item.state === "ready" && item.block), + isSubmitBlocked: () => queue.some((item) => item.state === "processing"), + onUserSubmit: (msg) => { + const readyBlocks = queue.flatMap((item) => item.state === "ready" && item.block ? [item.block] : []); + if (readyBlocks.length > 0) { + msg.blocks.unshift(...readyBlocks); + queue = queue.filter((item) => item.state !== "ready"); + renderPreviews(); + syncSubmitState(); + } + }, + destroy: () => { + destroyed = true; + fileInput === null || fileInput === void 0 ? void 0 : fileInput.removeEventListener("change", onFileInputChange); + inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.removeEventListener("dragenter", onDragEnter); + inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.removeEventListener("dragover", onDragOver); + inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.removeEventListener("dragleave", onDragLeave); + inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.removeEventListener("drop", onDrop); + inputContext === null || inputContext === void 0 ? void 0 : inputContext.input.removeEventListener("paste", onPaste); + inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.classList.remove("mur-attachment-drag-active"); + fileInput === null || fileInput === void 0 ? void 0 : fileInput.remove(); + attachBtn === null || attachBtn === void 0 ? void 0 : attachBtn.remove(); + previewContainer === null || previewContainer === void 0 ? void 0 : previewContainer.remove(); + queue = []; + inputContext = null; + dragDepth = 0; + } + }; +} +function renderReadyPreview(item, previewItem) { + var _a, _b; + const block = item.block; + if ((block === null || block === void 0 ? void 0 : block.type) === "file" && block.mimeType.startsWith("image/")) { + previewItem.appendChild(el("img", "", { src: block.data, alt: (_a = block.name) !== null && _a !== void 0 ? _a : item.fileName })); + return; + } + const label = (block === null || block === void 0 ? void 0 : block.type) === "file" ? (_b = block.name) !== null && _b !== void 0 ? _b : item.fileName : item.fileName; + previewItem.appendChild(el("div", "mur-file-preview", { textContent: `\u{1F4C4} ${label}` })); +} +function getBlockMimeType(block, fallback) { + return block.type === "file" ? block.mimeType : fallback; +} +function hasDraggedFiles(event) { + var _a; + const types = (_a = event.dataTransfer) === null || _a === void 0 ? void 0 : _a.types; + if (!types) + return false; + return Array.from(types).includes("Files"); +} +function hasClipboardText(event) { + const data = event.clipboardData; + if (!data) + return false; + const types = Array.from(data.types || []); + return types.includes("text/plain") || types.includes("text/html") || typeof data.getData === "function" && data.getData("text/plain").length > 0; +} +function isTextLikeFile(file) { + if (file.type.startsWith("text/") || file.type === "application/json") + return true; + const extension = getFileExtension(file.name); + return extension !== "" && TEXT_FILE_EXTENSIONS.has(extension); +} +function mimeTypeFromName(fileName) { + return getFileExtension(fileName) === "json" ? "application/json" : "text/plain"; +} +function getFileExtension(fileName) { + const index = fileName.lastIndexOf("."); + return index === -1 ? "" : fileName.slice(index + 1).toLowerCase(); +} +function readFile(file, mode) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + var _a; + return resolve(String((_a = reader.result) !== null && _a !== void 0 ? _a : "")); + }; + reader.onerror = () => { + var _a; + return reject((_a = reader.error) !== null && _a !== void 0 ? _a : new Error("Failed to read file")); + }; + if (mode === "data-url") { + reader.readAsDataURL(file); + } else { + reader.readAsText(file); + } + }); +} + // src/storage-keys.ts var STORAGE_KEYS = { endpoints: "llm_fallbacks_proxy_endpoints", guestToken: "llm_fallbacks_guest_token", defaultModel: "llm_fallbacks_default_model", - apiKeys: "llm_fallbacks_api_keys" + apiKeys: "llm_fallbacks_api_keys", + providerTiers: "llm_fallbacks_provider_tiers" }; function loadJson(key, fallback) { try { @@ -5340,10 +5648,6 @@ function shouldTryBrowser(model, catalog, keys) { } return hasAnyKey(keys) && hasKeyForModel(model, keys); } -function shouldFallbackToProxy(browserErr) { - const msg = browserErr.message || String(browserErr); - return msg === "BROWSER_UNAVAILABLE" || msg === "PROXY_UNAVAILABLE" || /^no API key for /i.test(msg) || /^unsupported provider:/i.test(msg); -} // src/turnstile-session.ts var siteKey; @@ -5631,6 +5935,42 @@ function mapProxyChainFailure(lastError, rateLimit) { return new ProxyUnavailableError(lastError); } +// src/providers/message-openai.ts +function isImageFileBlock(block) { + return block.type === "file" && typeof block.mimeType === "string" && block.mimeType.startsWith("image/") && typeof block.data === "string"; +} +function messageText(message) { + return message.blocks.filter((b2) => b2.type === "text").map((b2) => b2.type === "text" ? b2.text : "").join(""); +} +function messageHasImage(message) { + return message.blocks.some((b2) => isImageFileBlock(b2)); +} +function messagesHaveImage(messages) { + return messages.some((m2) => messageHasImage(m2)); +} +function messagesToOpenAi(messages) { + return messages.map((message) => { + const text = messageText(message); + const images = message.blocks.filter((b2) => isImageFileBlock(b2)); + if (images.length === 0) { + return { role: message.role, content: text }; + } + const parts = []; + if (text) parts.push({ type: "text", text }); + for (const image of images) { + parts.push({ type: "image_url", image_url: { url: image.data } }); + } + return { role: message.role, content: parts }; + }); +} +function messagesToPlainText(messages) { + return messages.map((message) => ({ role: message.role, content: messageText(message) })); +} +function modelSupportsVision(modelId, catalog) { + const entry = catalog.find((e) => e.id === modelId); + return entry?.supports_vision === true; +} + // src/providers/routing-metadata.ts var lastCompletionMeta = null; var COMPLETION_META_EVENT = "llm-fallbacks:completion-meta"; @@ -5752,17 +6092,290 @@ function emitTextAsStreamEvents(text, onEvent) { onEvent({ type: "finish", reason: "stop" }); } +// src/providers/tiers/defaults.ts +var TIER_IDS = [ + "quality_api", + "web_ui", + "searxng_discovery", + "proxy_failover" +]; +var DEFAULT_TIER_ENTRIES = [ + { id: "quality_api", enabled: true }, + { id: "web_ui", enabled: false }, + { id: "searxng_discovery", enabled: false }, + { id: "proxy_failover", enabled: true } +]; +var DEFAULT_ENABLED_BY_ID = new Map( + DEFAULT_TIER_ENTRIES.map((t) => [t.id, t.enabled]) +); +function defaultProviderTierSettings() { + return { + tiers: DEFAULT_TIER_ENTRIES.map((t) => ({ ...t })), + webRunnerUrl: "", + searxngUrl: "" + }; +} +function normalizeTierSettings(raw) { + const seen = /* @__PURE__ */ new Set(); + const tiers = []; + for (const entry of raw.tiers ?? []) { + if (!DEFAULT_ENABLED_BY_ID.has(entry.id) || seen.has(entry.id)) continue; + seen.add(entry.id); + tiers.push({ id: entry.id, enabled: !!entry.enabled }); + } + for (const id of TIER_IDS) { + if (!seen.has(id)) tiers.push({ id, enabled: DEFAULT_ENABLED_BY_ID.get(id) ?? false }); + } + return { + tiers, + webRunnerUrl: raw.webRunnerUrl?.trim() ?? "", + searxngUrl: raw.searxngUrl?.trim() ?? "" + }; +} + +// src/providers/tiers/settings.ts +function loadProviderTierSettings() { + const fallback = defaultProviderTierSettings(); + const raw = loadJson(STORAGE_KEYS.providerTiers, fallback); + return normalizeTierSettings({ + tiers: raw.tiers ?? fallback.tiers, + webRunnerUrl: raw.webRunnerUrl ?? "", + searxngUrl: raw.searxngUrl ?? "" + }); +} +function saveProviderTierSettings(settings) { + saveJson(STORAGE_KEYS.providerTiers, normalizeTierSettings(settings)); +} + +// src/providers/tiers/types.ts +var TierOrchestratorError = class extends Error { + attempts; + constructor(message, attempts) { + super(message); + this.name = "TierOrchestratorError"; + this.attempts = attempts; + } +}; +var TierSkipError = class extends Error { + tier; + constructor(tier, reason) { + super(reason); + this.name = "TierSkipError"; + this.tier = tier; + } +}; + +// src/providers/tiers/orchestrator.ts +function formatAttemptError(err) { + if (err instanceof Error) return err.message; + return String(err); +} +var TierOrchestrator = class { + constructor(handlers) { + this.handlers = handlers; + } + async streamChat(request, onEvent) { + const settings = loadProviderTierSettings(); + const attempts = []; + for (const entry of settings.tiers) { + if (!entry.enabled) continue; + const handler = this.handlerFor(entry.id); + if (!handler) continue; + try { + await handler(request, onEvent); + return; + } catch (err) { + if (err instanceof TierSkipError) { + attempts.push({ tier: entry.id, error: err.message }); + continue; + } + attempts.push({ tier: entry.id, error: formatAttemptError(err) }); + if (request.signal.aborted) throw err; + } + } + const summary = attempts.map((a) => `${a.tier}: ${a.error}`).join("; "); + throw new TierOrchestratorError( + summary || "No provider tiers are enabled.", + attempts + ); + } + handlerFor(id) { + switch (id) { + case "quality_api": + return this.handlers.qualityApi; + case "web_ui": + return this.handlers.webUi; + case "searxng_discovery": + return this.handlers.searxngDiscovery; + case "proxy_failover": + return this.handlers.proxyFailover; + default: + return null; + } + } +}; +function qualityApiTierUnavailable() { + return new TierSkipError( + "quality_api", + "No BYOK API key set for the selected model \u2014 skipping direct routes." + ); +} +function webUiTierUnavailable() { + return new TierSkipError("web_ui", "Web UI tier is not configured."); +} +function searxngTierUnavailable() { + return new TierSkipError("searxng_discovery", "SearXNG discovery is not configured."); +} + +// src/providers/tiers/searxng-discovery-tier.ts +var DiscoveryEmptyError = class extends Error { + constructor(query) { + super(`SearXNG returned no candidate free chat sites for "${query}".`); + this.name = "DiscoveryEmptyError"; + } +}; +var DiscoveryUnavailableError = class extends Error { + constructor(endpoint, cause) { + super( + `SearXNG at ${endpoint} is unreachable (${cause}). Check the URL in Tiers settings and that the instance allows browser requests (CORS).` + ); + this.name = "DiscoveryUnavailableError"; + } +}; +var DEFAULT_DISCOVERY_QUERY = "free AI chat online no signup"; +var CHAT_HINT_RE = /\b(chat|gpt|assistant|llm|ai)\b/i; +var EXCLUDED_HOST_RE = /(^|\.)(wikipedia\.org|youtube\.com|reddit\.com|github\.com|medium\.com|x\.com|twitter\.com|facebook\.com|linkedin\.com)$/i; +var MAX_CANDIDATES = 6; +function hostOf(url) { + try { + return new URL(url).hostname; + } catch { + return null; + } +} +function filterChatCandidates(results) { + const seenHosts = /* @__PURE__ */ new Set(); + const candidates = []; + for (const result of results) { + const url = result.url?.trim() ?? ""; + if (!url.startsWith("https://")) continue; + const host = hostOf(url); + if (!host || seenHosts.has(host) || EXCLUDED_HOST_RE.test(host)) continue; + const haystack = `${url} ${result.title ?? ""} ${result.content ?? ""}`; + if (!CHAT_HINT_RE.test(haystack)) continue; + seenHosts.add(host); + candidates.push({ + url, + title: result.title?.trim() || host, + snippet: (result.content ?? "").trim().slice(0, 200) + }); + if (candidates.length >= MAX_CANDIDATES) break; + } + return candidates; +} +function discoverySearchUrl(searxngUrl, query) { + const base = searxngUrl.replace(/\/$/, ""); + return `${base}/search?q=${encodeURIComponent(query)}&format=json`; +} +async function searchFreeChatCandidates(options) { + const query = options.query?.trim() || DEFAULT_DISCOVERY_QUERY; + const doFetch = options.fetchImpl ?? fetch; + const url = discoverySearchUrl(options.searxngUrl, query); + let res; + try { + res = await doFetch(url, { + headers: { Accept: "application/json" }, + signal: options.signal + }); + } catch (err) { + if (options.signal?.aborted) throw err; + const cause = err instanceof Error ? err.message : String(err); + throw new DiscoveryUnavailableError(options.searxngUrl, cause || "network/CORS error"); + } + if (!res.ok) { + throw new DiscoveryUnavailableError(options.searxngUrl, `HTTP ${res.status}`); + } + let parsed; + try { + parsed = await res.json(); + } catch { + throw new DiscoveryUnavailableError( + options.searxngUrl, + "non-JSON response \u2014 enable the JSON format in SearXNG settings" + ); + } + const candidates = filterChatCandidates(parsed.results ?? []); + if (candidates.length === 0) { + throw new DiscoveryEmptyError(query); + } + return candidates; +} +var DISCOVERY_RESULTS_EVENT = "llm-fallbacks:discovery-results"; +function broadcastDiscoveryResults(candidates) { + window.dispatchEvent( + new CustomEvent(DISCOVERY_RESULTS_EVENT, { detail: { candidates } }) + ); +} + +// src/providers/tiers/web-ui-tier.ts +var WebRunnerNotConfiguredError = class extends Error { + constructor(runnerUrl, detail) { + super( + `Web runner at ${runnerUrl} has no adapter configured (${detail}). Set up runner/runner.config.json \u2014 see runner/README.md.` + ); + this.name = "WebRunnerNotConfiguredError"; + } +}; +var WebRunnerUnavailableError = class extends Error { + constructor(runnerUrl, cause) { + super( + `Web runner at ${runnerUrl} is unreachable (${cause}). Check that the runner is started and the URL in Tiers settings is correct.` + ); + this.name = "WebRunnerUnavailableError"; + } +}; +function runnerChatUrl(runnerUrl) { + return `${runnerUrl.replace(/\/$/, "")}/v1/chat/completions`; +} +async function streamFromWebRunner(options) { + const doFetch = options.fetchImpl ?? fetch; + let res; + try { + res = await doFetch(runnerChatUrl(options.runnerUrl), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: options.model, + messages: options.messages, + max_tokens: options.maxTokens, + stream: true + }), + signal: options.signal + }); + } catch (err) { + if (options.signal.aborted) throw err; + const cause = err instanceof Error ? err.message : String(err); + throw new WebRunnerUnavailableError(options.runnerUrl, cause || "network/CORS error"); + } + if (res.status === 501) { + const bodyText = await res.text(); + throw new WebRunnerNotConfiguredError(options.runnerUrl, bodyText.slice(0, 160) || "HTTP 501"); + } + if (!res.ok) { + const bodyText = await res.text(); + throw new WebRunnerUnavailableError( + options.runnerUrl, + `HTTP ${res.status} \u2014 ${bodyText.slice(0, 160)}` + ); + } + await emitOpenAiSseAsStreamEvents(res, options.onEvent); +} + // src/providers/FailoverProvider.ts function endpointUrl(base) { const trimmed = base.replace(/\/$/, ""); return trimmed.endsWith("/v1/chat/completions") ? trimmed : `${trimmed}/v1/chat/completions`; } -function messagesToOpenAi(messages) { - return messages.map((m2) => { - const text = m2.blocks.filter((b2) => b2.type === "text").map((b2) => b2.type === "text" ? b2.text : "").join(""); - return { role: m2.role, content: text }; - }); -} function readRoutingHeaders(res) { const modelHeader = res.headers.get("x-litellm-model-name") || res.headers.get("x-litellm-model-id") || void 0; const durationRaw = res.headers.get("x-litellm-response-duration-ms"); @@ -5924,86 +6537,144 @@ var FailoverProvider = class { } throw mapProxyChainFailure(lastError, lastRateLimit); } - async streamChat(request, onEvent) { + buildChatBody(request) { const config = this.getRuntimeConfig(); const model = this.resolveModel(request); - const openAiMessages = messagesToOpenAi(request.messages); const body = { model, - messages: openAiMessages, + messages: messagesToOpenAi(request.messages), max_tokens: request.options.max_tokens ?? config.maxTokens }; - const keys = loadKeys(); - const userKeys = keys; - const tryBrowser = async (onEv) => { - const result = await chatWithBrowserFallback({ - model, - messages: openAiMessages, - maxTokens: body.max_tokens, - catalog: this.catalog, - providerUrls: this.providerUrls, - keys: userKeys, - onStatus: (s) => this.setStatus(s) - }); - this.lastRoute = result.route; - window.LLM_FALLBACKS_ROUTE = result.route; - setLastCompletionMeta({ - endpoint: result.route, - fallbackCount: 0 - }); - emitTextAsStreamEvents(result.content, onEv); + return { + config, + model, + body, + plainMessages: messagesToPlainText(request.messages), + hasImage: messagesHaveImage(request.messages) }; - if (config.endpoints.length) { - try { - await this.streamWithCompletionTracking( - (onEv) => this.streamProxyFallback(body, config, onEv, request.signal), - onEvent - ); - return; - } catch (proxyErr) { - if (!shouldTryBrowser(model, this.catalog, userKeys)) throw proxyErr; - this.setStatus("cloud proxy unavailable \u2014 trying optional browser route \u2026"); + } + // quality_api tier: direct/BYOK provider routes only. Disjoint from + // proxy_failover (KTD7) — this tier never touches the cloud proxy chain, so + // a single request is not attempted twice against the same endpoint. Without + // a usable key it skips, letting the orchestrator advance to proxy_failover. + async streamQualityApiRoute(request, onEvent) { + const { model, body, plainMessages, hasImage } = this.buildChatBody(request); + const userKeys = loadKeys(); + if (hasImage) { + throw new TierSkipError( + "quality_api", + "Direct BYOK routes do not support image attachments yet \u2014 using the proxy tier." + ); + } + if (!shouldTryBrowser(model, this.catalog, userKeys)) { + throw qualityApiTierUnavailable(); + } + const result = await chatWithBrowserFallback({ + model, + messages: plainMessages, + maxTokens: body.max_tokens, + catalog: this.catalog, + providerUrls: this.providerUrls, + keys: userKeys, + onStatus: (s) => this.setStatus(s) + }); + this.lastRoute = result.route; + window.LLM_FALLBACKS_ROUTE = this.lastRoute; + setLastCompletionMeta({ + endpoint: result.route, + fallbackCount: 0 + }); + emitTextAsStreamEvents(result.content, onEvent); + } + async streamProxyFailoverRoute(request, onEvent) { + const { config, body } = this.buildChatBody(request); + await this.streamProxyFallback(body, config, onEvent, request.signal); + } + async streamWebUiRoute(request, onEvent) { + const settings = loadProviderTierSettings(); + if (!settings.webRunnerUrl) { + throw webUiTierUnavailable(); + } + const { model, body, plainMessages, hasImage } = this.buildChatBody(request); + if (hasImage) { + throw new TierSkipError( + "web_ui", + "The web runner does not support image attachments \u2014 using the proxy tier." + ); + } + this.setStatus(`web runner: ${settings.webRunnerUrl} \u2026`); + let metaSet = false; + await streamFromWebRunner({ + runnerUrl: settings.webRunnerUrl, + model, + messages: plainMessages, + maxTokens: body.max_tokens, + signal: request.signal, + onEvent: (event) => { + if (!metaSet) { + metaSet = true; + this.lastRoute = `web_ui/${settings.webRunnerUrl}`; + window.LLM_FALLBACKS_ROUTE = this.lastRoute; + setLastCompletionMeta({ endpoint: this.lastRoute, fallbackCount: 0 }); + } + onEvent(event); } + }); + } + async streamSearxngDiscoveryRoute(request, _onEvent) { + const settings = loadProviderTierSettings(); + if (!settings.searxngUrl) { + throw searxngTierUnavailable(); } - if (model !== "free" && !shouldTryBrowser(model, this.catalog, userKeys)) { - if (config.endpoints.length) { - await this.streamWithCompletionTracking( - (onEv) => this.streamProxyFallback({ ...body, model: "free" }, config, onEv, request.signal), - onEvent + this.setStatus("searxng: searching for free chat sites \u2026"); + const candidates = await searchFreeChatCandidates({ + searxngUrl: settings.searxngUrl, + signal: request.signal + }); + broadcastDiscoveryResults(candidates); + throw new Error( + `SearXNG found ${candidates.length} candidate chat site${candidates.length === 1 ? "" : "s"} \u2014 see suggestions below the chat.` + ); + } + async streamChat(request, onEvent) { + if (messagesHaveImage(request.messages)) { + const model = this.resolveModel(request); + if (!modelSupportsVision(model, this.catalog)) { + throw new ChatRouteError( + "vision_unsupported", + `"${model}" can't read images. Pick a vision-capable model (filter by vision in the model explorer) or remove the attachment.` ); - return; } - throw new Error( - "Selected model requires an API key for its provider. Choose free or add the provider key in Settings." - ); } - if (shouldTryBrowser(model, this.catalog, userKeys)) { - try { - await this.streamWithCompletionTracking((onEv) => tryBrowser(onEv), onEvent); - return; - } catch (browserErr) { - const err = browserErr instanceof Error ? browserErr : new Error(String(browserErr)); - if (config.endpoints.length && shouldFallbackToProxy(err)) { - this.setStatus("browser route failed \u2014 retrying cloud proxy \u2026"); - await this.streamWithCompletionTracking( - (onEv) => this.streamProxyFallback(body, config, onEv, request.signal), - onEvent - ); - return; - } - throw err; + const orchestrator = new TierOrchestrator({ + qualityApi: (req, onEv) => this.streamWithCompletionTracking((inner) => this.streamQualityApiRoute(req, inner), onEv), + webUi: (req, onEv) => this.streamWithCompletionTracking((inner) => this.streamWebUiRoute(req, inner), onEv), + searxngDiscovery: (req, onEv) => this.streamWithCompletionTracking( + (inner) => this.streamSearxngDiscoveryRoute(req, inner), + onEv + ), + proxyFailover: (req, onEv) => this.streamWithCompletionTracking((inner) => this.streamProxyFailoverRoute(req, inner), onEv) + }); + try { + await orchestrator.streamChat(request, onEvent); + } catch (err) { + if (err instanceof TierOrchestratorError) { + throw this.mapTierFailure(err); } + throw err; } - if (config.endpoints.length) { - await this.streamWithCompletionTracking( - (onEv) => this.streamProxyFallback(body, config, onEv, request.signal), - onEvent + } + // Preserve the ChatRouteError taxonomy (rate-limit / quota / cold-start) while + // surfacing which tiers were tried and their last error (R40). A no-attempt + // failure means every tier skipped or none were enabled. + mapTierFailure(err) { + if (err.attempts.length === 0) { + return mapProxyChainFailure( + "No chat routes are available yet. Enable a provider tier in Settings, or wait for the demo proxy to finish deploying." ); - return; } - throw mapProxyChainFailure( - "No chat routes are available yet. The demo proxy is still deploying \u2014 refresh in a minute." - ); + const summary = err.attempts.map((a) => `${a.tier} \u2192 ${a.error}`).join(" | "); + return mapProxyChainFailure(summary); } }; @@ -6240,6 +6911,443 @@ function ByokSettingsPlugin(deps) { }; } +// src/plugins/tier-settings/settings.ts +var TIER_LABELS = { + quality_api: "Direct / BYOK routes", + web_ui: "Local web-UI runner (opt-in)", + searxng_discovery: "SearXNG discovery (opt-in)", + proxy_failover: "Cloud proxy failover" +}; +var TIER_HINTS = { + quality_api: "Uses API keys stored in this browser. Skips when no key matches the selected model.", + web_ui: "Optional local companion that drives a browser chat UI. Off by default \u2014 you run it.", + searxng_discovery: "Optional self-hosted SearXNG. Suggests free chat URLs when higher tiers fail.", + proxy_failover: "Public Worker / Render endpoints from Server settings. Serves zero-config visitors." +}; +function moveTier(settings, tierId, delta) { + const tiers = settings.tiers.map((t) => ({ ...t })); + const from = tiers.findIndex((t) => t.id === tierId); + if (from < 0) return settings; + const to = from + delta; + if (to < 0 || to >= tiers.length) return settings; + const [entry] = tiers.splice(from, 1); + tiers.splice(to, 0, entry); + return normalizeTierSettings({ ...settings, tiers }); +} +function setTierEnabled(settings, tierId, enabled) { + const tiers = settings.tiers.map( + (t) => t.id === tierId ? { ...t, enabled } : { ...t } + ); + return normalizeTierSettings({ ...settings, tiers }); +} +function updateCompanionUrls(settings, urls) { + return normalizeTierSettings({ + ...settings, + webRunnerUrl: urls.webRunnerUrl ?? settings.webRunnerUrl, + searxngUrl: urls.searxngUrl ?? settings.searxngUrl + }); +} +function persistTierSettings(settings) { + const normalized = normalizeTierSettings(settings); + saveProviderTierSettings(normalized); + return loadProviderTierSettings(); +} + +// src/plugins/tier-settings/index.ts +function renderTierList(settings) { + return settings.tiers.map((tier, index) => { + const label = TIER_LABELS[tier.id] ?? tier.id; + const hint = TIER_HINTS[tier.id] ?? ""; + return ` +
  • +
    + +
    + + +
    +
    +

    ${hint}

    +
  • + `; + }).join(""); +} +function TierSettingsPlugin() { + return { + name: "tier-settings", + onMount() { + window.registerShellPanel?.("tiers", (root) => { + let draft = loadProviderTierSettings(); + const paint = () => { + root.innerHTML = ` +
    +

    Provider tiers

    +
    +

    + Ordered routes we try for each chat. This is the omnifail stack + (which route to attempt), not cloud free-tier rate limits. + Exhausting enabled tiers still fails honestly \u2014 we do not promise never-fail. +

    +
      ${renderTierList(draft)}
    + +

    + Opt-in local companion. Off by default. You run it; it lowers pressure on the + public Worker demo. You are responsible for target-site terms \u2014 we do not + harvest credentials. +

    + +

    + Opt-in self-hosted search. Empty disables discovery. Respect SearXNG and + target-site terms of service. +

    +
    + + +
    +

    + `; + const list = root.querySelector("#lf-tier-list"); + list?.addEventListener("click", (event) => { + const target = event.target; + const up = target.closest("[data-tier-up]"); + const down = target.closest("[data-tier-down]"); + if (up?.dataset.tierUp) { + draft = moveTier(draft, up.dataset.tierUp, -1); + paint(); + return; + } + if (down?.dataset.tierDown) { + draft = moveTier(draft, down.dataset.tierDown, 1); + paint(); + } + }); + list?.addEventListener("change", (event) => { + const input = event.target; + const tierId = input.dataset.tierEnable; + if (!tierId || input.type !== "checkbox") return; + draft = setTierEnabled(draft, tierId, input.checked); + }); + root.querySelector("#lf-tier-reset")?.addEventListener("click", () => { + draft = persistTierSettings(defaultProviderTierSettings()); + paint(); + const status = root.querySelector("#lf-tier-status"); + if (status) status.textContent = "Restored zero-config defaults."; + }); + root.querySelector("#lf-tier-save")?.addEventListener("click", () => { + const webRunnerUrl = root.querySelector("#lf-web-runner-url")?.value ?? ""; + const searxngUrl = root.querySelector("#lf-searxng-url")?.value ?? ""; + draft = updateCompanionUrls(draft, { webRunnerUrl, searxngUrl }); + draft = persistTierSettings(draft); + paint(); + const status = root.querySelector("#lf-tier-status"); + if (status) { + status.textContent = `Saved order: ${draft.tiers.filter((t) => t.enabled).map((t) => t.id).join(" \u2192 ") || "(none enabled)"}`; + } + }); + }; + paint(); + }); + } + }; +} + +// src/plugins/compare-mode/column-provider.ts +function defaultCompareState(activeModel = "free") { + return { + active: false, + columns: { + a: { model: activeModel || "free" }, + b: { model: "openrouter/free" } + } + }; +} +function columnIsMetered(model, catalog, keys = loadKeys()) { + if (model === "free") return true; + return !shouldTryBrowser(model, catalog, keys); +} +function bothColumnsMetered(state, catalog, keys = loadKeys()) { + if (!state.active) return false; + return columnIsMetered(state.columns.a.model, catalog, keys) && columnIsMetered(state.columns.b.model, catalog, keys); +} +var METERED_COMPARE_BANNER = "Compare sends two requests. Rate limits and Turnstile apply to each column when both use the public proxy."; + +// src/plugins/compare-mode/index.ts +function modelOptionsHtml(selected) { + const parts = []; + for (const pinned of getPinnedModels()) { + parts.push( + `` + ); + } + for (const entry of getCatalogModels(40)) { + if (getPinnedModels().some((p) => p.id === entry.id)) continue; + parts.push( + `` + ); + } + return parts.join(""); +} +function applyDeltaToPane(pane, event) { + if (event.type === "message_start") { + pane.textContent = ""; + return; + } + if (event.type === "text_delta") { + pane.textContent = (pane.textContent || "") + event.delta; + } +} +function tagCompareMeta(event, column, model) { + if (event.type !== "message_start") return event; + return { + ...event, + message: { + ...event.message, + meta: { ...event.message.meta || {}, compareColumn: column, model } + } + }; +} +function CompareModePlugin(deps) { + let state = defaultCompareState(getActiveModel()); + let mount = null; + let chrome = null; + let bannerEl = null; + let paneA = null; + let paneB = null; + let labelA = null; + let labelB = null; + let wrapped = false; + const refreshBanner = () => { + if (!bannerEl) return; + const show = bothColumnsMetered(state, deps.getCatalog()); + bannerEl.hidden = !show; + bannerEl.textContent = show ? METERED_COMPARE_BANNER : ""; + }; + const syncChromeVisibility = () => { + if (!chrome || !mount) return; + chrome.hidden = !state.active; + mount.classList.toggle("lf-compare-active", state.active); + refreshBanner(); + }; + const paintColumnLabels = () => { + if (labelA) labelA.textContent = state.columns.a.model; + if (labelB) labelB.textContent = state.columns.b.model; + }; + return { + name: "compare-mode", + onMount(ctx) { + mount = ctx.container; + chrome = document.createElement("div"); + chrome.className = "lf-compare-chrome"; + chrome.hidden = true; + chrome.innerHTML = ` + +
    +
    +
    + + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + `; + const layout = mount.querySelector(".mur-chat-layout-wrapper"); + const formHost = mount.querySelector(".mur-chat-form-container"); + if (layout && formHost) { + layout.insertBefore(chrome, formHost); + } else { + (mount.querySelector(".mur-chat-scroll-area") || mount).appendChild(chrome); + } + bannerEl = chrome.querySelector("#lf-compare-banner"); + paneA = chrome.querySelector('[data-pane="a"]'); + paneB = chrome.querySelector('[data-pane="b"]'); + labelA = chrome.querySelector('[data-label="a"]'); + labelB = chrome.querySelector('[data-label="b"]'); + const selectA = chrome.querySelector('select[data-column="a"]'); + const selectB = chrome.querySelector('select[data-column="b"]'); + selectA.innerHTML = modelOptionsHtml(state.columns.a.model); + selectB.innerHTML = modelOptionsHtml(state.columns.b.model); + selectA.addEventListener("change", () => { + state.columns.a.model = selectA.value; + paintColumnLabels(); + refreshBanner(); + }); + selectB.addEventListener("change", () => { + state.columns.b.model = selectB.value; + paintColumnLabels(); + refreshBanner(); + }); + paintColumnLabels(); + if (formHost) { + const toggleRow = document.createElement("div"); + toggleRow.className = "lf-compare-toggle-row"; + toggleRow.innerHTML = ` + + Same prompt \u2192 two models side by side + `; + formHost.insertBefore(toggleRow, formHost.firstChild); + const checkbox = toggleRow.querySelector("#lf-compare-toggle"); + checkbox.addEventListener("change", () => { + state.active = checkbox.checked; + if (state.active) { + state.columns.a.model = selectA.value || getActiveModel(); + selectA.value = state.columns.a.model; + paintColumnLabels(); + showStatusMessage("Compare mode on \u2014 replies appear in both columns."); + } else { + showStatusMessage("Compare mode off \u2014 history kept."); + } + syncChromeVisibility(); + }); + } + syncChromeVisibility(); + if (!wrapped) { + wrapped = true; + const original = deps.provider.streamChat.bind(deps.provider); + deps.provider.streamChat = async (request, onEvent) => { + if (!state.active) { + return original(request, onEvent); + } + refreshBanner(); + if (paneA) paneA.textContent = ""; + if (paneB) paneB.textContent = ""; + paintColumnLabels(); + const modelA = state.columns.a.model; + const modelB = state.columns.b.model; + const reqA = { + ...request, + options: { ...request.options, model: modelA } + }; + const reqB = { + ...request, + options: { ...request.options, model: modelB } + }; + try { + await original(reqA, (event) => { + if (paneA) applyDeltaToPane(paneA, event); + onEvent(tagCompareMeta(event, "a", modelA)); + }); + } catch (err) { + if (paneA && !paneA.textContent) { + paneA.textContent = err instanceof Error ? err.message : String(err); + } + throw err; + } + try { + await original(reqB, (event) => { + if (paneB) applyDeltaToPane(paneB, event); + onEvent(tagCompareMeta(event, "b", modelB)); + }); + } catch (err) { + if (paneB && !paneB.textContent) { + paneB.textContent = err instanceof Error ? err.message : String(err); + } + } + }; + } + }, + beforeSubmit: async (request) => { + if (!state.active) return; + refreshBanner(); + return { + options: { + ...request.options, + model: state.columns.a.model, + lfCompare: true + } + }; + }, + destroy() { + chrome?.remove(); + mount?.classList.remove("lf-compare-active"); + } + }; +} + +// src/plugins/discovery-picklist/index.ts +function escapeHtml(text) { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} +function candidateRow(candidate) { + const title = escapeHtml(candidate.title); + const url = escapeHtml(candidate.url); + const snippet = escapeHtml(candidate.snippet); + return ` +
  • + ${title} + ${url} + ${snippet ? `

    ${snippet}

    ` : ""} +
  • + `; +} +function DiscoveryPicklistPlugin() { + let host = null; + let handler = null; + return { + name: "discovery-picklist", + onMount(ctx) { + host = document.createElement("div"); + host.className = "lf-discovery-picklist"; + host.hidden = true; + const layout = ctx.container.querySelector(".mur-chat-layout-wrapper"); + const form = ctx.container.querySelector(".mur-chat-form-container"); + if (layout && form) { + layout.insertBefore(host, form); + } else { + ctx.container.appendChild(host); + } + handler = (event) => { + const detail = event.detail; + const candidates = detail?.candidates ?? []; + if (!host || candidates.length === 0) return; + host.innerHTML = ` +
    + Free chat sites found via your SearXNG (open manually \u2014 nothing is automated): + +
    + + `; + host.hidden = false; + host.querySelector(".lf-discovery-dismiss")?.addEventListener("click", () => { + if (host) host.hidden = true; + }); + }; + window.addEventListener(DISCOVERY_RESULTS_EVENT, handler); + }, + destroy() { + if (handler) window.removeEventListener(DISCOVERY_RESULTS_EVENT, handler); + host?.remove(); + } + }; +} + // src/catalog-display.ts function formatContextLength(value) { if (value === void 0 || value <= 0) return "\u2014"; @@ -6394,25 +7502,25 @@ function ModelExplorerPlugin(deps) { } function cellHtml(row, col) { if (col.display === "context") { - return escapeHtml(formatContextLength(row.context_length)); + return escapeHtml2(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 escapeHtml2(provider); } - return escapeHtml(String(row[col.key] ?? "")); + return escapeHtml2(String(row[col.key] ?? "")); } function renderTable(rows) { 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) => `${TABLE_COLUMNS.map( + (row, rowIdx) => `${TABLE_COLUMNS.map( (c) => `${cellHtml(row, c)}` - ).join("")}` + ).join("")}` ).join(""); statusEl.textContent = `${rows.length} model(s) shown${rows.length > 200 ? " (first 200)" : ""}`; thead.querySelectorAll("th").forEach((th) => { @@ -6467,7 +7575,7 @@ function ModelExplorerPlugin(deps) { } }; } -function escapeHtml(s) { +function escapeHtml2(s) { return s.replace(/&/g, "&").replace(//g, ">"); } @@ -6767,6 +7875,7 @@ function closeShellPanel(_id) { function bindTopBarButtons() { document.getElementById("sysSetting")?.addEventListener("click", () => openShellPanel("failover")); document.getElementById("byokSetting")?.addEventListener("click", () => openShellPanel("byok")); + document.getElementById("tiersSetting")?.addEventListener("click", () => openShellPanel("tiers")); document.getElementById("explorerSetting")?.addEventListener("click", () => openShellPanel("explorer")); document.getElementById("closeSet")?.addEventListener("click", () => closeShellPanel()); document.getElementById("sysMask")?.addEventListener("click", (e) => { @@ -6775,14 +7884,14 @@ function bindTopBarButtons() { } // src/export-session.ts -function messageText(message) { +function messageText2(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); + const text = messageText2(message); if (!text) continue; const heading = message.role === "user" ? "## User" : "## Assistant"; lines.push(heading, "", text, ""); @@ -6798,7 +7907,7 @@ function toJson(messages, meta) { messages: messages.map((m2) => ({ id: m2.id, role: m2.role, - text: messageText(m2) + text: messageText2(m2) })) }, null, @@ -7032,6 +8141,7 @@ function ShortcutsSheetPlugin() { } // src/main.ts +var MAX_IMAGE_ATTACHMENT_BYTES = 4e6; async function loadCatalog(config) { let catalog = []; let providerUrls = {}; @@ -7182,6 +8292,19 @@ async function bootstrap() { }, plugins: (engine) => [ CopyPlugin(), + AttachmentPlugin({ + acceptedTypes: "image/*", + maxFileSize: MAX_IMAGE_ATTACHMENT_BYTES, + onSizeExceeded: (file, maxSize) => { + const limitMb = Math.round(maxSize / 1e6); + showStatusMessage( + `"${file.name}" is too large. Images must be under ${limitMb} MB.` + ); + }, + onUnsupportedFile: (file) => { + showStatusMessage(`"${file.name}" isn't a supported image type.`); + } + }), ModelPickerPlugin(), MessageActionsPlugin(), RoutingChipPlugin(), @@ -7204,6 +8327,12 @@ async function bootstrap() { provider.setCatalog(catalogRef2, providerUrlsRef); } }), + TierSettingsPlugin(), + CompareModePlugin({ + provider, + getCatalog: () => catalogRef2 + }), + DiscoveryPicklistPlugin(), ModelExplorerPlugin({ getCatalog: () => catalogRef2, getCatalogUrl: () => readRuntimeConfig().catalogUrl diff --git a/docs/assets/shell/chat-overrides.css b/docs/assets/shell/chat-overrides.css index ad20d36..f5a1205 100644 --- a/docs/assets/shell/chat-overrides.css +++ b/docs/assets/shell/chat-overrides.css @@ -960,3 +960,255 @@ body.lf-chat-page::before { color: #c77dff; } +/* Wave 4B: image attachment tray — inherits murm-ui attachment vars from the + dark theme block above; these rules just tighten thumbnail framing so the + composer tray matches the dark shell. */ +#chatMount .mur-attachment-previews { + gap: 0.4rem; +} + +#chatMount .mur-attachment-preview-item img, +#chatMount .mur-attachment-image { + border-radius: 8px; + border: 1px solid var(--mur-border); +} + +/* Wave 4B: provider tier settings panel */ +.lf-tier-list { + list-style: none; + margin: 0 0 1rem; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.lf-tier-row { + border: 1px solid rgba(157, 78, 221, 0.22); + border-radius: 10px; + padding: 0.65rem 0.75rem; + background: rgba(26, 26, 46, 0.55); +} + +.lf-tier-row-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.lf-tier-enable { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-weight: 600; + color: #e8e8ef; +} + +.lf-tier-enable input { + width: auto; + margin: 0; +} + +.lf-tier-move { + display: flex; + gap: 0.25rem; + flex-shrink: 0; +} + +.lf-tier-move .panel-btn { + min-width: 2rem; + padding: 0.2rem 0.45rem; +} + +.lf-tier-hint { + margin: 0.35rem 0 0; +} + +/* Wave 4B: compare mode split-pane */ +.lf-compare-toggle-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + padding: 0.35rem 0.15rem 0.55rem; +} + +.lf-compare-toggle { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin: 0; + font-weight: 600; + color: #e8e8ef; + cursor: pointer; +} + +.lf-compare-toggle input { + width: auto; + margin: 0; +} + +.lf-compare-toggle-hint { + font-size: 0.78rem; + color: #8b8ba3; +} + +.lf-compare-chrome { + padding: 0.5rem 0.75rem 0.85rem; + border-top: 1px solid rgba(157, 78, 221, 0.22); + background: rgba(18, 18, 31, 0.92); + flex-shrink: 0; +} + +.lf-compare-chrome[hidden] { + display: none !important; +} + +.lf-compare-banner { + margin: 0 0 0.65rem; + padding: 0.55rem 0.7rem; + border-radius: 8px; + border: 1px solid rgba(248, 113, 113, 0.35); + background: rgba(127, 29, 29, 0.28); + color: #fecaca; + font-size: 0.82rem; +} + +.lf-compare-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +@media (max-width: 720px) { + .lf-compare-grid { + grid-template-columns: 1fr; + } +} + +.lf-compare-column { + border: 1px solid rgba(157, 78, 221, 0.22); + border-radius: 10px; + background: rgba(26, 26, 46, 0.65); + min-height: 8rem; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.lf-compare-column-header { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.55rem 0.65rem; + border-bottom: 1px solid rgba(157, 78, 221, 0.18); +} + +.lf-compare-column-title { + display: block; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #8b8ba3; + margin-bottom: 0.2rem; +} + +.lf-compare-model { + width: 100%; + max-width: 100%; +} + +.lf-compare-live-label { + font-size: 0.75rem; + color: #c77dff; + word-break: break-all; +} + +.lf-compare-pane { + flex: 1; + padding: 0.65rem 0.75rem; + white-space: pre-wrap; + color: #e8e8ef; + font-size: 0.92rem; + line-height: 1.45; + min-height: 5rem; +} + +.lf-compare-pane:empty::before { + content: "Waiting for reply…"; + color: #8b8ba3; + font-style: italic; +} + +#chatMount.lf-compare-active .mur-chat-history { + /* Keep history visible above the live compare panes */ + max-height: 42vh; +} + +/* Wave 4B: SearXNG discovery pick list */ +.lf-discovery-picklist { + flex-shrink: 0; + padding: 0.55rem 0.75rem; + border-top: 1px solid rgba(157, 78, 221, 0.22); + background: rgba(18, 18, 31, 0.92); +} + +.lf-discovery-picklist[hidden] { + display: none !important; +} + +.lf-discovery-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.8rem; + color: #c4c4d4; +} + +.lf-discovery-dismiss { + min-width: 1.8rem; + padding: 0.1rem 0.4rem; +} + +.lf-discovery-list { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.lf-discovery-item { + border: 1px solid rgba(157, 78, 221, 0.22); + border-radius: 8px; + padding: 0.5rem 0.65rem; + background: rgba(26, 26, 46, 0.6); +} + +.lf-discovery-item a { + color: #c77dff; + font-weight: 600; + text-decoration: none; +} + +.lf-discovery-item a:hover { + text-decoration: underline; +} + +.lf-discovery-url { + display: block; + font-size: 0.72rem; + color: #8b8ba3; + word-break: break-all; +} + +.lf-discovery-snippet { + margin: 0.3rem 0 0; + font-size: 0.8rem; + color: #c4c4d4; +} + diff --git a/docs/brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md b/docs/brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md new file mode 100644 index 0000000..af92326 --- /dev/null +++ b/docs/brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md @@ -0,0 +1,130 @@ +--- +title: Chat UI Wave 4B — vision, compare, and omnifail provider tiers +date: 2026-07-25 +status: confirmed +priority_wave: wave4b-differentiation +origin: docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md +prior_waves: [wave1, wave2, wave3, wave4] +strategy: STRATEGY.md +--- + +# Chat UI Wave 4B — vision, compare, and omnifail provider tiers + +## Summary + +Extend the static chat demo with **vision attachments**, **two-column model compare**, and a **user-configurable provider tier stack** that prioritizes the highest-quality free paths first, then optional headless web-UI automation, then existing proxy/BYOK failover. When configured tiers exhaust, **SearXNG discovery** proposes new free web chat targets before giving up — aligned with the repo goal that a response should almost never fail for lack of a route. + +## Problem Frame + +Waves 1–4 delivered table-stakes UX (picker, routing chip, export/import, streaming polish). The homepage still loses to ChatGPT-class tools on **multimodal** and **model shopping** — visitors cannot attach images or ask the same prompt of two models side-by-side. Separately, the product thesis is **omnifail routing**: quality-first, exhaustive fallback. Today `FailoverProvider` covers proxy → BYOK HTTP only; it does not orchestrate headless free web UIs or discover new targets when the chain is exhausted. + +Demand for Wave 4B is **speculative** (no observed visitor workaround yet); success is measured by demo parity with multimodal/compare expectations and by depth of configurable failover, not traffic proof. + +## Requirements + +### Multimodal (vision) + +| ID | Requirement | +|----|-------------| +| R28 | Users **attach one or more images** (paste, file picker, or drag) in the composer when the selected route supports vision; thumbnails preview before send. | +| R29 | Non-vision routes **block send or warn clearly** when images are attached — images are never silently dropped. | +| R30 | Vision-capable catalog models (already badged in explorer/picker) are **preferred or filtered** when attachments are present. | +| R31 | Image payloads respect a **published size cap** (client-side); oversize files show a clear error without corrupting the session. | + +### Compare mode + +| ID | Requirement | +|----|-------------| +| R32 | Users enter **compare mode**: same user prompt (including attachments when supported) sent to **two independently configured sources** shown in a two-column layout. | +| R33 | Each column shows **model/source label**, streaming reply, and routing metadata consistent with the routing chip pattern. | +| R34 | Compare mode **surfaces rate-limit and Turnstile cost** (two requests) before send when both columns use metered routes. | +| R35 | Users can **exit compare mode** back to single-column chat without losing session history. | + +### Provider tiers (omnifail stack) + +| ID | Requirement | +|----|-------------| +| R36 | Users configure an **ordered provider tier list** persisted in browser storage; default order ships sensible for zero-config demo (quality API routes before exotic tiers). | +| R37 | **Tier: quality API** — existing proxy SSE and BYOK HTTP OpenAI-compatible calls, ranked by user order and catalog quality score where applicable. | +| R38 | **Tier: headless web UI** (optional, off by default) — user-supplied runner (local or self-hosted) automates free web chat UIs via headless browser; public Pages demo does not require this tier to function. | +| R39 | **Tier: SearXNG discovery** — when higher tiers fail or are disabled, query a user-configurable SearXNG instance to find candidate **free web chat URLs**; discovered targets feed the web-UI tier or present as suggested links — not silent auto-login to third-party accounts. | +| R40 | Failover walks the tier list until a response succeeds or all tiers exhaust; final failure shows **actionable diagnostics** (which tiers were tried, last error class). | +| R41 | Local/self-hosted runner mode is **explicitly opt-in** and documented as lower rate-limit pressure than the public Worker demo. | + +### Trust, ops, and demo constraints + +| ID | Requirement | +|----|-------------| +| R42 | Web-UI and SearXNG tiers include **CAVEATS copy**: user responsibility for target site terms, no credential harvesting, no default enablement on the public homepage without operator config. | +| R43 | Wave 4B features degrade gracefully when optional backends are absent — zero-config text chat via proxy remains unchanged. | + +## Approaches considered + +### A. Client-only (vision + compare on existing proxy) + +Ship R28–R35 using today’s `FailoverProvider` only. **Pros:** smallest diff, static Pages only. **Cons:** ignores omnifail tier vision and SearXNG. + +### B. Differentiation + tier stack (recommended) + +Ship R28–R35 plus R36–R43: UI features on static Pages; tier orchestration in browser with optional local/edge runners for web-UI and SearXNG. **Pros:** matches user priority (Playwright web UIs → BYOK → proxy) while keeping demo static-first. **Cons:** web tier and discovery need follow-on implementation units and operator docs. + +### C. Omnifail platform first + +Build tier engine and SearXNG discovery before compare/vision UI. **Pros:** routing depth first. **Cons:** visitors see no multimodal/compare win until late; higher risk of over-engineering backend. + +**Recommendation:** **B** — thin vision/compare UX plus tier framework in one wave; SearXNG discovery included as specified by product owner (not deferred). + +## Scope boundaries + +**In scope:** `webui/`, optional `edge/` or companion runner docs, operator settings UI, Playwright e2e for vision/compare happy paths (mocked), `docs/CAVEATS.md`, `CONCEPTS.md`. + +**Out of scope (this wave):** + +- Tool-call / reasoning UI, PWA offline shell, cloud session sync +- Repo-owned credentials for third-party web UIs +- Mandatory Playwright on Cloudflare Worker without user opt-in +- Full TypeScript port of Python model discovery + +**Deferred to follow-up work:** + +- Automatic account creation on discovered web UIs +- More than two compare columns +- Vision through web-UI tier if first ship is text-only for that tier (must fail clearly per R29) + +## Success criteria + +- User attaches a PNG, picks a vision-capable model, receives a relevant description via proxy or BYOK. +- Compare mode: same prompt produces two visible streaming columns with distinct source labels. +- User reorders tiers in settings; next chat respects new order; failure diagnostics list attempted tiers. +- With SearXNG configured, exhausted API tiers yield at least one discovered candidate URL or an explicit “discovery empty” message — not a generic network error. +- Public zero-config demo works with tiers 37-only (API/proxy) when web and SearXNG tiers are disabled. + +## Key decisions + +| ID | Decision | Rationale | +|----|----------|-----------| +| K5 | Include SearXNG discovery in Wave 4B | Product owner expand confirm; aligns with omnifail thesis | +| K6 | Web-UI automation tier is opt-in / user-run | Static Pages cannot host Playwright; ToS and abuse risk | +| K7 | Compare binds per-column tier or model | Supports “ChatGPT web vs our free proxy” not only “two proxy models” | +| K8 | Speculative demand recorded as assumption | No visitor evidence yet; ship for demo parity and routing depth | + +## Dependencies and assumptions + +- Waves 1–4 merged and deployed on `main`. +- murm-ui `AttachmentPlugin` supports local image processing; multimodal request shaping may require provider extension (planning). +- `searxng/search` exists in catalog artifacts but web-UI **discovery** is net-new behavior — assumes user provides SearXNG base URL (self-hosted or public instance). +- Headless web-UI tier assumes a companion process the user runs locally or on Render — not verified on Worker today. + +## Outstanding questions + +| ID | Question | Default for planning | +|----|----------|---------------------| +| Q4 | Compare layout: split-pane in `#chatMount` vs full-width overlay? | Split-pane in main chat area | +| Q5 | SearXNG discovery: auto-queue top URL to web tier vs show pick list? | Show pick list; user confirms before automation | +| Q6 | Max images per message | 1 image for thin slice; raise in follow-up if trivial | + +## Research references + +- [DEV — image upload in chat (2025)](https://dev.to/newbe36524/implementing-image-upload-and-ai-recognition-in-chat-a-complete-solution-from-design-to-4le0) — attachment bar, preview-before-send, multimodal fallback +- [AI Chat UI best practices (2026)](https://thefrontkit.com/blogs/ai-chat-ui-best-practices) — document/image previews, streaming stability +- Prior: `docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md` (Wave C deferred list) diff --git a/docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md b/docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md new file mode 100644 index 0000000..77cf732 --- /dev/null +++ b/docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md @@ -0,0 +1,142 @@ +--- +title: Chat UI Wave 5 — agent UX trust layer +date: 2026-07-25 +status: confirmed +priority_wave: wave5-agent-ux +origin: docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md +prior_waves: [wave1, wave2, wave3, wave4, wave4b] +strategy: STRATEGY.md +--- + +# Chat UI Wave 5 — agent UX trust layer + +## Summary + +After Wave 4B (vision, compare, omnifail tiers), close the remaining **2026 agent UX gap** on the static demo: **collapsible reasoning blocks**, **tool-call lifecycle cards**, and **composer voice input** on supported browsers. Optional follow-on slice adds **PWA installability** and offline read of local sessions. Stays static-first; no MCP marketplace, tool execution loop, or backend accounts. + +## Problem Frame + +Waves 1–4 delivered table-stakes chat (model picker, routing chip, export/import, streaming polish). Wave 4B addresses multimodal and compare. Visitors comparing to ChatGPT and Claude still see a **2023-class agent surface**: murm-ui already receives `reasoning_delta` and `tool_call_*` stream events, but the bundled renderer **skips reasoning** (`continue`) and shows tool calls as a one-line emoji placeholder. ResearchWizard **voice chrome exists but stays hidden** (Wave 1 R18) — which now reads as unfinished rather than intentionally unsupported. + +Demand is **speculative** (no observed visitor workaround). Success is measured by demo parity with reasoning-model and agentic-chat expectations, not traffic proof. STRATEGY still anchors static Pages + edge proxies — Wave 5 is client-side trust polish, not a pivot to Open WebUI. + +## Requirements + +### Reasoning / thinking blocks + +| ID | Requirement | +|----|-------------| +| R44 | When the active route streams **reasoning content**, assistant messages show a **collapsed thinking header** (e.g. “Thinking…” while streaming, “Thought for Ns” when complete) with expand/collapse for the full trace. | +| R45 | When the provider **does not** expose a reasoning channel, the UI shows **nothing** — no empty placeholders or fake thinking states. | +| R46 | The **final answer text** remains visually primary; reasoning uses muted styling and sits above or beside the answer without displacing it. | + +### Tool-call display (read-only v1) + +| ID | Requirement | +|----|-------------| +| R47 | Streamed tool invocations render as **lifecycle cards** with states `pending → running → success | error | cancelled`, replacing the default emoji one-liner. | +| R48 | Wave 5 is **display-only** — the public demo does not execute tools client-side or round-trip tool results unless a later wave explicitly adds it. | +| R49 | Each card shows **tool name**, **status**, and an **expandable args summary**; raw JSON is available on expand, not the default view. | + +### Voice input + +| ID | Requirement | +|----|-------------| +| R50 | A **mic control** in the composer uses the **Web Speech API** to dictate into the text field on supported browsers (Chromium-first). | +| R51 | Unsupported browsers show a **disabled mic** with tooltip copy explaining the limitation — not a hidden or broken button. | +| R52 | Voice **fills the composer only**; the user reviews and sends manually (no auto-send on end-of-speech in v1). | + +### PWA shell (optional Wave 5B slice) + +| ID | Requirement | +|----|-------------| +| R53 | A **service worker** caches the app shell, static assets, and `free_models.json` for repeat visits. | +| R54 | When offline, users can **read prior sessions** from IndexedDB; **chat send is disabled** with a clear “offline” message — not a silent failure. | +| R55 | Service worker updates prompt the user to **reload** before applying, avoiding mid-stream breakage during active chat. | + +### Trust and demo constraints + +| ID | Requirement | +|----|-------------| +| R56 | Reasoning and tool UI **degrade gracefully** on routes that emit text-only SSE — zero-config proxy chat behavior is unchanged. | +| R57 | Voice and PWA features include **no new server endpoints** on the public Worker; optional BYOK routes behave the same as today. | + +## Approaches considered + +### A. Agent UX trust layer (recommended) + +Ship R44–R52 in one wave: murm-ui plugins + SSE mapping extensions so reasoning and tool events reach the renderer; unhide and wire `#voiceInputBtn`. **Pros:** Highest perception ROI vs carrying cost; reuses existing stream plumbing and shell CSS. **Cons:** Provider-dependent reasoning visibility on free routes. + +### B. PWA-first + +Prioritize R53–R55 before reasoning/tools/voice. **Pros:** Repeat-visitor retention, installable icon. **Cons:** Does not fix the “feels like 2023 agent chat” comparison; SW build pipeline adds CI surface before visible UX win. + +### C. MCP client panel first + +Ship external MCP connect + tool list before display polish. **Pros:** Power-user agent gateway story. **Cons:** CORS/auth complexity, conflicts with “ranked free model demo” positioning; high trust surface for a speculative need. + +**Recommendation:** **A**, then **B** as a separate PR-sized slice (5B). Defer **C** until product explicitly pivots toward agent gateway. **Extend** existing murm-ui block rendering and `FailoverProvider` SSE mapping — net-new plugins, not a fork. + +## Scope boundaries + +**In scope:** `webui/`, murm-ui plugin(s), SSE event forwarding, shell voice wiring, optional SW in build pipeline, Playwright e2e with mocks, `docs/chat-ui-plugins.md`, `CONCEPTS.md`. + +**Deferred for later:** + +- Client-side or Worker-side **tool execution loop** +- MCP client settings panel and remote tool invocation +- TTS / read-aloud replies, wake word, MediaRecorder + paid STT +- Generative UI / iframe widget runtime for tool results +- More than two compare columns (Wave 4B scope) +- Vision through web-UI tier (Wave 4B deferral) + +**Outside this product's identity (STRATEGY):** + +- Open WebUI / LibreChat embedding, user accounts, cloud session sync +- MCP marketplace, RAG pipelines, org-wide tool registries +- Full offline AI (local inference without network) + +## Success criteria + +- On a BYOK or proxy route that streams reasoning deltas, user sees collapsed thinking with working expand/collapse and a visible final answer. +- On a mocked tool-call stream, user sees lifecycle cards with status transitions — not the emoji placeholder. +- On Chrome, mic dictation fills `#chatinput`; on Safari/Firefox, mic is disabled with explanatory tooltip. +- Zero-config text-only chat on the public homepage is unchanged when no reasoning/tools/voice features activate. +- (5B) With SW registered, repeat visit loads from cache; offline mode shows history read-only and blocks send with clear copy. + +## Key decisions + +| ID | Decision | Rationale | +|----|----------|-----------| +| K9 | **Display-only tool cards in v1** | Agentic credibility without Worker tool execution or abuse surface | +| K10 | **Reasoning is provider-gated** | Matches 2026 norm; avoids fake thinking on models without a channel | +| K11 | **Voice = composer dictation, no TTS** | Reuses hidden shell affordance; legacy `docs/legacy/chatgpt-web/` patterns inform UX, not wholesale port | +| K12 | **PWA as optional 5B slice** | Installability is medium value; agent UX polish is higher leverage for “not simplistic” perception | +| K13 | **MCP deferred** | Speculative power-user need; CORS and auth cost disproportionate to demo thesis | +| K14 | **Speculative demand recorded** | No visitor evidence yet; ship for ChatGPT-class parity after Wave 4B lands | + +## Dependencies and assumptions + +- **Wave 4B merged** before Wave 5 **implementation** starts — avoids parallel large diffs on `FailoverProvider` and composer. (Planning for Wave 5 may proceed earlier.) +- murm-ui stream event types for `reasoning_delta` and `tool_call_*` already exist in the bundled renderer; `webui/src/providers/sse.ts` currently forwards **text only** — planning must extend mapping (verified in repo). +- Free proxy routes may not emit reasoning or tool streams today; BYOK paths are the primary validation target for R44–R49. +- Web Speech API availability is Chromium-skewed; graceful degrade is mandatory. +- `docs/manifest.json` exists; no service worker today (verified). + +## Outstanding questions + +| ID | Question | Default for planning | +|----|----------|---------------------| +| Q7 | Reasoning default: collapsed always vs remember user expand preference? | Collapsed always; session-persist expand is follow-up | +| Q8 | Tool card placement: inline in message vs grouped timeline? | Inline above answer text, matching reasoning block | +| Q9 | PWA: ship in same release as 5A or separate 5B PR? | Separate 5B PR after 5A merges | + +## Research references + +- [UI Potion — AI response rendering patterns](https://uipotion.com/potions/patterns/ai-response-rendering) — reasoning gating, tool lifecycle cards +- [TanStack AI — thinking content](https://tanstack.com/ai/latest/docs/chat/thinking-content) — UI-only reasoning, progressive disclosure +- [UX/UI Principles — tool-use UX](https://uxuiprinciples.com/en/principles/tool-use-function-calling-ux) — show activity, human-in-the-loop norms +- [MDN — PWA offline operation](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Offline_and_background_operation) — SW + IndexedDB patterns +- Prior: `docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md` (deferred tool/reasoning, PWA) +- Prior: `docs/brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md` (Wave 4B out-of-scope list) +- Repo: `docs/assets/chat.js` (murm-ui reasoning/tool render hooks), `docs/legacy/chatgpt-web/` (prior voice UX), `webui/shell/styles.css` (`#voiceInputBtn` styles) diff --git a/docs/brainstorms/2026-07-25-chat-ui-wave6-transparency-discovery-requirements.md b/docs/brainstorms/2026-07-25-chat-ui-wave6-transparency-discovery-requirements.md new file mode 100644 index 0000000..893a0de --- /dev/null +++ b/docs/brainstorms/2026-07-25-chat-ui-wave6-transparency-discovery-requirements.md @@ -0,0 +1,185 @@ +--- +title: Chat UI Wave 6 — transparency, branching, and discovery +date: 2026-07-25 +status: confirmed +priority_wave: wave6-transparency-discovery +origin: user brainstorm — what to improve after Waves 1–5 +prior_waves: [wave1, wave2, wave3, wave4, wave4b, wave5] +strategy: STRATEGY.md +--- + +# Chat UI Wave 6 — transparency, branching, and discovery + +## Summary + +After Wave 5 (agent UX trust layer), make the static demo **teach and retain** — not just chat. Wave 6 adds **per-reply usage and latency**, a **failover timeline** that explains ranked routing, **conversation branching**, a **prompt template library**, and **read-only share/embed** modes. Optional slice adds a **session analytics drawer** and **read-only artifact pane**. Stays static-first; no accounts, MCP execution, or cloud session sync. + +## Problem Frame + +Waves 1–5 close the ChatGPT-class baseline: picker, routing chip, export/import, vision/compare, omnifail tiers, reasoning/tools display, voice, and optional PWA. Visitors still leave without understanding **why this project exists** — the ranked `free` alias, fallback hops, and quality scoring are buried in README copy. Meanwhile 2026 chat products treat **branching**, **prompt libraries**, and **usage transparency** as table stakes for power users. + +Demand remains **speculative** (no visitor evidence). Success is measured by demo differentiation (failover story visible in-product) and power-user parity, not traffic proof. STRATEGY still anchors Python artifacts + edge proxies — Wave 6 is client-side education and local-session depth, not a pivot to Open WebUI. + +## Requirements + +### Usage and latency transparency + +| ID | Requirement | +|----|-------------| +| R58 | Each assistant reply shows a compact **usage badge**: input/output token counts when the route exposes them, plus **time-to-first-token** and total duration. | +| R59 | When token counts are unavailable, the badge shows **latency only** — no fabricated token estimates. | +| R60 | A **session totals row** (footer or drawer header) aggregates tokens and wall time for the active session. | + +### Failover timeline and routing education + +| ID | Requirement | +|----|-------------| +| R61 | The routing chip expands into a **failover timeline**: ordered attempts with endpoint, resolved model, outcome (success / skip / error class), and hop index. | +| R62 | Timeline copy distinguishes **`free` ranked alias** from **`openrouter/free`** meta-router in one sentence accessible from the expanded view. | +| R63 | When omnifail tiers (Wave 4B) are active, the timeline includes **tier name** and skip reason for disabled or exhausted tiers. | +| R64 | Timeline data comes from **response metadata already available** to the client; Wave 6 does not add new proxy logging backends. | + +### Conversation branching + +| ID | Requirement | +|----|-------------| +| R65 | Users **branch from any user message**: edit-and-resubmit creates a sibling thread without deleting the original path. | +| R66 | The sidebar shows a **branch indicator** on sessions with forks; users can switch active branch within a session. | +| R67 | Export/import (Wave 3) includes branch structure or exports the **active branch only** with clear labeling — planning picks one behavior and documents it. | +| R68 | Branching is **IndexedDB-only**; no server merge or cross-device sync. | + +### Prompt template library + +| ID | Requirement | +|----|-------------| +| R69 | A **template picker** (slash command or composer menu) offers bundled prompts: compare two models, summarize, debug code, explain ranking — sourced from static repo artifacts. | +| R70 | Templates support **`{{variable}}` placeholders**; the UI prompts for values before inserting into the composer. | +| R71 | Users can **save custom templates** to local storage; bundled templates are read-only. | +| R72 | Templates respect the active model and route capabilities (e.g., vision template disabled when route lacks vision). | + +### Share and embed (static-safe) + +| ID | Requirement | +|----|-------------| +| R73 | **Share snapshot** produces a read-only view of the current session (active branch) via **client-encoded URL** or downloadable HTML — no account required. | +| R74 | Shared views are **read-only**; recipients cannot send messages or see API keys. | +| R75 | **`?embed=1`** (or equivalent) renders chromeless chat suitable for iframe embed in README/docs; Turnstile and rate-limit warnings still apply when sending. | +| R76 | Share payloads **strip secrets** (BYOK keys, guest token values, runner URLs with credentials). | + +### Session analytics drawer (optional Wave 6C) + +| ID | Requirement | +|----|-------------| +| R77 | A slide-out **session analytics** panel shows per-turn model used, fallback rate, error mix, and cumulative latency for the active session. | +| R78 | Analytics are computed **client-side** from stored messages and routing metadata; no new analytics backend. | +| R79 | Drawer is **informational** — it does not gate chat or require opt-in beyond opening it. | + +### Read-only artifact pane (optional Wave 6C) + +| ID | Requirement | +|----|-------------| +| R80 | When an assistant message contains a fenced **HTML, SVG, or Mermaid** block, users can open a **sandboxed preview pane** beside the thread. | +| R81 | Preview is **display-only** — no script execution beyond sandboxed iframe rules; no tool round-trip. | +| R82 | Artifact pane degrades to **code view only** when content type is unsupported or sandbox blocks render. | + +### Mobile shell polish + +| ID | Requirement | +|----|-------------| +| R83 | Chat layout uses a **mobile-first bottom sheet** for model picker, settings, and analytics on narrow viewports. | +| R84 | Composer controls (attach, mic, send) sit in the **thumb zone** with minimum 44px touch targets. | +| R85 | Streaming messages do not trap scroll; **sticky composer** remains visible without obscuring the last reply. | + +### Streaming accessibility+ + +| ID | Requirement | +|----|-------------| +| R86 | **`aria-live` announces lifecycle events** (generation started, completed, error) — not per-token updates. | +| R87 | Keyboard shortcut **re-reads the last assistant message** for screen-reader users. | +| R88 | Expanded failover timeline and usage badges are **keyboard reachable** with visible focus rings. | + +## Approaches considered + +### A. Transparency-first (recommended) + +Ship R58–R64, R69–R72, and R83–R88 in one wave: make failover and usage visible, add templates and mobile/a11y polish. **Pros:** Highest alignment with STRATEGY messaging; low server cost; compounds routing chip from Wave 1. **Cons:** Less viral than share features alone. + +### B. Branching and share-first + +Prioritize R65–R76 before usage timeline. **Pros:** Matches ChatGPT Sept 2025 branching; share links aid distribution. **Pros:** Viral/demo growth angle. **Cons:** Does not explain *why* this gateway exists; branch graph adds IndexedDB migration risk before core story is visible. + +### C. Analytics and artifacts panel + +Prioritize R77–R82 before branching. **Pros:** Power-user delight; artifact pane feels “agentic” without execution loop. **Cons:** Higher UI surface; artifacts less differentiated than failover education on a failover-focused product. + +**Recommendation:** **A** as Wave 6A, then **B** (branching + share) as 6B if scope needs splitting. **C** (analytics + artifacts) as optional 6C slice — ship only if 6A/6B land under budget. **Extend** routing chip, session storage, and static template artifacts — no new backend services. + +## Scope boundaries + +**In scope:** `webui/`, static template JSON/markdown in `docs/` or `webui/`, IndexedDB schema extensions for branches, optional Worker header passthrough for token counts (if not already exposed), Playwright e2e, `CONCEPTS.md`, `docs/chat-ui-plugins.md`. + +**Deferred for later:** + +- MCP client panel and **tool execution loop** (Wave 5 display-only remains the ceiling until product pivot) +- TTS, wake word, MediaRecorder + paid STT +- Full **generative UI widget runtime** (interactive tool results, not sandboxed preview) +- **Cloud-hosted share** with KV persistence and TTL (v1 share is client-encoded or download) +- Text/PDF document attach beyond images (high cost, uneven free-model support) +- Session folders/tags across devices +- More than two compare columns + +**Outside this product's identity (STRATEGY):** + +- User accounts, org workspaces, cloud session sync +- Full TypeScript port of Python discovery or live scoring in the browser +- Open WebUI / LibreChat embedding +- Paid analytics backends or third-party product analytics SDKs + +## Success criteria + +- A first-time visitor expands a reply and sees **which models were tried**, why fallback occurred, and how `free` differs from `openrouter/free`. +- Usage badge shows real token counts on at least one BYOK/proxy route; latency-only degrade on routes without usage metadata. +- User branches from a mid-conversation message, switches branch in sidebar, and exports without data loss on the chosen export semantics. +- Template picker inserts a compare-models prompt with variables filled; custom template persists across reload. +- Share link or HTML snapshot opens read-only with secrets stripped; `?embed=1` loads in an iframe without layout breakage. +- Mobile viewport: model picker opens as bottom sheet; composer remains usable one-handed. +- Screen reader hears start/complete announcements; failover timeline is keyboard navigable. + +## Key decisions + +| ID | Decision | Rationale | +|----|----------|-----------| +| K15 | **Transparency before branching** | Product thesis is ranked failover — teach it in-product before parity features | +| K16 | **No fabricated usage data** | Trust beats impressiveness; matches Wave 5 reasoning-gating pattern | +| K17 | **Share v1 is client-encoded or download** | Avoids KV/auth surface on public Worker; fits static-first | +| K18 | **Templates from static artifacts** | Preserves Python-as-brain; no live template CMS | +| K19 | **Artifact pane is sandboxed preview only** | Agentic feel without deferred tool execution | +| K20 | **6B/6C are splittable** | Keeps 6A shippable if branch graph or analytics overrun estimate | +| K21 | **Speculative demand recorded** | No visitor evidence; ship for differentiation after Wave 5 | + +## Dependencies and assumptions + +- **Wave 5 merged** before Wave 6 — avoids parallel composer and SSE mapper conflicts. +- Wave 4B routing metadata (R6–R7) supplies hop timeline inputs for Wave 6A. Tier names in the timeline (R63) land when the Wave 4B orchestrator exists; do not block 6A on web/SearXNG tiers. +- Token usage requires provider/proxy to expose counts in response or stream trailers; not all free routes will qualify (R59 degrade path). +- Branching may require murm-ui session graph support or a thin wrapper — planning verifies without prescribing architecture here. +- Share URL length limits may cap very large sessions; planning defines max snapshot size or falls back to download-only. + +## Outstanding questions + +| ID | Question | Default for planning | +|----|----------|---------------------| +| Q10 | Branch export: full tree vs active branch only? | Active branch only in v1; tree export follow-up | +| Q11 | Share: compressed hash URL vs downloadable HTML file? | Hash URL under size cap; HTML download fallback | +| Q12 | Session analytics in 6A or 6C? | 6C optional slice after 6A | +| Q13 | Mobile bottom sheet: replace all slide panels or chat-specific only? | Chat-adjacent panels first (model picker, analytics) | + +## Research references + +- [UX/UI Principles — AI cost transparency](https://uxuiprinciples.com/en/principles/ai-cost-transparency) — usage-at-action-time norms +- [OpenRouter — reliability failover](https://openrouter.ai/blog/insights/reliability-failover/) — hop transparency patterns +- [UIPotion — AI response rendering](https://uipotion.com/potions/patterns/ai-response-rendering) — streaming a11y lifecycle +- [NN/G — bottom sheets](https://www.nngroup.com/articles/bottom-sheet/) — mobile overlay UX +- [Ars Technica — ChatGPT branching (Sept 2025)](https://arstechnica.com/ai/2025/09/chatgpts-new-branching-feature-is-a-good-reminder-that-ai-chatbots-arent-people/) — branch prior art +- Prior: `docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md` (deferred MCP, execution) +- Prior: `docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md` (R10–R12 catalog education) diff --git a/docs/chat-ui-plugins.md b/docs/chat-ui-plugins.md index 283e9ca..58e8f23 100644 --- a/docs/chat-ui-plugins.md +++ b/docs/chat-ui-plugins.md @@ -8,7 +8,7 @@ The public chat at [`docs/index.html`](../index.html) is built from [`webui/`](. |-------|--------|------| | Shell | [ai-researchwizard](https://github.com/bolabaden/ai-researchwizard) (`webui/shell/styles.css`) | Top bar, slide panels, dark theme | | Chat engine | [murm-ui](https://github.com/levmv/murm-ui) | `ChatUI` + `IndexedDBStorage` | -| Routing | `FailoverProvider` | Cloud proxy first (SSE), optional browser BYOK fallback | +| Routing | `FailoverProvider` | Tier orchestrator: direct/BYOK → optional runner/SearXNG → cloud proxy SSE | ## Build @@ -28,6 +28,9 @@ Set `APP_VERSION` when building for cache busting (CI sets this from `github.sha |--------|-------|---------| | `failover-settings` | Server (top bar) | Proxy endpoints, guest token, default model, test connection | | `byok-settings` | Your keys | Optional provider API keys (`localStorage` only) | +| `tier-settings` | Tiers | Ordered omnifail route stack (enable/reorder + optional runner/SearXNG URLs) | +| `compare-mode` | Composer toggle | Two-column model compare (same prompt → dual proxy/BYOK streams) | +| `discovery-picklist` | Above composer | SearXNG-discovered free chat links (manual open only, dismissible) | | `model-explorer` | Models | Filter/sort `free_models.json`; **Use for chat** sets session model | | `model-picker` | Composer | Dropdown for `free`, `openrouter/free`, and top catalog models | | `routing-chip` | Messages | Endpoint / model / fallback metadata under assistant replies | @@ -40,6 +43,8 @@ Wave 3 adds **catalog enrichment** (context + capability badges in Models panel 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. +Wave 4B adds **provider tiers** (omnifail route stack in the Tiers panel), **image attachments** (composer tray → multimodal proxy requests), **compare mode** (two-column dual streams), **SearXNG discovery** (suggested free chat links when the tier is enabled — never automated), and an **opt-in local web-UI runner** (`runner/` — OpenAI-shaped SSE over user-configured adapters, see `runner/README.md`). + ## 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. @@ -69,6 +74,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_provider_tiers` | Omnifail tier order, enable flags, runner/SearXNG URLs | | `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/index.html b/docs/index.html index 2f40af3..ed29a74 100644 --- a/docs/index.html +++ b/docs/index.html @@ -24,6 +24,7 @@
    Server
    Your keys
    +
    Tiers
    Models
    @@ -67,6 +68,7 @@

    Chat

    + 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 index 262fa29..a4c0bba 100644 --- 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 @@ -1,6 +1,6 @@ --- title: "feat: Chat UI Wave 3 — catalog differentiation, export, hash routing" -status: active +status: completed date: 2026-07-24 type: feat origin: docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md 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 index 7f98aa0..28439ba 100644 --- 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 @@ -270,7 +270,7 @@ U4/U5 parallel with U2/U3 after U1 spike confirms patch path. ### Deferred to Follow-Up Work -- Wave 4B vision upload / Wave 4C model compare (separate brainstorm if pursued) +- Wave 4B vision upload / model compare (see `docs/brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md`) - Import merge into existing session - Upstream murm-ui PR for incremental markdown (if we ship patch-package locally) diff --git a/docs/plans/2026-07-25-002-feat-chat-ui-wave4b-differentiation-plan.md b/docs/plans/2026-07-25-002-feat-chat-ui-wave4b-differentiation-plan.md new file mode 100644 index 0000000..b2b02c1 --- /dev/null +++ b/docs/plans/2026-07-25-002-feat-chat-ui-wave4b-differentiation-plan.md @@ -0,0 +1,367 @@ +--- +title: "feat: Chat UI Wave 4B — vision, compare, omnifail tiers" +status: completed +date: 2026-07-25 +deepened: 2026-07-25 +type: feat +origin: docs/brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md +strategy: STRATEGY.md +wave: 4b +requirements: R28-R43 +prior_plan: docs/plans/2026-07-25-001-feat-chat-ui-wave4-polish-plan.md +--- + +# feat: Chat UI Wave 4B — vision, compare, omnifail tiers + +> **Origin:** [Wave 4B differentiation brainstorm](../brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md) — vision attachments, two-column compare, user-ordered provider tiers including optional headless web-UI runner and SearXNG discovery. + +### Delta Update + +- **Landed (all units complete, 2026-07-25):** U1 orchestrator stabilized (disjoint tiers, Vitest storage shim, order-preserving `normalizeTierSettings`, structured R40 diagnostics); U2 vision attachments + multimodal mapper + vision guard; U4 Tiers settings panel; U3 compare mode; U6 SearXNG discovery tier + pick-list plugin; U5 opt-in `runner/` companion (stub + generic-selector adapters, Node ≥23.6 native TS) with `web-ui-tier.ts` browser bridge; U7 Playwright specs (`vision-attach`, `compare-mode`, `tier-settings`) registered in `deploy-pages.yml`, CAVEATS/plugins docs, CONCEPTS Wave 4B terms. +- **Verification:** webui vitest 68 passing; runner node:test 4 passing; Wave 4B Playwright specs green locally; `npm run build` green. +- **Commits:** a34dfce (U1) → da223b6 (U2) → 5622e91 (U4) → 6948748 (U3) → d57fa78 (U6) → 9262ecf (U5) → 52354ba (U7). + +## Summary + +Add **multimodal composer attachments**, **compare mode** (two sources, one prompt), and a **configurable provider-tier stack** with **disjoint route ownership**: `quality_api` (direct/BYOK) → optional headless web UI → SearXNG discovery → `proxy_failover` (Worker/Render endpoints). Zero-config demo enables quality_api + proxy_failover; exotic tiers stay opt-in. Exhaust user-enabled routes before giving up — still **best-effort** free-tier HA (STRATEGY), not “never fail.” + +## Problem Frame + +Waves 1–4 shipped table-stakes chat UX. Visitors still expect **image input** and **model shopping**. Configurable tiers deepen routing beyond today’s proxy→BYOK path. `messagesToOpenAi` still drops file blocks; compare UI and discovery/runner are not shipped. Partial U1 scaffold exists but must be corrected before feature units. + +## Requirements traceability + +| ID | Requirement | +|----|-------------| +| R28–R31 | Vision attach, non-vision guard, catalog filter, size cap | +| R32–R35 | Compare mode, column labels/chips, rate-limit warning, exit without history loss | +| R36–R41 | Ordered tiers, API/web/SearXNG tiers, diagnostics, local runner opt-in | +| R42–R43 | CAVEATS, graceful degradation | + +## Key Technical Decisions + +| ID | Decision | Rationale | +|----|----------|-----------| +| KTD1 | Register murm-ui `AttachmentPlugin` with image-only accept + 4MB cap (Q6 default: 1 image) | Reuse murm-ui file blocks; R31 client cap; plugin has no built-in max-count — enforce in handler | +| KTD2 | New `message-openai.ts` multimodal mapper (text + `image_url` data URLs) | Proxy/LiteLLM accept vision when model supports it | +| KTD3 | **`TierOrchestrator`** with injected handlers | R36–R40 without rewriting FailoverProvider in one pass | +| KTD4 | Compare = **`ComparePlugin`** with dual provider delegates, not second `ChatUI` | murm-ui single engine | +| KTD5 | **Web-UI tier** → user-configured **`runner/`** HTTP service | Static Pages cannot run Playwright; R38/R41 | +| KTD6 | **SearXNG tier** = browser fetch + **pick list** (Q5) | No Worker SearXNG required for v1 | +| KTD7 | **Disjoint tiers:** `quality_api` = direct/BYOK only; `proxy_failover` = configured proxy endpoints; **defaults enable both**; web_ui + searxng **disabled** | User choice: no duplicate proxy attempts; zero-config needs proxy enabled | +| KTD8 | Vision export/import text-only in Wave 4B | YAGNI; document in CAVEATS | +| KTD9 | Vitest: in-memory `localStorage` mock (or injectable storage seam) for tier tests — no full jsdom required | Unblocks U1; Node has no DOM | +| KTD10 | `normalizeTierSettings` **preserves user order**; only fills missing known tier IDs | Fixes R36/U4 reorder bug in current scaffold | +| KTD11 | Surface `TierOrchestratorError.attempts` via status strip / structured route error — do not squash to opaque string only | R40 diagnostics | +| KTD12 | Single storage key `providerTiers` JSON blob (`tiers`, `webRunnerUrl`, `searxngUrl`) | Matches landed code; plan no longer lists separate URL keys | +| KTD13 | Bootstrap stays `loadRuntimeConfig()` → `FailoverProvider`; never wire tiers to raw `readRuntimeConfig()` alone | AGENTS pitfall 14 / F001 | +| KTD14 | Compare does **not** depend on runner; API-tier compare ships before U5 | Visible demo win without companion process | + +## High-Level Design + +```mermaid +flowchart TB + subgraph ui [webui] + Attach[AttachmentPlugin] + Compare[ComparePlugin] + TierUI[Tier settings panel] + Engine[ChatEngine] + end + + subgraph orch [TierOrchestrator] + API[quality_api BYOK/direct] + Web[web_ui optional] + Searx[searxng_discovery optional] + Proxy[proxy_failover Worker/Render] + end + + Runner[runner/ Playwright service] + SearxInst[User SearXNG instance] + + Attach --> Engine + Compare --> Engine + Engine --> orch + Web --> Runner + Searx --> SearxInst +``` + +Disjoint ownership: `quality_api` never calls proxy endpoints; `proxy_failover` never runs BYOK browser routes. Missing config → `TierSkipError`, not a hard fail that aborts the chain early. + +## Implementation Units + +### U1. Provider tier model + orchestrator (R36–R40, R43) — stabilize WIP + +**Goal:** Configurable ordered tiers with diagnostics; disjoint handlers; zero-config path unchanged. + +**Requirements:** R36, R37, R40, R43 + +**Files:** +- `webui/src/providers/tiers/types.ts` +- `webui/src/providers/tiers/defaults.ts` — defaults: quality_api + proxy_failover **enabled**; order-preserving normalize +- `webui/src/providers/tiers/settings.ts` +- `webui/src/providers/tiers/orchestrator.ts` +- `webui/src/providers/tiers/orchestrator.test.ts` — localStorage mock; order + skip + diagnostics cases +- `webui/src/providers/FailoverProvider.ts` — split `streamQualityApiRoute` (BYOK/direct only) vs `streamProxyFailoverRoute` (proxy only); pass structured attempts on failure +- `webui/src/storage-keys.ts` — `providerTiers` only +- `webui/src/providers/FailoverProvider.tiers.test.ts` (new) — integration: zero-config hits proxy when BYOK absent + +**Approach:** +1. Tier IDs: `quality_api`, `web_ui`, `searxng_discovery`, `proxy_failover`. +2. Persist ordered list + enabled flags in one localStorage JSON blob. +3. Orchestrator tries enabled tiers in stored order; collect `{ tier, error }[]`. +4. `quality_api` = browser-router / BYOK only; skip when no usable keys for selected model. +5. `proxy_failover` = dual-endpoint SSE loop only. +6. Fix Vitest storage before asserting behavior. + +**Execution note:** Test-first — red tests for order preserve, disjoint ownership, and zero-config before green refactor. + +**Test scenarios:** +- Default settings → quality_api skipped (no keys) then proxy_failover succeeds (mock) — same outcome as pre-refactor zero-config. +- Disabled web_ui → never calls runner URL. +- Custom tier order persisted after save/load (normalize must not reset to `TIER_IDS` catalog order). +- All tiers fail → `TierOrchestratorError.attempts` length ≥2 with tier ids. +- `TierSkipError` recorded; chain continues. +- AbortSignal mid-tier → error rethrown, no further tiers. + +**Verification:** `cd webui && npm test` green including orchestrator suite. + +--- + +### U2. Vision attachments + multimodal requests (R28–R31) + +**Goal:** Image attach in composer; vision requests through enabled API/proxy tiers. + +**Requirements:** R28, R29, R30, R31 + +**Dependencies:** U1 + +**Files:** +- `webui/src/main.ts` — register `AttachmentPlugin` +- `webui/src/providers/message-openai.ts` (new) +- `webui/src/providers/message-openai.test.ts` (new) +- `webui/src/providers/FailoverProvider.ts` — use mapper; vision guard before send +- `webui/src/plugins/model-picker/index.ts` — prefer `supports_vision` when attachments present +- `webui/shell/chat-overrides.css` — attachment tray; keep `data-theme` dark overrides intact + +**Approach:** +1. AttachmentPlugin: `acceptedTypes: "image/*"`, `maxFileSize: 4_000_000`, max 1 file via handler. +2. Map `file` blocks → OpenAI `image_url` data URLs. +3. Guard: attachments && !vision-capable model → block with clear status (R29). +4. Suggest vision catalog models when image attached (R30). + +**Test scenarios:** +- PNG block → OpenAI body contains `image_url`. +- Non-vision model + attachment → guard false / blocked send. +- Oversize file → clear error; session not corrupted. + +**Verification:** vitest + mocked proxy path. + +--- + +### U3. Compare mode UI (R32–R35) + +**Goal:** Two-column compare with independent source per column (API/proxy tiers — no runner required). + +**Requirements:** R32, R33, R34, R35 + +**Dependencies:** U1, U2 + +**Files:** +- `webui/src/plugins/compare-mode/index.ts` (new) +- `webui/src/plugins/compare-mode/column-provider.ts` (new) +- `webui/shell/chat-overrides.css` — `.lf-compare-grid` +- `webui/src/main.ts` — register plugin +- `tests/e2e/compare-mode.spec.ts` (new) + +**Approach:** +1. Toggle compare; Q4 default: split-pane in `#chatMount`. +2. Each column: model + optional tier override (defaults to global order). +3. Fork prompt to two orchestrator/provider instances; grid rows for pairs. +4. Pre-send banner when both columns use metered routes (R34). +5. Exit compare restores single column; compare turns append as two assistant messages with column meta. + +**Test scenarios:** +- Two mocked replies → both columns visible. +- Exit compare → history preserved. +- Rate-limit banner when compare enabled (mock). + +**Verification:** Playwright compare-mode spec. + +--- + +### U4. Tier settings panel (R36, R41, R42) + +**Goal:** User configures tier order, SearXNG URL, web runner URL. + +**Requirements:** R36, R41, R42 + +**Dependencies:** U1 (order-preserving settings) + +**Files:** +- `webui/src/plugins/tier-settings/index.ts` (new) +- `webui/src/plugins/tier-settings/settings.test.ts` (new) +- `webui/src/shell-panels.ts` +- `docs/CAVEATS.md` — web automation + SearXNG ToS; disambiguate “provider tiers” (omnifail stack) vs cloud free-tier limits; bootstrap merge note (AGENTS pitfall 14); vision export text-only (KTD8) +- `docs/chat-ui-plugins.md` + +**Approach:** +1. Shell panel: reorder + enable toggles. +2. Fields: SearXNG URL, web runner URL (empty default). +3. Copy: local runner opt-in (R41); ToS responsibility (R42); do not say “never fail.” + +**Test scenarios:** +- Reorder → localStorage order → orchestrator attempt order matches. + +**Verification:** vitest round-trip. + +--- + +### U5. Local web-UI runner service (R38) + +**Goal:** Minimal companion process for headless web chat automation tier. + +**Requirements:** R38 (partial R40) + +**Dependencies:** U1 (ships after U3 for sequencing preference) + +**Files:** +- `runner/README.md` (new) +- `runner/package.json` (new) +- `runner/src/server.ts` (new) +- `runner/src/adapters/` (new) — stub + BYO selectors +- `webui/src/providers/tiers/web-ui-tier.ts` (new) + +**Approach:** +1. OpenAI-shaped SSE; CORS for localhost + Pages. +2. Generic selector-driven adapter — not hardcoded ChatGPT. +3. Stub may return 501 until configured. + +**Execution note:** Spike one adapter; stub acceptable for merge if documented. + +**Test scenarios:** +- GET `/health`. +- Mock adapter fixed SSE text. + +**Verification:** `cd runner && npm test` + +--- + +### U6. SearXNG discovery tier (R39) + +**Goal:** When higher tiers fail, search for candidate free chat URLs; show pick list. + +**Requirements:** R39, R40 + +**Dependencies:** U1, U4 + +**Files:** +- `webui/src/providers/tiers/searxng-discovery-tier.ts` (new) +- `webui/src/providers/tiers/searxng-discovery-tier.test.ts` (new) +- `webui/src/plugins/discovery-picklist/index.ts` (new) + +**Approach:** +1. Query user SearXNG JSON API with configurable query. +2. Heuristic filter; pick list (Q5); user confirms before feeding web runner. +3. Empty → explicit discovery-empty diagnostic (R40). + +**Test scenarios:** +- Mock JSON → ≥1 URL. +- Empty → typed message. +- CORS failure → clear diagnostic (proxy spike deferred). + +**Verification:** vitest fixtures. + +--- + +### U7. E2E, docs, build (R28–R43) + +**Goal:** CI coverage and operator docs. + +**Requirements:** All + +**Dependencies:** U2–U6 (U5 stub OK if documented) + +**Files:** +- `tests/e2e/vision-attach.spec.ts` (new) +- `tests/e2e/compare-mode.spec.ts` (new) +- `tests/e2e/tier-settings.spec.ts` (new, optional) +- `.github/workflows/deploy-pages.yml` — register specs +- `docs/CAVEATS.md`, `docs/chat-ui-plugins.md` +- `CONCEPTS.md` — Wave 4B terms already present; do **not** re-add Wave 5–6 glossary rows in this unit + +**Test scenarios:** +- Vision PNG + mocked vision model. +- Compare two mocked endpoints. +- Zero-config: web+searx disabled, text chat works; keep `MODEL_CHAIN` short on public Worker. + +**Verification:** `cd webui && npm test && npm run build`; Playwright Wave 4B specs. + +## Sequencing + +```mermaid +flowchart LR + U1[U1 tiers stabilize] --> U2[U2 vision] + U1 --> U4[U4 settings] + U2 --> U3[U3 compare] + U4 --> U6[U6 searxng] + U3 --> U5[U5 runner] + U6 --> U5 + U3 --> U7[U7 e2e] + U5 --> U7 + U6 --> U7 +``` + +**Recommended order:** U1 → U2 → U4 → U3 → U6 → U5 → U7 + +Compare before runner so the demo ships multimodal + side-by-side without requiring a companion process. + +## Scope Boundaries + +**In scope:** `webui/`, `runner/` companion, docs, Playwright mocks. + +**Out of scope:** Worker-hosted Playwright, auto-login to third-party chats, 3+ compare columns, tool/reasoning UI (Wave 5), PWA, cloud sync. + +### Deferred to Follow-Up Work + +- Vision in export/import JSON +- Named-site runner adapters as optional plugins +- SearXNG CORS proxy on Worker if browser fetch blocked +- Wave 5 agent UX / Wave 6 transparency (queued plans/requirements) + +## Risks and Dependencies + +| Risk | Mitigation | +|------|------------| +| LiteLLM/proxy rejects large data URLs | Client resize + 4MB cap | +| Compare layout vs shell | CSS grid; disable on narrow viewports if needed | +| SearXNG CORS | Document CORS; optional runner proxy spike | +| Web automation ToS | Off by default; user-run; CAVEATS | +| Compare doubles rate limits | R34 pre-send warning | +| Losing murm-ui streaming patch on bump | See `docs/solutions/tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md` | +| Stale single-endpoint localStorage | Always `loadRuntimeConfig()` after settings save | + +## Acceptance Examples + +- **AE1.** Attach PNG + vision model → assistant describes image via proxy. +- **AE2.** Compare `free` vs `openrouter/free` (mocked) → two columns, two replies. +- **AE3.** web_ui + searxng disabled → zero-config text chat matches pre-4B main. +- **AE4.** SearXNG configured, API/proxy fail mock → pick list with ≥1 URL or explicit empty. +- **AE5.** Reorder tiers in settings → next send attempts in new order; failure lists tiers tried. + +## Open Questions (implementation-time) + +| ID | Question | Plan default | +|----|----------|--------------| +| Q4 | Split-pane vs overlay | Split-pane in `#chatMount` | +| Q5 | Auto-queue vs pick list | Pick list | +| Q6 | Max images | 1 for v1 | +| Q7 | SearXNG CORS | Direct fetch first; runner proxy if blocked | +| Q8 | Vitest storage | In-memory localStorage mock in test file / tiny helper | + +## Sources + +- [Wave 4B requirements](../brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md) +- [STRATEGY.md](../../STRATEGY.md) — best-effort HA; optional companions; no agent-gateway identity +- murm-ui `AttachmentPlugin` — `webui/node_modules/murm-ui/dist/plugins/attachment/` +- WIP scaffold — `webui/src/providers/tiers/`, `webui/src/providers/FailoverProvider.ts` +- Learnings — `docs/solutions/integration-issues/workers-ai-proxy-fallback-and-model-chain.md`, `docs/solutions/tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md` diff --git a/docs/plans/2026-07-25-003-feat-chat-ui-wave5-agent-ux-plan.md b/docs/plans/2026-07-25-003-feat-chat-ui-wave5-agent-ux-plan.md new file mode 100644 index 0000000..c209654 --- /dev/null +++ b/docs/plans/2026-07-25-003-feat-chat-ui-wave5-agent-ux-plan.md @@ -0,0 +1,324 @@ +--- +title: "feat: Chat UI Wave 5 — agent UX trust layer" +status: queued +date: 2026-07-25 +type: feat +origin: docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md +strategy: STRATEGY.md +wave: 5 +requirements: R44-R57 +prior_plan: docs/plans/2026-07-25-002-feat-chat-ui-wave4b-differentiation-plan.md +blocked_by: wave4b +--- + +# feat: Chat UI Wave 5 — agent UX trust layer + +> **Origin:** [Wave 5 agent UX brainstorm](../brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md) — reasoning blocks, tool-call cards, voice input, optional PWA (5B). **Prerequisite:** Wave 4B merged to `main` before **implementation** starts (planning already complete). + +## Summary + +Wire **murm-ui's built-in thinking and tools plugins**, extend **FailoverProvider SSE mapping** so reasoning and tool-call stream events reach the renderer, add **composer voice dictation** (Web Speech API), and optionally ship **PWA shell + offline read** as Wave **5B**. + +## Problem Frame + +murm-ui already handles `reasoning_delta` and `tool_call_*` events and ships `ThinkingPlugin` + `ToolsPlugin`, but the bundled default renderer skips reasoning and shows tool calls as a one-line placeholder. `webui/src/providers/sse.ts` forwards **text only**. Voice chrome exists in ResearchWizard CSS but is hidden by Wave 1 R18 overrides. + +## Requirements (Wave 5 traceability) + +| ID | Source | Wave 5 requirement | +|----|--------|-------------------| +| R44 | Wave 5 | Collapsed thinking header with expand/collapse when reasoning streams | +| R45 | Wave 5 | No reasoning UI when provider omits channel | +| R46 | Wave 5 | Final answer visually primary; reasoning muted | +| R47 | Wave 5 | Tool lifecycle cards replace emoji placeholder | +| R48 | Wave 5 | Display-only — no tool execution loop | +| R49 | Wave 5 | Tool name, status, expandable args summary | +| R50 | Wave 5 | Mic → Web Speech → composer on supported browsers | +| R51 | Wave 5 | Disabled mic + tooltip on unsupported browsers | +| R52 | Wave 5 | Manual send after dictation | +| R53 | Wave 5 | Service worker caches shell + catalog (5B) | +| R54 | Wave 5 | Offline read sessions; block send with message (5B) | +| R55 | Wave 5 | SW update reload prompt (5B) | +| R56 | Wave 5 | Text-only routes unchanged | +| R57 | Wave 5 | No new Worker endpoints for voice/PWA | + +**Out of scope:** MCP client, tool execution loop, TTS, generative UI widgets (see origin doc). + +## Key Technical Decisions + +| ID | Decision | Rationale | +|----|----------|-----------| +| KTD1 | **Reuse murm-ui `ThinkingPlugin` + `ToolsPlugin`** | Already match R44–R49; import via `murm-ui/plugins/thinking` and `murm-ui/plugins/tools` (see origin K9–K10) | +| KTD2 | **Port OpenAI stream mapping from murm-ui `OpenAIProvider`** into `webui/src/providers/sse.ts` (or shared helper) | Reasoning fields (`reasoning_content`, `reasoning`, `reasoning_text`) and `delta.tool_calls` handling already implemented upstream — avoid reinventing (see origin dependency note) | +| KTD3 | **Duration label v1: accept murm-ui "Thought Process" / "Thinking…"** | murm-ui toggle does not emit elapsed seconds; "Thought for Ns" deferred to follow-up unless trivial timer added in thin wrapper plugin | +| KTD4 | **Voice via first-party `voice-input` plugin** | Inject mic into `.mur-form-footer-right`; remove `#voiceInputBtn` from hide list in `chat-overrides.css`; do not port legacy TTS from `docs/legacy/chatgpt-web/` | +| KTD5 | **5A and 5B as separate PRs** | Origin Q9 default — agent UX ships first; PWA adds build/CI surface | +| KTD6 | **Branch from `main` after Wave 4B merge** | Avoid parallel diffs on `FailoverProvider` / composer | +| KTD7 | **Validate reasoning/tools on mocked SSE + BYOK path** | Free proxy may not emit reasoning today; e2e uses fixture streams | + +## High-Level Technical Design + +```mermaid +flowchart TB + subgraph sse [Stream path 5A] + Proxy[FailoverProvider SSE] + Map[sse.ts OpenAI mapper] + Events[reasoning_delta / tool_call_* / text_delta] + Engine[murm-ui ChatEngine] + Proxy --> Map --> Events --> Engine + end + + subgraph render [Render path 5A] + TP[ThinkingPlugin] + ToolsP[ToolsPlugin] + Engine --> TP + Engine --> ToolsP + end + + subgraph voice [Voice 5A] + Mic[voice-input plugin] + WS[Web Speech API] + Input[#chatinput] + Mic --> WS --> Input + end + + subgraph pwa [PWA 5B] + SW[service-worker.js] + Cache[app shell + free_models.json] + SW --> Cache + end +``` + +## Implementation Units + +### U1. OpenAI SSE mapper — reasoning + tool events (R44–R49, R56) + +**Goal:** `emitOpenAiSseAsStreamEvents` emits the same event types murm-ui's `OpenAIProvider` emits for reasoning and tool calls. + +**Requirements:** R44–R49, R56 + +**Dependencies:** None (Wave 4B merged on branch) + +**Files:** +- `webui/src/providers/sse.ts` — extend delta handling +- `webui/src/providers/openai-stream-mapper.ts` (new, optional extract) — mirror murm-ui reasoning/tool logic for testability +- `webui/src/providers/sse.test.ts` (new) + +**Approach:** +1. Add reasoning extraction for `reasoning_content`, `reasoning`, `reasoning_text`, encrypted variants (match murm-ui `extractReasoning`). +2. Track `currentReasoningBlockId`, `activeToolCalls` map by index; emit `reasoning_delta`, `tool_call_start`, `tool_call_delta`. +3. On `finish_reason: tool_calls`, emit `finish` with `tool_use` (existing). +4. Mark tool blocks `complete` on stream end if murm-ui reducer expects it — verify against `stream-reducer.js` (may need `tool_call_delta` with `status: "complete"` on finish). +5. Text-only chunks behave exactly as today. + +**Patterns to follow:** `webui/node_modules/murm-ui/dist/core/providers/openai.js` (reference only — copy logic into first-party module, do not import from node_modules at runtime). + +**Test scenarios:** +- Chunk with `delta.reasoning_content` → `reasoning_delta` with blockId. +- Chunk with `delta.content` only → `text_delta` only; no reasoning events. +- First `tool_calls` chunk with `id` → `tool_call_start`; follow-up chunks → `tool_call_delta` with args append. +- `[DONE]` after mixed stream → single `finish`. +- Malformed JSON line → ignored (existing behavior). + +**Verification:** `cd webui && npm test` — `sse.test.ts` passes; manual BYOK stream with reasoning model optional. + +--- + +### U2. Register thinking + tools plugins (R44–R49, R46) + +**Goal:** Reasoning and tool blocks render via murm-ui plugins instead of default placeholder/skip. + +**Requirements:** R44–R49, R46 + +**Dependencies:** U1 + +**Files:** +- `webui/src/main.ts` — register plugins in `plugins:` array **before** message rendering order matters (thinking/tools early in list) +- `webui/shell/chat-overrides.css` — optional dark-theme tweaks for `.mur-think-wrapper`, `.mur-tool-summary` on embedded shell + +**Approach:** +1. `import { ThinkingPlugin } from "murm-ui/plugins/thinking"` and `ToolsPlugin` from `murm-ui/plugins/tools` (CSS loads via sideEffects). +2. Register `ThinkingPlugin()` and `ToolsPlugin({ defaultExpanded: false })`. +3. Confirm text blocks still render via default path when no reasoning/tools present (R56 regression). +4. Optional: add `lf-think-muted` override so reasoning sits visually subordinate to answer (R46). + +**Patterns to follow:** Existing plugin registration in `webui/src/main.ts` (`CopyPlugin`, `RoutingChipPlugin`). + +**Test scenarios:** +- Engine state with `reasoning` block → plugin renders toggle (manual or e2e in U5). +- Engine state with `tool_call` block status `streaming` → card shows `...` status symbol per ToolsPlugin. + +**Verification:** Manual chat with mocked provider emitting reasoning/tool events; zero-config text chat unchanged. + +--- + +### U3. Voice input plugin (R50–R52, R51) + +**Goal:** Mic button dictating into composer on Chromium; graceful degrade elsewhere. + +**Requirements:** R50–R52, R51, R57 + +**Dependencies:** None (parallel with U1–U2) + +**Files:** +- `webui/src/plugins/voice-input/index.ts` (new) +- `webui/src/plugins/voice-input/speech.ts` (new) — feature detect, `SpeechRecognition` / `webkitSpeechRecognition` +- `webui/src/plugins/voice-input/speech.test.ts` (new) +- `webui/shell/chat-overrides.css` — remove `#voiceInputBtn` from hide rule; add `.lf-voice-btn` styles aligned with shell +- `webui/src/main.ts` — register `VoiceInputPlugin()` + +**Approach:** +1. On `onMount`, locate `.mur-form-footer-right` and insert mic button before send. +2. If `SpeechRecognition` unavailable: render disabled button with `title` explaining browser limitation (R51). +3. On click: toggle listening; append/interim-update `#chatinput` value; visual `.listening` state (reuse shell CSS patterns). +4. Do **not** auto-submit on `onend` (R52). +5. Handle permission denied with status-strip toast. + +**Patterns to follow:** Interim results pattern from `docs/legacy/chatgpt-web/` (reference only); `ShortcutsSheetPlugin` for modal/toast patterns. + +**Test scenarios:** +- Mock `SpeechRecognition` → transcript appended to textarea value. +- No API → button disabled, `title` contains "not supported". +- `onend` fires → form not submitted. + +**Verification:** Manual on Chrome; vitest for feature-detect helpers. + +--- + +### U4. Offline send guard (R54 partial, prep for 5B) + +**Goal:** When `navigator.onLine === false`, block send with clear copy even before SW lands (cheap win for 5B). + +**Requirements:** R54 (message half) + +**Dependencies:** None + +**Files:** +- `webui/src/plugins/offline-guard/index.ts` (new) or hook in `VoiceInputPlugin`'s sibling +- `webui/src/main.ts` — register plugin + +**Approach:** +1. Listen to `online` / `offline` events. +2. Disable send button + show banner in status strip or above composer when offline. +3. IndexedDB sessions remain readable (murm-ui default — no change). + +**Test scenarios:** +- Fire `offline` event → send disabled, message visible. +- Fire `online` → send re-enabled. + +**Verification:** Manual devtools offline; optional vitest event dispatch. + +--- + +### U5. Service worker + cache (R53, R55) — Wave 5B + +**Goal:** Repeat visits load cached shell; updates prompt reload. + +**Requirements:** R53, R55 + +**Dependencies:** U1–U4 merged (5A) + +**Files:** +- `webui/public/sw.js` or `webui/src/sw.ts` (new) — compiled/copied to `docs/sw.js` +- `webui/esbuild.config.mjs` — copy SW to `docs/` +- `webui/src/pwa/register.ts` (new) — register SW, listen for `controllerchange` / updatefound +- `webui/src/main.ts` — call register on bootstrap +- `docs/manifest.json` — verify `start_url`, icons (existing) + +**Approach:** +1. Cache-first for `./assets/**`, `./assets/shell/**`, `./config.js`, `./free_models.json` with versioned cache name (`lf-cache-v${APP_VERSION}`). +2. Network-first for chat API (N/A — client calls external proxy; no change). +3. On waiting worker → non-blocking toast "Update available — Reload" (R55). +4. Do not cache `/v1/chat/completions` or proxy URLs. + +**Patterns to follow:** MDN PWA offline guide (origin research); version bump via existing `APP_VERSION` in CI. + +**Test scenarios:** +- Second load serves shell from SW (manual Application tab). +- New SW waiting → reload prompt appears. + +**Verification:** Manual; document in `docs/chat-ui-plugins.md`. + +--- + +### U6. E2E, docs, build (R44–R57) + +**Goal:** CI coverage and operator docs for Wave 5A (+ 5B when included). + +**Requirements:** All applicable + +**Dependencies:** U1–U5 (U5 only if shipping 5B in same release cycle) + +**Files:** +- `tests/e2e/reasoning-block.spec.ts` (new) — mock SSE with reasoning chunks +- `tests/e2e/tool-call-card.spec.ts` (new) — mock SSE with tool_calls +- `tests/e2e/voice-input.spec.ts` (new) — disabled state or mocked recognition +- `docs/chat-ui-plugins.md` — document thinking, tools, voice, PWA +- `.github/workflows/deploy-pages.yml` — add e2e specs if stable + +**Test scenarios:** +- Mock stream with reasoning → `.mur-think-toggle` visible in assistant message. +- Mock stream with tool_calls → `.mur-tool-summary` visible (not emoji placeholder). +- Zero-config text reply → no thinking/tool chrome (regression via existing `pages-chat-zero-config.spec.ts`). +- Voice: mic present; on Playwright default (Chromium) optionally skip live mic. + +**Verification:** `cd webui && npm test && npm run build`; Playwright wave 5 specs. + +--- + +## Sequencing + +```mermaid +flowchart LR + U1[U1 SSE mapper] --> U2[U2 plugins] + U3[U3 voice] --> U6[U6 e2e docs] + U4[U4 offline guard] --> U6 + U2 --> U6 + U6 --> U5[U5 PWA 5B] +``` + +**Recommended order (5A):** U1 → U2 → U3 → U4 → U6 + +**5B follow-on:** U5 after 5A merges (can reuse U4 offline guard). + +## Scope Boundaries + +**In scope:** `webui/`, `tests/e2e/`, `docs/chat-ui-plugins.md`, `docs/sw.js` (5B). + +**Out of scope:** Tool execution, MCP panel, TTS, Worker changes. + +### Deferred to Follow-Up Work + +- "Thought for Ns" elapsed timer (KTD3) +- Tool execution loop + `tool_result` round-trip +- Background Sync offline send queue +- SearXNG / tier work (Wave 4B plan) + +**Outside product identity:** MCP marketplace, Open WebUI embedding (STRATEGY). + +## Risks and Dependencies + +| Risk | Mitigation | +|------|------------| +| Free proxy never emits reasoning | Document BYOK/demo path; e2e uses mocks | +| murm-ui plugin CSS clashes with shell | `chat-overrides.css` spot fixes | +| Web Speech blocked or denied | R51 disabled state + toast | +| SW breaks cache busting | Version cache key from `APP_VERSION`; skip caching `chat.js` with query param or use stale-while-revalidate | +| Wave 4B not merged | KTD6 — rebase after 4B | + +## Acceptance Examples + +- **AE1.** Mocked reasoning stream → collapsed thinking toggle expands to show trace; answer text visible below. +- **AE2.** Mocked tool stream → lifecycle card with name and status; expand shows args JSON. +- **AE3.** Chrome → mic fills composer; user clicks send manually. +- **AE4.** Safari → mic disabled with tooltip (or skip if e2e uses unsupported flag). +- **AE5.** Zero-config text chat on homepage → identical to pre-Wave-5 behavior. +- **AE6.** (5B) Offline → prior messages readable; send blocked with message. + +## Sources and Research + +- Origin: `docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md` +- murm-ui reference: `OpenAIProvider` stream mapping, `ThinkingPlugin`, `ToolsPlugin` (v0.2.0) +- Prior voice UX: `docs/legacy/chatgpt-web/` (STT patterns only) +- [TanStack AI thinking content](https://tanstack.com/ai/latest/docs/chat/thinking-content) +- [MDN PWA offline guide](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Offline_and_background_operation) diff --git a/docs/solutions/README.md b/docs/solutions/README.md index e9615e4..d9cef4c 100644 --- a/docs/solutions/README.md +++ b/docs/solutions/README.md @@ -10,6 +10,7 @@ Incident and workflow notes with YAML frontmatter. Search by `applies_when`, `ca | **workflow-issues** | [Product pulse without analytics](workflow-issues/ci-based-product-pulse-without-analytics.md) | STRATEGY metrics when PostHog is not wired | | **integration-issues** | [Workers AI proxy fallback and model chain](integration-issues/workers-ai-proxy-fallback-and-model-chain.md) | 502 on stream fallback, long MODEL_CHAIN | | **ui-bugs** | [murm-ui light theme on dark shell](ui-bugs/murm-ui-light-theme-on-dark-shell.md) | White chat surfaces on dark Pages shell | +| **tooling-decisions** | [murm-ui streaming plaintext tail patch](tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md) | SSE flicker; murm-ui bump vs patch-package | ## `applies_when` quick reference @@ -19,6 +20,7 @@ Incident and workflow notes with YAML frontmatter. Search by `applies_when`, `ca | ci-based-product-pulse-without-analytics | Need product health recap without PostHog | | workers-ai-proxy-fallback-and-model-chain | Live chat 502 after OpenRouter 429; MODEL_CHAIN too long | | murm-ui-light-theme-on-dark-shell | Chat UI renders light surfaces despite dark shell | +| murm-ui-streaming-plaintext-tail-patch | Stream flicker; re-justify patch after murm-ui upgrade | ## Related diff --git a/docs/solutions/tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md b/docs/solutions/tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md new file mode 100644 index 0000000..0b82e20 --- /dev/null +++ b/docs/solutions/tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md @@ -0,0 +1,61 @@ +--- +title: murm-ui streaming plaintext tail via patch-package +date: 2026-07-25 +category: tooling-decisions +module: webui +problem_type: tooling_decision +component: tooling +severity: medium +applies_when: + - "Long SSE replies flicker or re-layout during markdown streaming in murm-ui" + - "Considering a murm-ui bump, fork, or CSS-only fix for stream render jank" + - "Re-evaluating webui/patches/murm-ui+0.2.0.patch after a murm-ui upgrade" +tags: + - murm-ui + - patch-package + - streaming + - sse + - markdown +related_components: + - documentation +--- + +# murm-ui streaming plaintext tail via patch-package + +## Context + +Wave 4 closed the remaining “feels basic” gap on long streamed replies. murm-ui’s `MessageNode` throttled then ran full `marked.parse` + `syncDOMChildren` on every tick, so closed code fences and tables re-laid out mid-stream. Upstream bump alone did not fix it; forking murm-ui would own a full chat engine; CSS containment alone could not stop full-block reparse. + +## Guidance + +Prefer a **bounded `patch-package` change** on murm-ui’s message renderer: + +1. While a text block is still generating, render plain text into a `.mur-streaming-text` node (`textContent`) — no markdown parse per token. +2. When generation completes, clear the plain node and run `applyMarkdown` once for the final content. +3. Keep the patch under `webui/patches/` and apply it via `postinstall: patch-package` in `webui/package.json`. +4. Style `.mur-streaming-text` in `webui/shell/chat-overrides.css` so the streaming tail matches the dark shell. + +Do **not** fork murm-ui for this class of fix. Revisit the patch on every murm-ui bump: try an upstream fix first; if absent, refresh the patch against the new package version. + +## Why This Matters + +Full markdown reparse during SSE is the dominant source of streaming flicker on the GitHub Pages demo. A local patch is cheap to maintain, survives CI `npm ci`, and avoids coupling the product to a murm-ui fork. Losing the patch on an unpatched bump silently regresses Wave 4 acceptance (R19–R20). + +## When to Apply + +- Long assistant streams show full-message flash or re-highlight closed fences mid-stream. +- Evaluating murm-ui upgrades after Wave 4 shipped. +- Choosing between CSS-only mitigation, incremental markdown libraries, and dependency patches. + +## Examples + +**Before (upstream):** throttle timer → `applyMarkdown` on full block text every ~70ms while `generating`. + +**After (patch):** during generate, `plainEl.textContent = block.text`; on complete, `applyMarkdown` once. Playwright `tests/e2e/streaming-polish.spec.ts` asserts plain-text stream then final markdown/code. + +## Related + +- [murm-ui light theme on dark shell](../ui-bugs/murm-ui-light-theme-on-dark-shell.md) — complementary embed pitfalls (`data-theme`, overrides) +- Plan: `docs/plans/2026-07-25-001-feat-chat-ui-wave4-polish-plan.md` (KTD1/KTD2) +- Patch: `webui/patches/murm-ui+0.2.0.patch` +- Operator note: `docs/chat-ui-plugins.md` (Wave 4 streaming polish one-liner) diff --git a/docs/solutions/ui-bugs/murm-ui-light-theme-on-dark-shell.md b/docs/solutions/ui-bugs/murm-ui-light-theme-on-dark-shell.md index 4ae38ff..7a183af 100644 --- a/docs/solutions/ui-bugs/murm-ui-light-theme-on-dark-shell.md +++ b/docs/solutions/ui-bugs/murm-ui-light-theme-on-dark-shell.md @@ -60,3 +60,4 @@ murm-ui defaults to light theme on `.mur-app` unless `data-theme=dark` is set. E ## Related Issues - `webui/shell/chat-overrides.css` — canonical override file (copied to `docs/assets/shell/` on build) +- [murm-ui streaming plaintext tail via patch-package](../tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md) — SSE render jank (different root cause) diff --git a/runner/README.md b/runner/README.md new file mode 100644 index 0000000..46df676 --- /dev/null +++ b/runner/README.md @@ -0,0 +1,72 @@ +# llm-fallbacks web-UI runner + +Opt-in local companion for the chat demo's **web_ui** provider tier (Wave 4B, R38). It exposes an OpenAI-shaped SSE endpoint that the browser client streams from; behind it, a user-configured adapter automates a free web chat UI. The public GitHub Pages demo works fully without this process — zero-config chat always goes through the proxy tier. + +## Quick start + +Requires **Node.js ≥ 23.6** (runs TypeScript natively via type stripping — no build step). + +```bash +cd runner +npm install +npm test # health + SSE contract tests (stub adapter) +npm start # http://127.0.0.1:8815 — chat returns 501 until configured +``` + +Then in the chat demo: **Tiers panel → enable "Local web UI" → set runner URL** to `http://127.0.0.1:8815`. + +## Endpoints + +| Endpoint | Behavior | +|----------|----------| +| `GET /health` | `{ "ok": true, "adapter": "stub" \| "generic-selector" \| null }` | +| `POST /v1/chat/completions` | OpenAI-style SSE stream; **501** until an adapter is configured | + +CORS allows `localhost` / `127.0.0.1` (any port) and `https://*.github.io` origins. + +## Configuration + +Create `runner/runner.config.json` (or point `RUNNER_CONFIG` at a path): + +```json +{ + "adapter": "stub", + "stubReply": "Hello from the runner", + "port": 8815 +} +``` + +The stub adapter streams a fixed reply — use it to verify tier wiring end to end. + +### Generic selector adapter (BYO selectors) + +Automates an arbitrary web chat page with CSS selectors you supply. Requires Playwright: + +```bash +npm i playwright && npx playwright install chromium +``` + +```json +{ + "adapter": "generic-selector", + "port": 8815, + "selector": { + "targetUrl": "https://example-free-chat.example", + "inputSelector": "textarea", + "submitSelector": "button[type=submit]", + "replySelector": ".assistant-message", + "firstReplyTimeoutMs": 30000, + "settleMs": 2500, + "headless": true + } +} +``` + +The adapter navigates to `targetUrl`, types the latest user prompt, clicks submit, and streams the last `replySelector` element's text until it stops growing for `settleMs`. + +## Caveats and responsibilities + +- **You run this; you own it.** The runner is never hosted by the project and no site selectors are shipped. Automating a third-party chat UI is subject to **that site's terms of service** — review them before pointing the adapter anywhere. +- No login automation. Sites requiring auth or captchas need `"headless": false` and a manual session, and may still break. +- One request at a time per adapter; a fresh browser is launched per request (slow but stateless). This is a spike-quality tier, not the primary route — quality API and proxy tiers remain the supported paths. +- The runner binds to `127.0.0.1` only. diff --git a/runner/package-lock.json b/runner/package-lock.json new file mode 100644 index 0000000..d9ef258 --- /dev/null +++ b/runner/package-lock.json @@ -0,0 +1,50 @@ +{ + "name": "llm-fallbacks-runner", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "llm-fallbacks-runner", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=23.6" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/runner/package.json b/runner/package.json new file mode 100644 index 0000000..6364661 --- /dev/null +++ b/runner/package.json @@ -0,0 +1,18 @@ +{ + "name": "llm-fallbacks-runner", + "version": "0.1.0", + "private": true, + "description": "Opt-in local companion for the llm-fallbacks web_ui tier: OpenAI-shaped SSE over user-configured web chat adapters.", + "type": "module", + "engines": { + "node": ">=23.6" + }, + "scripts": { + "start": "node src/server.ts", + "test": "node --test src/server.test.ts" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } +} diff --git a/runner/src/adapters/generic-selector.ts b/runner/src/adapters/generic-selector.ts new file mode 100644 index 0000000..f9e41a0 --- /dev/null +++ b/runner/src/adapters/generic-selector.ts @@ -0,0 +1,98 @@ +import type { ChatMessage, RunnerAdapter } from "./types.ts"; +import { lastUserPrompt } from "./types.ts"; + +/** + * Generic selector-driven browser adapter (BYO selectors, R38). Automates an + * arbitrary free web chat UI: navigate, type the prompt, submit, then poll + * the reply element until its text stops growing. Nothing is hardcoded to a + * specific site — the user supplies the target URL and CSS selectors and is + * responsible for that site's terms of service. + * + * Playwright is imported lazily so the runner works (stub adapter, /health) + * without it installed. Enable with: npm i playwright && npx playwright + * install chromium + */ +export interface SelectorAdapterConfig { + targetUrl: string; + inputSelector: string; + submitSelector: string; + replySelector: string; + /** Max ms to wait for the first reply text. Default 30000. */ + firstReplyTimeoutMs?: number; + /** Reply is considered complete after this many ms without growth. Default 2500. */ + settleMs?: number; + /** Run the browser headed for manual login/captcha flows. Default true (headless). */ + headless?: boolean; +} + +export function createGenericSelectorAdapter(config: SelectorAdapterConfig): RunnerAdapter { + for (const key of ["targetUrl", "inputSelector", "submitSelector", "replySelector"] as const) { + if (!config[key]) { + throw new Error(`generic-selector adapter config is missing "${key}"`); + } + } + + return { + name: "generic-selector", + async streamReply( + messages: ChatMessage[], + onDelta: (text: string) => void, + signal?: AbortSignal + ): Promise { + let playwright: typeof import("playwright"); + try { + playwright = await import("playwright"); + } catch { + throw new Error( + "Playwright is not installed. Run: npm i playwright && npx playwright install chromium" + ); + } + + const prompt = lastUserPrompt(messages); + const firstReplyTimeoutMs = config.firstReplyTimeoutMs ?? 30_000; + const settleMs = config.settleMs ?? 2_500; + + const browser = await playwright.chromium.launch({ headless: config.headless ?? true }); + try { + const page = await browser.newPage(); + await page.goto(config.targetUrl, { waitUntil: "domcontentloaded" }); + await page.fill(config.inputSelector, prompt); + await page.click(config.submitSelector); + + // Stream growth of the last reply element's text; finish when it + // settles or the abort signal fires. + let emitted = ""; + let lastGrowth = Date.now(); + const deadline = Date.now() + firstReplyTimeoutMs; + for (;;) { + if (signal?.aborted) return; + const text = + (await page + .locator(config.replySelector) + .last() + .textContent() + .catch(() => null)) ?? ""; + if (text.length > emitted.length && text.startsWith(emitted)) { + onDelta(text.slice(emitted.length)); + emitted = text; + lastGrowth = Date.now(); + } else if (text && text !== emitted && !text.startsWith(emitted)) { + // Reply element re-rendered from scratch; re-emit the full text. + onDelta(text); + emitted = text; + lastGrowth = Date.now(); + } + if (emitted && Date.now() - lastGrowth >= settleMs) break; + if (!emitted && Date.now() > deadline) { + throw new Error( + `No reply text appeared in ${config.replySelector} within ${firstReplyTimeoutMs}ms` + ); + } + await page.waitForTimeout(250); + } + } finally { + await browser.close(); + } + }, + }; +} diff --git a/runner/src/adapters/stub.ts b/runner/src/adapters/stub.ts new file mode 100644 index 0000000..4931b25 --- /dev/null +++ b/runner/src/adapters/stub.ts @@ -0,0 +1,20 @@ +import type { ChatMessage, RunnerAdapter } from "./types.ts"; +import { lastUserPrompt } from "./types.ts"; + +/** + * Smoke-test adapter: streams a fixed reply in small chunks. Useful for + * verifying the tier wiring end to end before configuring a real browser + * adapter. + */ +export function createStubAdapter(reply?: string): RunnerAdapter { + return { + name: "stub", + async streamReply(messages: ChatMessage[], onDelta: (text: string) => void): Promise { + const text = reply ?? `Stub runner echo: ${lastUserPrompt(messages)}`; + const words = text.split(/(\s+)/); + for (const word of words) { + if (word) onDelta(word); + } + }, + }; +} diff --git a/runner/src/adapters/types.ts b/runner/src/adapters/types.ts new file mode 100644 index 0000000..46797af --- /dev/null +++ b/runner/src/adapters/types.ts @@ -0,0 +1,25 @@ +export interface ChatMessage { + role: string; + content: string; +} + +/** + * A runner adapter turns a chat transcript into a streamed plain-text reply. + * Adapters are user-configured — the runner ships with a stub for smoke + * testing and a generic selector-driven browser adapter (BYO selectors). + */ +export interface RunnerAdapter { + name: string; + streamReply( + messages: ChatMessage[], + onDelta: (text: string) => void, + signal?: AbortSignal + ): Promise; +} + +export function lastUserPrompt(messages: ChatMessage[]): string { + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (messages[i].role === "user") return messages[i].content; + } + return ""; +} diff --git a/runner/src/server.test.ts b/runner/src/server.test.ts new file mode 100644 index 0000000..2e09e53 --- /dev/null +++ b/runner/src/server.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { after, describe, it } from "node:test"; +import type { AddressInfo } from "node:net"; +import type { Server } from "node:http"; +import { createStubAdapter } from "./adapters/stub.ts"; +import { createRunnerServer } from "./server.ts"; + +const servers: Server[] = []; + +function listen(server: Server): Promise { + servers.push(server); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as AddressInfo; + resolve(`http://127.0.0.1:${port}`); + }); + }); +} + +after(() => { + for (const server of servers) server.close(); +}); + +describe("runner server", () => { + it("GET /health reports the active adapter", async () => { + const base = await listen(createRunnerServer(createStubAdapter())); + const res = await fetch(`${base}/health`); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { ok: true, adapter: "stub" }); + }); + + it("streams OpenAI-shaped SSE from the stub adapter", async () => { + const base = await listen(createRunnerServer(createStubAdapter("fixed runner reply"))); + const res = await fetch(`${base}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "web-ui", + messages: [{ role: "user", content: "hello" }], + stream: true, + }), + }); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type") ?? "", /text\/event-stream/); + + const raw = await res.text(); + let text = ""; + let finished = false; + for (const line of raw.split("\n")) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (data === "[DONE]") continue; + const parsed = JSON.parse(data) as { + choices?: { delta?: { content?: string }; finish_reason?: string }[]; + }; + text += parsed.choices?.[0]?.delta?.content ?? ""; + if (parsed.choices?.[0]?.finish_reason === "stop") finished = true; + } + assert.equal(text, "fixed runner reply"); + assert.ok(finished, "expected a finish_reason=stop chunk"); + assert.ok(raw.includes("data: [DONE]")); + }); + + it("returns 501 with guidance when no adapter is configured", async () => { + const base = await listen(createRunnerServer(null)); + const res = await fetch(`${base}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: [{ role: "user", content: "hello" }] }), + }); + assert.equal(res.status, 501); + const body = (await res.json()) as { error: { message: string } }; + assert.match(body.error.message, /runner\.config\.json/); + }); + + it("answers CORS preflight for localhost and github.io origins", async () => { + const base = await listen(createRunnerServer(createStubAdapter())); + const res = await fetch(`${base}/v1/chat/completions`, { + method: "OPTIONS", + headers: { Origin: "https://example.github.io" }, + }); + assert.equal(res.status, 204); + assert.equal(res.headers.get("access-control-allow-origin"), "https://example.github.io"); + + const denied = await fetch(`${base}/health`, { + headers: { Origin: "https://evil.example.com" }, + }); + assert.equal(denied.headers.get("access-control-allow-origin"), null); + }); +}); diff --git a/runner/src/server.ts b/runner/src/server.ts new file mode 100644 index 0000000..8997d63 --- /dev/null +++ b/runner/src/server.ts @@ -0,0 +1,155 @@ +/** + * llm-fallbacks web-UI runner (R38): a user-run companion that exposes an + * OpenAI-shaped SSE endpoint backed by a configurable adapter. The public + * Pages demo never requires this process — it powers the opt-in web_ui tier. + * + * Endpoints: + * GET /health → { ok, adapter } + * POST /v1/chat/completions → OpenAI-style SSE (501 until an adapter is configured) + */ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { readFileSync } from "node:fs"; +import { createGenericSelectorAdapter, type SelectorAdapterConfig } from "./adapters/generic-selector.ts"; +import { createStubAdapter } from "./adapters/stub.ts"; +import type { ChatMessage, RunnerAdapter } from "./adapters/types.ts"; + +const DEFAULT_PORT = 8815; + +// Browser callers are the local dev shell or the GitHub Pages demo. +const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$|^https:\/\/[\w-]+\.github\.io$/; + +function applyCors(req: IncomingMessage, res: ServerResponse): void { + const origin = req.headers.origin; + if (origin && ALLOWED_ORIGIN_RE.test(origin)) { + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + } +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); +} + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString("utf8"); +} + +function sseChunk(payload: unknown): string { + return `data: ${JSON.stringify(payload)}\n\n`; +} + +async function handleChat( + adapter: RunnerAdapter | null, + req: IncomingMessage, + res: ServerResponse +): Promise { + if (!adapter) { + sendJson(res, 501, { + error: { + message: + "No runner adapter configured. Create runner.config.json (see runner/README.md) and restart.", + }, + }); + return; + } + + let messages: ChatMessage[]; + try { + const parsed = JSON.parse(await readBody(req)) as { messages?: ChatMessage[] }; + messages = parsed.messages ?? []; + } catch { + sendJson(res, 400, { error: { message: "Invalid JSON body" } }); + return; + } + if (messages.length === 0) { + sendJson(res, 400, { error: { message: "messages[] is required" } }); + return; + } + + res.writeHead(200, { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + + const abort = new AbortController(); + req.on("close", () => abort.abort()); + + try { + await adapter.streamReply( + messages, + (delta) => { + res.write(sseChunk({ choices: [{ delta: { content: delta } }] })); + }, + abort.signal + ); + res.write(sseChunk({ choices: [{ delta: {}, finish_reason: "stop" }] })); + res.write("data: [DONE]\n\n"); + } catch (err) { + // Headers are already sent; surface the failure as an SSE error payload. + const message = err instanceof Error ? err.message : String(err); + res.write(sseChunk({ error: { message: `runner adapter "${adapter.name}" failed: ${message}` } })); + } + res.end(); +} + +export function createRunnerServer(adapter: RunnerAdapter | null): Server { + return createServer((req, res) => { + applyCors(req, res); + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + if (req.method === "GET" && req.url === "/health") { + sendJson(res, 200, { ok: true, adapter: adapter?.name ?? null }); + return; + } + if (req.method === "POST" && req.url === "/v1/chat/completions") { + void handleChat(adapter, req, res); + return; + } + sendJson(res, 404, { error: { message: "Not found" } }); + }); +} + +interface RunnerConfig { + adapter?: "stub" | "generic-selector"; + stubReply?: string; + port?: number; + selector?: SelectorAdapterConfig; +} + +export function adapterFromConfig(config: RunnerConfig): RunnerAdapter | null { + if (config.adapter === "stub") return createStubAdapter(config.stubReply); + if (config.adapter === "generic-selector" && config.selector) { + return createGenericSelectorAdapter(config.selector); + } + return null; +} + +function loadConfig(path: string): RunnerConfig { + try { + return JSON.parse(readFileSync(path, "utf8")) as RunnerConfig; + } catch { + return {}; + } +} + +const isMain = process.argv[1]?.endsWith("server.ts") || process.argv[1]?.endsWith("server.js"); +if (isMain) { + const configPath = process.env.RUNNER_CONFIG ?? new URL("../runner.config.json", import.meta.url).pathname; + const config = loadConfig(configPath); + const adapter = adapterFromConfig(config); + const port = config.port ?? (Number(process.env.PORT) || DEFAULT_PORT); + createRunnerServer(adapter).listen(port, "127.0.0.1", () => { + console.log( + `llm-fallbacks runner on http://127.0.0.1:${port} — adapter: ${adapter?.name ?? "none (chat returns 501)"}` + ); + }); +} diff --git a/runner/tsconfig.json b/runner/tsconfig.json new file mode 100644 index 0000000..e712035 --- /dev/null +++ b/runner/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/tests/e2e/compare-mode.spec.ts b/tests/e2e/compare-mode.spec.ts new file mode 100644 index 0000000..18cb53d --- /dev/null +++ b/tests/e2e/compare-mode.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from "@playwright/test"; +import { + DEMO_PROXY, + installLocalChatBundle, + installTestConfigMock, + mockProxySse, +} from "./helpers"; + +test.describe("Wave 4B — compare mode", () => { + test.beforeEach(async ({ page }) => { + let call = 0; + await installTestConfigMock(page); + await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => { + call += 1; + const reply = call === 1 ? "reply from column A" : "reply from column B"; + await route.fulfill({ + status: 200, + contentType: "text/event-stream; charset=utf-8", + body: mockProxySse(reply), + }); + }); + await installLocalChatBundle(page); + await page.goto("./", { waitUntil: "domcontentloaded" }); + await page.evaluate(() => localStorage.clear()); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator("#lf-compare-toggle")).toBeVisible({ timeout: 45_000 }); + }); + + test("toggle shows grid, dual replies, exit keeps history", async ({ page }) => { + const toggle = page.locator("#lf-compare-toggle"); + await toggle.evaluate((el: HTMLInputElement) => { + el.checked = true; + el.dispatchEvent(new Event("change", { bubbles: true })); + }); + await expect(page.locator(".lf-compare-grid")).toBeVisible(); + await expect(page.locator("#lf-compare-banner")).toBeVisible(); + await expect(page.locator("#lf-compare-banner")).toContainText(/two requests/i); + + await page.locator("#chatinput").fill("compare please"); + await page.locator("#sendbutton").click({ force: true }); + + await expect(page.locator('[data-pane="a"]')).toContainText("column A", { timeout: 30_000 }); + await expect(page.locator('[data-pane="b"]')).toContainText("column B", { timeout: 30_000 }); + + // History keeps compare turns after exit (R35). + await toggle.evaluate((el: HTMLInputElement) => { + el.checked = false; + el.dispatchEvent(new Event("change", { bubbles: true })); + }); + await expect(page.locator(".lf-compare-chrome")).toBeHidden(); + await expect(page.locator(".mur-message-user").last()).toContainText("compare please"); + await expect(page.locator(".mur-message-assistant")).toHaveCount(2, { timeout: 15_000 }); + }); +}); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 8779225..5fd289f 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -200,6 +200,26 @@ export async function installLocalChatBundle(page: Page): Promise { }); } +/** + * Serve the locally built docs/index.html instead of the deployed page. + * Needed when a spec depends on static markup (e.g. the Tiers button) that + * has not shipped to GitHub Pages yet. Pair with installLocalChatBundle. + */ +export async function installLocalIndexHtml(page: Page): Promise { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const fs = require("node:fs") as typeof import("node:fs"); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const path = require("node:path") as typeof import("node:path"); + const body = fs.readFileSync(path.join(process.cwd(), "docs/index.html"), "utf8"); + await page.route("**/*", async (route) => { + if (route.request().resourceType() !== "document") { + await route.fallback(); + return; + } + await route.fulfill({ status: 200, contentType: "text/html; charset=utf-8", body }); + }); +} + export function readStoredEndpoints(page: Page): Promise { return page.evaluate(() => { try { diff --git a/tests/e2e/tier-settings.spec.ts b/tests/e2e/tier-settings.spec.ts new file mode 100644 index 0000000..791e315 --- /dev/null +++ b/tests/e2e/tier-settings.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from "@playwright/test"; +import { + DEMO_PROXY, + installLocalChatBundle, + installLocalIndexHtml, + installTestConfigMock, + mockProxySse, +} from "./helpers"; + +const TIERS_KEY = "llm_fallbacks_provider_tiers"; + +test.describe("Wave 4B — tier settings panel", () => { + 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: mockProxySse("proxy reply"), + }); + }); + await installLocalChatBundle(page); + await installLocalIndexHtml(page); + await page.goto("./", { waitUntil: "domcontentloaded" }); + await page.evaluate(() => localStorage.clear()); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator("#tiersSetting")).toBeVisible({ timeout: 45_000 }); + }); + + test("reorder persists to localStorage and survives reload (R36)", async ({ page }) => { + await page.locator("#tiersSetting").click(); + const panel = page.locator("#shell-panel-tiers"); + await expect(panel).toBeVisible(); + await expect(panel.locator(".lf-tier-row")).toHaveCount(4); + + // Default order starts with quality_api; move proxy_failover to the top. + await panel.locator('[data-tier-up="proxy_failover"]').click(); + await panel.locator('[data-tier-up="proxy_failover"]').click(); + await panel.locator('[data-tier-up="proxy_failover"]').click(); + await expect(panel.locator(".lf-tier-row").first()).toHaveAttribute( + "data-tier-id", + "proxy_failover" + ); + + await panel.locator("#lf-tier-save").click(); + await expect(panel.locator("#lf-tier-status")).toContainText(/Saved order: proxy_failover/); + + const stored = await page.evaluate( + (key) => JSON.parse(localStorage.getItem(key) || "null"), + TIERS_KEY + ); + expect(stored?.tiers?.[0]).toEqual({ id: "proxy_failover", enabled: true }); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator("#tiersSetting")).toBeVisible({ timeout: 45_000 }); + await page.locator("#tiersSetting").click(); + await expect( + page.locator("#shell-panel-tiers .lf-tier-row").first() + ).toHaveAttribute("data-tier-id", "proxy_failover"); + }); + + test("zero-config chat still streams with web/searxng tiers untouched (AE3)", async ({ + page, + }) => { + await page.locator("#chatinput").fill("hello zero config"); + await page.locator("#sendbutton").click({ force: true }); + await expect(page.locator(".mur-message-assistant").last()).toContainText("proxy reply", { + timeout: 30_000, + }); + }); +}); diff --git a/tests/e2e/vision-attach.spec.ts b/tests/e2e/vision-attach.spec.ts new file mode 100644 index 0000000..790ab1a --- /dev/null +++ b/tests/e2e/vision-attach.spec.ts @@ -0,0 +1,96 @@ +import { test, expect, type Page } from "@playwright/test"; +import { + DEMO_PROXY, + installLocalChatBundle, + installTestConfigMock, + mockProxySse, + waitForAssistantText, +} from "./helpers"; + +const MOCK_CATALOG = [ + { + 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 }, +]; + +// 1x1 transparent PNG +const TINY_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64" +); + +async function attachPng(page: Page): Promise { + await page.setInputFiles('input[type="file"]', { + name: "tiny.png", + mimeType: "image/png", + buffer: TINY_PNG, + }); + await expect(page.locator(".mur-attachment-preview-item")).toBeVisible({ timeout: 15_000 }); +} + +test.describe("Wave 4B — vision attachments", () => { + let lastProxyBody: { messages?: { role: string; content: unknown }[] } | null; + + test.beforeEach(async ({ page }) => { + lastProxyBody = null; + await installTestConfigMock(page); + await page.route("**/free_models.json", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(MOCK_CATALOG), + }); + }); + await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => { + lastProxyBody = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: "text/event-stream; charset=utf-8", + body: mockProxySse("I can see a tiny transparent pixel."), + }); + }); + await installLocalChatBundle(page); + await page.goto("./", { waitUntil: "domcontentloaded" }); + await page.evaluate(() => localStorage.clear()); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator(".lf-model-picker-select")).toBeVisible({ timeout: 45_000 }); + }); + + test("PNG + vision model streams a reply and sends image_url to the proxy (AE1)", async ({ + page, + }) => { + await page.locator(".lf-model-picker-select").selectOption("groq/llama-3.3-70b"); + await attachPng(page); + + await page.locator("#chatinput").fill("what is in this image?"); + await page.locator("#sendbutton").click({ force: true }); + + const reply = await waitForAssistantText(page); + expect(reply).toContain("tiny transparent pixel"); + + expect(lastProxyBody).not.toBeNull(); + const userMessage = lastProxyBody?.messages?.at(-1); + expect(Array.isArray(userMessage?.content)).toBe(true); + const parts = userMessage?.content as { type: string; image_url?: { url: string } }[]; + const imagePart = parts.find((p) => p.type === "image_url"); + expect(imagePart?.image_url?.url).toMatch(/^data:image\/png;base64,/); + }); + + test("PNG on a non-vision model blocks with a clear message (R29)", async ({ page }) => { + // Default model stays `free` (not vision-capable in the catalog). + await attachPng(page); + + await page.locator("#chatinput").fill("describe this image"); + await page.locator("#sendbutton").click({ force: true }); + + await expect(page.locator("#chatMount")).toContainText(/can't read images/i, { + timeout: 30_000, + }); + expect(lastProxyBody).toBeNull(); + }); +}); diff --git a/webui/index.template.html b/webui/index.template.html index a666087..d3e4c82 100644 --- a/webui/index.template.html +++ b/webui/index.template.html @@ -24,6 +24,7 @@
    Server
    Your keys
    +
    Tiers
    Models
    @@ -67,6 +68,7 @@

    Chat

    + diff --git a/webui/shell/chat-overrides.css b/webui/shell/chat-overrides.css index ad20d36..f5a1205 100644 --- a/webui/shell/chat-overrides.css +++ b/webui/shell/chat-overrides.css @@ -960,3 +960,255 @@ body.lf-chat-page::before { color: #c77dff; } +/* Wave 4B: image attachment tray — inherits murm-ui attachment vars from the + dark theme block above; these rules just tighten thumbnail framing so the + composer tray matches the dark shell. */ +#chatMount .mur-attachment-previews { + gap: 0.4rem; +} + +#chatMount .mur-attachment-preview-item img, +#chatMount .mur-attachment-image { + border-radius: 8px; + border: 1px solid var(--mur-border); +} + +/* Wave 4B: provider tier settings panel */ +.lf-tier-list { + list-style: none; + margin: 0 0 1rem; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.lf-tier-row { + border: 1px solid rgba(157, 78, 221, 0.22); + border-radius: 10px; + padding: 0.65rem 0.75rem; + background: rgba(26, 26, 46, 0.55); +} + +.lf-tier-row-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.lf-tier-enable { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-weight: 600; + color: #e8e8ef; +} + +.lf-tier-enable input { + width: auto; + margin: 0; +} + +.lf-tier-move { + display: flex; + gap: 0.25rem; + flex-shrink: 0; +} + +.lf-tier-move .panel-btn { + min-width: 2rem; + padding: 0.2rem 0.45rem; +} + +.lf-tier-hint { + margin: 0.35rem 0 0; +} + +/* Wave 4B: compare mode split-pane */ +.lf-compare-toggle-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + padding: 0.35rem 0.15rem 0.55rem; +} + +.lf-compare-toggle { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin: 0; + font-weight: 600; + color: #e8e8ef; + cursor: pointer; +} + +.lf-compare-toggle input { + width: auto; + margin: 0; +} + +.lf-compare-toggle-hint { + font-size: 0.78rem; + color: #8b8ba3; +} + +.lf-compare-chrome { + padding: 0.5rem 0.75rem 0.85rem; + border-top: 1px solid rgba(157, 78, 221, 0.22); + background: rgba(18, 18, 31, 0.92); + flex-shrink: 0; +} + +.lf-compare-chrome[hidden] { + display: none !important; +} + +.lf-compare-banner { + margin: 0 0 0.65rem; + padding: 0.55rem 0.7rem; + border-radius: 8px; + border: 1px solid rgba(248, 113, 113, 0.35); + background: rgba(127, 29, 29, 0.28); + color: #fecaca; + font-size: 0.82rem; +} + +.lf-compare-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +@media (max-width: 720px) { + .lf-compare-grid { + grid-template-columns: 1fr; + } +} + +.lf-compare-column { + border: 1px solid rgba(157, 78, 221, 0.22); + border-radius: 10px; + background: rgba(26, 26, 46, 0.65); + min-height: 8rem; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.lf-compare-column-header { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.55rem 0.65rem; + border-bottom: 1px solid rgba(157, 78, 221, 0.18); +} + +.lf-compare-column-title { + display: block; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #8b8ba3; + margin-bottom: 0.2rem; +} + +.lf-compare-model { + width: 100%; + max-width: 100%; +} + +.lf-compare-live-label { + font-size: 0.75rem; + color: #c77dff; + word-break: break-all; +} + +.lf-compare-pane { + flex: 1; + padding: 0.65rem 0.75rem; + white-space: pre-wrap; + color: #e8e8ef; + font-size: 0.92rem; + line-height: 1.45; + min-height: 5rem; +} + +.lf-compare-pane:empty::before { + content: "Waiting for reply…"; + color: #8b8ba3; + font-style: italic; +} + +#chatMount.lf-compare-active .mur-chat-history { + /* Keep history visible above the live compare panes */ + max-height: 42vh; +} + +/* Wave 4B: SearXNG discovery pick list */ +.lf-discovery-picklist { + flex-shrink: 0; + padding: 0.55rem 0.75rem; + border-top: 1px solid rgba(157, 78, 221, 0.22); + background: rgba(18, 18, 31, 0.92); +} + +.lf-discovery-picklist[hidden] { + display: none !important; +} + +.lf-discovery-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.8rem; + color: #c4c4d4; +} + +.lf-discovery-dismiss { + min-width: 1.8rem; + padding: 0.1rem 0.4rem; +} + +.lf-discovery-list { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.lf-discovery-item { + border: 1px solid rgba(157, 78, 221, 0.22); + border-radius: 8px; + padding: 0.5rem 0.65rem; + background: rgba(26, 26, 46, 0.6); +} + +.lf-discovery-item a { + color: #c77dff; + font-weight: 600; + text-decoration: none; +} + +.lf-discovery-item a:hover { + text-decoration: underline; +} + +.lf-discovery-url { + display: block; + font-size: 0.72rem; + color: #8b8ba3; + word-break: break-all; +} + +.lf-discovery-snippet { + margin: 0.3rem 0 0; + font-size: 0.8rem; + color: #c4c4d4; +} + diff --git a/webui/src/main.ts b/webui/src/main.ts index 31db069..b60ca27 100644 --- a/webui/src/main.ts +++ b/webui/src/main.ts @@ -1,5 +1,6 @@ import { ChatUI, IndexedDBStorage, type ChatEngine } from "murm-ui/with-css"; import { CopyPlugin } from "murm-ui/plugins/copy"; +import { AttachmentPlugin } from "murm-ui/plugins/attachment"; import { loadRuntimeConfig, readRuntimeConfig, @@ -10,6 +11,9 @@ import { FailoverProvider } from "./providers/FailoverProvider"; import type { CatalogEntry } from "./providers/browser-router"; import { FailoverSettingsPlugin } from "./plugins/failover-settings"; import { ByokSettingsPlugin } from "./plugins/byok-settings"; +import { TierSettingsPlugin } from "./plugins/tier-settings"; +import { CompareModePlugin } from "./plugins/compare-mode"; +import { DiscoveryPicklistPlugin } from "./plugins/discovery-picklist"; import { ModelExplorerPlugin } from "./plugins/model-explorer"; import { ModelPickerPlugin } from "./plugins/model-picker"; import { RoutingChipPlugin } from "./plugins/routing-chip"; @@ -36,6 +40,9 @@ import { import { ShortcutsSheetPlugin } from "./plugins/shortcuts-sheet"; import { showStatusMessage } from "./plugins/status-strip"; +// Published client-side image attachment cap (R31). +const MAX_IMAGE_ATTACHMENT_BYTES = 4_000_000; + async function loadCatalog(config: AppConfig): Promise<{ catalog: CatalogEntry[]; providerUrls: Record; @@ -202,6 +209,19 @@ async function bootstrap(): Promise { }, plugins: (engine) => [ CopyPlugin(), + AttachmentPlugin({ + acceptedTypes: "image/*", + maxFileSize: MAX_IMAGE_ATTACHMENT_BYTES, + onSizeExceeded: (file, maxSize) => { + const limitMb = Math.round(maxSize / 1_000_000); + showStatusMessage( + `"${file.name}" is too large. Images must be under ${limitMb} MB.` + ); + }, + onUnsupportedFile: (file) => { + showStatusMessage(`"${file.name}" isn't a supported image type.`); + }, + }), ModelPickerPlugin(), MessageActionsPlugin(), RoutingChipPlugin(), @@ -224,6 +244,12 @@ async function bootstrap(): Promise { provider.setCatalog(catalogRef, providerUrlsRef); }, }), + TierSettingsPlugin(), + CompareModePlugin({ + provider, + getCatalog: () => catalogRef, + }), + DiscoveryPicklistPlugin(), ModelExplorerPlugin({ getCatalog: () => catalogRef, getCatalogUrl: () => readRuntimeConfig().catalogUrl, diff --git a/webui/src/plugins/compare-mode/column-provider.test.ts b/webui/src/plugins/compare-mode/column-provider.test.ts new file mode 100644 index 0000000..2c6e726 --- /dev/null +++ b/webui/src/plugins/compare-mode/column-provider.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + METERED_COMPARE_BANNER, + bothColumnsMetered, + columnIsMetered, + defaultCompareState, +} from "./column-provider"; +import { STORAGE_KEYS, saveJson } from "../../storage-keys"; +import type { CatalogEntry } from "../../providers/browser-router"; + +const catalog: CatalogEntry[] = [ + { id: "groq/llama", provider: "groq", supports_vision: false }, + { id: "openrouter/free", provider: "openrouter" }, +]; + +describe("compare column helpers", () => { + it("defaults to inactive with free vs openrouter/free", () => { + const state = defaultCompareState("free"); + expect(state.active).toBe(false); + expect(state.columns.a.model).toBe("free"); + expect(state.columns.b.model).toBe("openrouter/free"); + }); + + it("treats free as metered (public proxy)", () => { + expect(columnIsMetered("free", catalog, {})).toBe(true); + }); + + it("treats a BYOK-backed model as not metered", () => { + expect(columnIsMetered("groq/llama", catalog, { groq: "sk-test" })).toBe(false); + }); + + it("flags both-columns-metered for R34 banner", () => { + localStorage.clear(); + saveJson(STORAGE_KEYS.apiKeys, {}); + const state = defaultCompareState(); + state.active = true; + state.columns.a.model = "free"; + state.columns.b.model = "openrouter/free"; + expect(bothColumnsMetered(state, catalog, {})).toBe(true); + expect(METERED_COMPARE_BANNER).toMatch(/two requests/i); + }); + + it("does not flag metered banner when one column is BYOK", () => { + const state = defaultCompareState(); + state.active = true; + state.columns.a.model = "free"; + state.columns.b.model = "groq/llama"; + expect(bothColumnsMetered(state, catalog, { groq: "sk-test" })).toBe(false); + }); +}); diff --git a/webui/src/plugins/compare-mode/column-provider.ts b/webui/src/plugins/compare-mode/column-provider.ts new file mode 100644 index 0000000..b74d2b2 --- /dev/null +++ b/webui/src/plugins/compare-mode/column-provider.ts @@ -0,0 +1,60 @@ +import type { CatalogEntry } from "../../providers/browser-router"; +import { hasKeyForModel, loadKeys, shouldTryBrowser } from "../../providers/browser-router"; + +export type CompareColumnId = "a" | "b"; + +export interface CompareColumnConfig { + model: string; +} + +export interface CompareState { + active: boolean; + columns: Record; +} + +export function defaultCompareState(activeModel = "free"): CompareState { + return { + active: false, + columns: { + a: { model: activeModel || "free" }, + b: { model: "openrouter/free" }, + }, + }; +} + +/** + * A column is "metered" when it will hit the shared public proxy (no usable + * BYOK key for that model). Compare of two metered columns costs two proxy + * requests — rate limits and Turnstile apply twice (R34). + */ +export function columnIsMetered( + model: string, + catalog: readonly CatalogEntry[], + keys = loadKeys() +): boolean { + if (model === "free") return true; + return !shouldTryBrowser(model, catalog as CatalogEntry[], keys); +} + +export function bothColumnsMetered( + state: CompareState, + catalog: readonly CatalogEntry[], + keys = loadKeys() +): boolean { + if (!state.active) return false; + return ( + columnIsMetered(state.columns.a.model, catalog, keys) && + columnIsMetered(state.columns.b.model, catalog, keys) + ); +} + +export function compareUsesByok( + model: string, + keys = loadKeys() +): boolean { + if (model === "free") return false; + return hasKeyForModel(model, keys); +} + +export const METERED_COMPARE_BANNER = + "Compare sends two requests. Rate limits and Turnstile apply to each column when both use the public proxy."; diff --git a/webui/src/plugins/compare-mode/index.ts b/webui/src/plugins/compare-mode/index.ts new file mode 100644 index 0000000..6ce3c04 --- /dev/null +++ b/webui/src/plugins/compare-mode/index.ts @@ -0,0 +1,256 @@ +import type { ChatPlugin, ChatRequest, StreamEvent } from "murm-ui"; +import { + getActiveModel, + getCatalogModels, + getPinnedModels, + modelOptionLabel, +} from "../../model-selection"; +import type { CatalogEntry } from "../../providers/browser-router"; +import type { FailoverProvider } from "../../providers/FailoverProvider"; +import { showStatusMessage } from "../status-strip"; +import { + METERED_COMPARE_BANNER, + bothColumnsMetered, + defaultCompareState, + type CompareState, +} from "./column-provider"; + +function modelOptionsHtml(selected: string): string { + const parts: string[] = []; + for (const pinned of getPinnedModels()) { + parts.push( + `` + ); + } + for (const entry of getCatalogModels(40)) { + if (getPinnedModels().some((p) => p.id === entry.id)) continue; + parts.push( + `` + ); + } + return parts.join(""); +} + +function applyDeltaToPane(pane: HTMLElement, event: StreamEvent): void { + if (event.type === "message_start") { + pane.textContent = ""; + return; + } + if (event.type === "text_delta") { + pane.textContent = (pane.textContent || "") + event.delta; + } +} + +/** Tag message_start so history rows carry compare column + model (R33). */ +function tagCompareMeta( + event: StreamEvent, + column: "a" | "b", + model: string +): StreamEvent { + if (event.type !== "message_start") return event; + return { + ...event, + message: { + ...event.message, + meta: { ...(event.message.meta || {}), compareColumn: column, model }, + }, + }; +} + +export function CompareModePlugin(deps: { + provider: FailoverProvider; + getCatalog: () => CatalogEntry[]; +}): ChatPlugin { + let state: CompareState = defaultCompareState(getActiveModel()); + let mount: HTMLElement | null = null; + let chrome: HTMLElement | null = null; + let bannerEl: HTMLElement | null = null; + let paneA: HTMLElement | null = null; + let paneB: HTMLElement | null = null; + let labelA: HTMLElement | null = null; + let labelB: HTMLElement | null = null; + let wrapped = false; + + const refreshBanner = (): void => { + if (!bannerEl) return; + const show = bothColumnsMetered(state, deps.getCatalog()); + bannerEl.hidden = !show; + bannerEl.textContent = show ? METERED_COMPARE_BANNER : ""; + }; + + const syncChromeVisibility = (): void => { + if (!chrome || !mount) return; + chrome.hidden = !state.active; + mount.classList.toggle("lf-compare-active", state.active); + refreshBanner(); + }; + + const paintColumnLabels = (): void => { + if (labelA) labelA.textContent = state.columns.a.model; + if (labelB) labelB.textContent = state.columns.b.model; + }; + + return { + name: "compare-mode", + onMount(ctx) { + mount = ctx.container; + + chrome = document.createElement("div"); + chrome.className = "lf-compare-chrome"; + chrome.hidden = true; + chrome.innerHTML = ` + +
    +
    +
    + + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + `; + + const layout = mount.querySelector(".mur-chat-layout-wrapper"); + const formHost = mount.querySelector(".mur-chat-form-container"); + if (layout && formHost) { + layout.insertBefore(chrome, formHost); + } else { + (mount.querySelector(".mur-chat-scroll-area") || mount).appendChild(chrome); + } + + bannerEl = chrome.querySelector("#lf-compare-banner"); + paneA = chrome.querySelector('[data-pane="a"]'); + paneB = chrome.querySelector('[data-pane="b"]'); + labelA = chrome.querySelector('[data-label="a"]'); + labelB = chrome.querySelector('[data-label="b"]'); + + const selectA = chrome.querySelector('select[data-column="a"]')!; + const selectB = chrome.querySelector('select[data-column="b"]')!; + selectA.innerHTML = modelOptionsHtml(state.columns.a.model); + selectB.innerHTML = modelOptionsHtml(state.columns.b.model); + selectA.addEventListener("change", () => { + state.columns.a.model = selectA.value; + paintColumnLabels(); + refreshBanner(); + }); + selectB.addEventListener("change", () => { + state.columns.b.model = selectB.value; + paintColumnLabels(); + refreshBanner(); + }); + paintColumnLabels(); + + if (formHost) { + const toggleRow = document.createElement("div"); + toggleRow.className = "lf-compare-toggle-row"; + toggleRow.innerHTML = ` + + Same prompt → two models side by side + `; + formHost.insertBefore(toggleRow, formHost.firstChild); + const checkbox = toggleRow.querySelector("#lf-compare-toggle")!; + checkbox.addEventListener("change", () => { + state.active = checkbox.checked; + if (state.active) { + state.columns.a.model = selectA.value || getActiveModel(); + selectA.value = state.columns.a.model; + paintColumnLabels(); + showStatusMessage("Compare mode on — replies appear in both columns."); + } else { + showStatusMessage("Compare mode off — history kept."); + } + syncChromeVisibility(); + }); + } + + syncChromeVisibility(); + + if (!wrapped) { + wrapped = true; + const original = deps.provider.streamChat.bind(deps.provider); + deps.provider.streamChat = async ( + request: ChatRequest, + onEvent: (event: StreamEvent) => void + ): Promise => { + if (!state.active) { + return original(request, onEvent); + } + + refreshBanner(); + if (paneA) paneA.textContent = ""; + if (paneB) paneB.textContent = ""; + paintColumnLabels(); + + const modelA = state.columns.a.model; + const modelB = state.columns.b.model; + const reqA: ChatRequest = { + ...request, + options: { ...request.options, model: modelA }, + }; + const reqB: ChatRequest = { + ...request, + options: { ...request.options, model: modelB }, + }; + + // Sequential through onEvent so murm-ui appends two assistants in one + // generation (multiple message_start). setMessages cannot run while busy. + try { + await original(reqA, (event) => { + if (paneA) applyDeltaToPane(paneA, event); + onEvent(tagCompareMeta(event, "a", modelA)); + }); + } catch (err) { + if (paneA && !paneA.textContent) { + paneA.textContent = err instanceof Error ? err.message : String(err); + } + throw err; + } + + try { + await original(reqB, (event) => { + if (paneB) applyDeltaToPane(paneB, event); + onEvent(tagCompareMeta(event, "b", modelB)); + }); + } catch (err) { + if (paneB && !paneB.textContent) { + paneB.textContent = err instanceof Error ? err.message : String(err); + } + // Column B failure must not erase column A's history message. + } + }; + } + }, + beforeSubmit: async (request) => { + if (!state.active) return; + refreshBanner(); + return { + options: { + ...request.options, + model: state.columns.a.model, + lfCompare: true, + }, + }; + }, + destroy() { + chrome?.remove(); + mount?.classList.remove("lf-compare-active"); + }, + }; +} diff --git a/webui/src/plugins/discovery-picklist/index.ts b/webui/src/plugins/discovery-picklist/index.ts new file mode 100644 index 0000000..805f299 --- /dev/null +++ b/webui/src/plugins/discovery-picklist/index.ts @@ -0,0 +1,79 @@ +import type { ChatPlugin } from "murm-ui"; +import { + DISCOVERY_RESULTS_EVENT, + type DiscoveryCandidate, +} from "../../providers/tiers/searxng-discovery-tier"; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function candidateRow(candidate: DiscoveryCandidate): string { + const title = escapeHtml(candidate.title); + const url = escapeHtml(candidate.url); + const snippet = escapeHtml(candidate.snippet); + return ` +
  • + ${title} + ${url} + ${snippet ? `

    ${snippet}

    ` : ""} +
  • + `; +} + +/** + * Renders SearXNG discovery results as a dismissible pick list (Q5) between + * the chat history and the composer. Links only — the user decides what to + * open; nothing is automated on their behalf (R39). + */ +export function DiscoveryPicklistPlugin(): ChatPlugin { + let host: HTMLElement | null = null; + let handler: ((event: Event) => void) | null = null; + + return { + name: "discovery-picklist", + onMount(ctx) { + host = document.createElement("div"); + host.className = "lf-discovery-picklist"; + host.hidden = true; + + const layout = ctx.container.querySelector(".mur-chat-layout-wrapper"); + const form = ctx.container.querySelector(".mur-chat-form-container"); + if (layout && form) { + layout.insertBefore(host, form); + } else { + ctx.container.appendChild(host); + } + + handler = (event: Event) => { + const detail = (event as CustomEvent<{ candidates: DiscoveryCandidate[] }>).detail; + const candidates = detail?.candidates ?? []; + if (!host || candidates.length === 0) return; + host.innerHTML = ` +
    + Free chat sites found via your SearXNG (open manually — nothing is automated): + +
    +
      + ${candidates.map((c) => candidateRow(c)).join("")} +
    + `; + host.hidden = false; + host + .querySelector(".lf-discovery-dismiss") + ?.addEventListener("click", () => { + if (host) host.hidden = true; + }); + }; + window.addEventListener(DISCOVERY_RESULTS_EVENT, handler); + }, + destroy() { + if (handler) window.removeEventListener(DISCOVERY_RESULTS_EVENT, handler); + host?.remove(); + }, + }; +} diff --git a/webui/src/plugins/tier-settings/index.ts b/webui/src/plugins/tier-settings/index.ts new file mode 100644 index 0000000..ce92d30 --- /dev/null +++ b/webui/src/plugins/tier-settings/index.ts @@ -0,0 +1,132 @@ +import type { ChatPlugin } from "murm-ui"; +import { defaultProviderTierSettings } from "../../providers/tiers/defaults"; +import { loadProviderTierSettings } from "../../providers/tiers/settings"; +import type { ProviderTierSettings, TierId } from "../../providers/tiers/types"; +import { + TIER_HINTS, + TIER_LABELS, + moveTier, + persistTierSettings, + setTierEnabled, + updateCompanionUrls, +} from "./settings"; + +function renderTierList(settings: ProviderTierSettings): string { + return settings.tiers + .map((tier, index) => { + const label = TIER_LABELS[tier.id] ?? tier.id; + const hint = TIER_HINTS[tier.id] ?? ""; + return ` +
  • +
    + +
    + + +
    +
    +

    ${hint}

    +
  • + `; + }) + .join(""); +} + +export function TierSettingsPlugin(): ChatPlugin { + return { + name: "tier-settings", + onMount() { + window.registerShellPanel?.("tiers", (root) => { + let draft = loadProviderTierSettings(); + + const paint = (): void => { + root.innerHTML = ` +
    +

    Provider tiers

    +
    +

    + Ordered routes we try for each chat. This is the omnifail stack + (which route to attempt), not cloud free-tier rate limits. + Exhausting enabled tiers still fails honestly — we do not promise never-fail. +

    +
      ${renderTierList(draft)}
    + +

    + Opt-in local companion. Off by default. You run it; it lowers pressure on the + public Worker demo. You are responsible for target-site terms — we do not + harvest credentials. +

    + +

    + Opt-in self-hosted search. Empty disables discovery. Respect SearXNG and + target-site terms of service. +

    +
    + + +
    +

    + `; + + const list = root.querySelector("#lf-tier-list"); + list?.addEventListener("click", (event) => { + const target = event.target as HTMLElement; + const up = target.closest("[data-tier-up]"); + const down = target.closest("[data-tier-down]"); + if (up?.dataset.tierUp) { + draft = moveTier(draft, up.dataset.tierUp as TierId, -1); + paint(); + return; + } + if (down?.dataset.tierDown) { + draft = moveTier(draft, down.dataset.tierDown as TierId, 1); + paint(); + } + }); + + list?.addEventListener("change", (event) => { + const input = event.target as HTMLInputElement; + const tierId = input.dataset.tierEnable as TierId | undefined; + if (!tierId || input.type !== "checkbox") return; + draft = setTierEnabled(draft, tierId, input.checked); + }); + + root.querySelector("#lf-tier-reset")?.addEventListener("click", () => { + draft = persistTierSettings(defaultProviderTierSettings()); + paint(); + const status = root.querySelector("#lf-tier-status"); + if (status) status.textContent = "Restored zero-config defaults."; + }); + + root.querySelector("#lf-tier-save")?.addEventListener("click", () => { + const webRunnerUrl = + root.querySelector("#lf-web-runner-url")?.value ?? ""; + const searxngUrl = + root.querySelector("#lf-searxng-url")?.value ?? ""; + draft = updateCompanionUrls(draft, { webRunnerUrl, searxngUrl }); + draft = persistTierSettings(draft); + paint(); + const status = root.querySelector("#lf-tier-status"); + if (status) { + status.textContent = `Saved order: ${draft.tiers + .filter((t) => t.enabled) + .map((t) => t.id) + .join(" → ") || "(none enabled)"}`; + } + }); + }; + + paint(); + }); + }, + }; +} diff --git a/webui/src/plugins/tier-settings/settings.test.ts b/webui/src/plugins/tier-settings/settings.test.ts new file mode 100644 index 0000000..f011ad7 --- /dev/null +++ b/webui/src/plugins/tier-settings/settings.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatRequest, StreamEvent } from "murm-ui"; +import { defaultProviderTierSettings } from "../../providers/tiers/defaults"; +import { TierOrchestrator } from "../../providers/tiers/orchestrator"; +import { loadProviderTierSettings } from "../../providers/tiers/settings"; +import { + moveTier, + persistTierSettings, + setTierEnabled, + updateCompanionUrls, +} from "./settings"; + +const baseRequest: ChatRequest = { + messages: [{ id: "1", role: "user", blocks: [{ type: "text", text: "hi" }] }], + options: {}, + signal: new AbortController().signal, +}; + +describe("tier-settings helpers", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("moves a tier up and preserves the new order through persistence", () => { + const start = defaultProviderTierSettings(); + // Default order: quality_api, web_ui, searxng_discovery, proxy_failover + const moved = moveTier(start, "proxy_failover", -1); + expect(moved.tiers.map((t) => t.id)).toEqual([ + "quality_api", + "web_ui", + "proxy_failover", + "searxng_discovery", + ]); + + const saved = persistTierSettings(moved); + expect(loadProviderTierSettings().tiers.map((t) => t.id)).toEqual( + saved.tiers.map((t) => t.id) + ); + }); + + it("persists enable toggles and companion URLs", () => { + let settings = defaultProviderTierSettings(); + settings = setTierEnabled(settings, "web_ui", true); + settings = updateCompanionUrls(settings, { + webRunnerUrl: " http://127.0.0.1:8788 ", + searxngUrl: "http://127.0.0.1:8080", + }); + const saved = persistTierSettings(settings); + expect(saved.tiers.find((t) => t.id === "web_ui")?.enabled).toBe(true); + expect(saved.webRunnerUrl).toBe("http://127.0.0.1:8788"); + expect(saved.searxngUrl).toBe("http://127.0.0.1:8080"); + }); + + it("reorder → localStorage → orchestrator attempt order matches (R36)", async () => { + // Put proxy_failover first, then quality_api. Both enabled. + const reordered = persistTierSettings({ + ...defaultProviderTierSettings(), + tiers: [ + { id: "proxy_failover", enabled: true }, + { id: "quality_api", enabled: true }, + { id: "web_ui", enabled: false }, + { id: "searxng_discovery", enabled: false }, + ], + }); + expect(reordered.tiers.map((t) => t.id).slice(0, 2)).toEqual([ + "proxy_failover", + "quality_api", + ]); + + const calls: string[] = []; + const proxyFailover = vi.fn(async (_req, onEvent: (e: StreamEvent) => void) => { + calls.push("proxy_failover"); + onEvent({ type: "text_delta", delta: "from proxy" }); + }); + const qualityApi = vi.fn(async () => { + calls.push("quality_api"); + }); + + const orchestrator = new TierOrchestrator({ + qualityApi, + webUi: vi.fn(), + searxngDiscovery: vi.fn(), + proxyFailover, + }); + + await orchestrator.streamChat(baseRequest, () => {}); + + expect(calls).toEqual(["proxy_failover"]); + expect(proxyFailover).toHaveBeenCalledOnce(); + expect(qualityApi).not.toHaveBeenCalled(); + }); + + it("does not move past list ends", () => { + const start = defaultProviderTierSettings(); + expect(moveTier(start, "quality_api", -1)).toEqual(start); + expect(moveTier(start, "proxy_failover", 1).tiers.map((t) => t.id)).toEqual( + start.tiers.map((t) => t.id) + ); + }); +}); diff --git a/webui/src/plugins/tier-settings/settings.ts b/webui/src/plugins/tier-settings/settings.ts new file mode 100644 index 0000000..48c9bda --- /dev/null +++ b/webui/src/plugins/tier-settings/settings.ts @@ -0,0 +1,69 @@ +import { + loadProviderTierSettings, + saveProviderTierSettings, +} from "../../providers/tiers/settings"; +import type { ProviderTierSettings, TierEntry, TierId } from "../../providers/tiers/types"; +import { normalizeTierSettings } from "../../providers/tiers/defaults"; + +export const TIER_LABELS: Record = { + quality_api: "Direct / BYOK routes", + web_ui: "Local web-UI runner (opt-in)", + searxng_discovery: "SearXNG discovery (opt-in)", + proxy_failover: "Cloud proxy failover", +}; + +export const TIER_HINTS: Record = { + quality_api: + "Uses API keys stored in this browser. Skips when no key matches the selected model.", + web_ui: + "Optional local companion that drives a browser chat UI. Off by default — you run it.", + searxng_discovery: + "Optional self-hosted SearXNG. Suggests free chat URLs when higher tiers fail.", + proxy_failover: + "Public Worker / Render endpoints from Server settings. Serves zero-config visitors.", +}; + +/** Move a tier by delta (−1 = up, +1 = down). Returns a new settings object. */ +export function moveTier( + settings: ProviderTierSettings, + tierId: TierId, + delta: -1 | 1 +): ProviderTierSettings { + const tiers = settings.tiers.map((t) => ({ ...t })); + const from = tiers.findIndex((t) => t.id === tierId); + if (from < 0) return settings; + const to = from + delta; + if (to < 0 || to >= tiers.length) return settings; + const [entry] = tiers.splice(from, 1); + tiers.splice(to, 0, entry); + return normalizeTierSettings({ ...settings, tiers }); +} + +export function setTierEnabled( + settings: ProviderTierSettings, + tierId: TierId, + enabled: boolean +): ProviderTierSettings { + const tiers: TierEntry[] = settings.tiers.map((t) => + t.id === tierId ? { ...t, enabled } : { ...t } + ); + return normalizeTierSettings({ ...settings, tiers }); +} + +export function updateCompanionUrls( + settings: ProviderTierSettings, + urls: { webRunnerUrl?: string; searxngUrl?: string } +): ProviderTierSettings { + return normalizeTierSettings({ + ...settings, + webRunnerUrl: urls.webRunnerUrl ?? settings.webRunnerUrl, + searxngUrl: urls.searxngUrl ?? settings.searxngUrl, + }); +} + +/** Round-trip helpers used by the panel save path and tests. */ +export function persistTierSettings(settings: ProviderTierSettings): ProviderTierSettings { + const normalized = normalizeTierSettings(settings); + saveProviderTierSettings(normalized); + return loadProviderTierSettings(); +} diff --git a/webui/src/providers/FailoverProvider.tiers.test.ts b/webui/src/providers/FailoverProvider.tiers.test.ts new file mode 100644 index 0000000..355ec95 --- /dev/null +++ b/webui/src/providers/FailoverProvider.tiers.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatRequest, StreamEvent } from "murm-ui"; +import type { AppConfig } from "../config"; + +vi.mock("../turnstile-session", () => ({ + ensureTurnstileToken: async () => "", +})); +vi.mock("../plugins/status-strip", () => ({ + showRateLimitBanner: () => {}, +})); +vi.mock("../analytics", () => ({ + trackChatCompletionSuccess: () => {}, +})); + +import { FailoverProvider } from "./FailoverProvider"; + +const config: AppConfig = { + endpoints: ["https://proxy.test"], + guestToken: "guest", + defaultModel: "free", + catalogUrl: "", + providerUrlsUrl: "", + maxTokens: 256, +}; + +const PNG_DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANS"; + +function request(): ChatRequest { + return { + messages: [{ id: "1", role: "user", blocks: [{ type: "text", text: "hi" }] }], + // Pin the model so resolveModel does not consult session state. + options: { model: "free" }, + signal: new AbortController().signal, + }; +} + +function imageRequest(model: string): ChatRequest { + return { + messages: [ + { + id: "1", + role: "user", + blocks: [ + { type: "text", text: "what is this" }, + { type: "file", mimeType: "image/png", name: "x.png", data: PNG_DATA_URL }, + ], + }, + ], + options: { model }, + signal: new AbortController().signal, + }; +} + +function sseResponse(chunks: string[], init?: ResponseInit): Response { + const body = chunks.map((c) => `data: ${c}\n\n`).join("") + "data: [DONE]\n\n"; + return new Response(body, { status: 200, ...init }); +} + +function collectDeltas(events: StreamEvent[]): string { + return events + .filter((e): e is Extract => e.type === "text_delta") + .map((e) => e.delta) + .join(""); +} + +describe("FailoverProvider tier routing", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("zero-config (no keys) skips quality_api and is served by proxy_failover", async () => { + const fetchMock = vi.fn(async () => + sseResponse([ + JSON.stringify({ choices: [{ delta: { content: "hello" } }] }), + JSON.stringify({ choices: [{ finish_reason: "stop" }] }), + ]) + ); + vi.stubGlobal("fetch", fetchMock); + + const provider = new FailoverProvider(config); + const events: StreamEvent[] = []; + await provider.streamChat(request(), (e) => events.push(e)); + + expect(collectDeltas(events)).toBe("hello"); + expect(provider.getLastRoute()).toMatch(/^proxy\//); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("surfaces attempted tiers when every enabled tier fails (R40)", async () => { + const fetchMock = vi.fn(async () => new Response("upstream boom", { status: 500 })); + vi.stubGlobal("fetch", fetchMock); + + const provider = new FailoverProvider(config); + await expect(provider.streamChat(request(), () => {})).rejects.toMatchObject({ + message: expect.stringContaining("proxy_failover"), + }); + }); + + it("searxng discovery suggests links then chain falls through to proxy (R39/R43)", async () => { + const { STORAGE_KEYS, saveJson } = await import("../storage-keys"); + saveJson(STORAGE_KEYS.providerTiers, { + tiers: [ + { id: "quality_api", enabled: true }, + { id: "web_ui", enabled: false }, + { id: "searxng_discovery", enabled: true }, + { id: "proxy_failover", enabled: true }, + ], + webRunnerUrl: "", + searxngUrl: "http://searx.test", + }); + + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("http://searx.test")) { + return new Response( + JSON.stringify({ + results: [ + { url: "https://chat.example.com", title: "Example AI chat", content: "free chat" }, + ], + }), + { status: 200 } + ); + } + return sseResponse([ + JSON.stringify({ choices: [{ delta: { content: "proxy answer" } }] }), + JSON.stringify({ choices: [{ finish_reason: "stop" }] }), + ]); + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = new FailoverProvider(config); + const events: StreamEvent[] = []; + await provider.streamChat(request(), (e) => events.push(e)); + + expect(collectDeltas(events)).toBe("proxy answer"); + expect(provider.getLastRoute()).toMatch(/^proxy\//); + const urls = fetchMock.mock.calls.map((c) => String(c[0])); + expect(urls.some((u) => u.startsWith("http://searx.test/search"))).toBe(true); + }); + + it("blocks an image attachment on a non-vision model (R29)", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const provider = new FailoverProvider(config); + provider.setCatalog([{ id: "text/model", supports_vision: false }], {}); + + await expect( + provider.streamChat(imageRequest("text/model"), () => {}) + ).rejects.toMatchObject({ kind: "vision_unsupported" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("sends an image_url part to the proxy for a vision model (R28)", async () => { + const fetchMock = vi.fn(async () => + sseResponse([ + JSON.stringify({ choices: [{ delta: { content: "a cat" } }] }), + JSON.stringify({ choices: [{ finish_reason: "stop" }] }), + ]) + ); + vi.stubGlobal("fetch", fetchMock); + + const provider = new FailoverProvider(config); + provider.setCatalog([{ id: "vision/model", supports_vision: true }], {}); + + const events: StreamEvent[] = []; + await provider.streamChat(imageRequest("vision/model"), (e) => events.push(e)); + + expect(collectDeltas(events)).toBe("a cat"); + const sent = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); + const userContent = sent.messages.at(-1).content; + expect(Array.isArray(userContent)).toBe(true); + expect(userContent).toContainEqual({ + type: "image_url", + image_url: { url: PNG_DATA_URL }, + }); + }); +}); diff --git a/webui/src/providers/FailoverProvider.ts b/webui/src/providers/FailoverProvider.ts index 2605664..707afe8 100644 --- a/webui/src/providers/FailoverProvider.ts +++ b/webui/src/providers/FailoverProvider.ts @@ -1,6 +1,6 @@ import { trackChatCompletionSuccess } from "../analytics"; import { getActiveModel } from "../model-selection"; -import type { ChatProvider, ChatRequest, Message, StreamEvent } from "murm-ui"; +import type { ChatProvider, ChatRequest, StreamEvent } from "murm-ui"; import type { AppConfig } from "../config"; import { readRuntimeConfig } from "../config"; import type { CatalogEntry } from "./browser-router"; @@ -8,14 +8,32 @@ import { RETRYABLE, chatWithBrowserFallback, loadKeys, - shouldFallbackToProxy, shouldTryBrowser, } from "./browser-router"; import { ensureTurnstileToken } from "../turnstile-session"; import { showRateLimitBanner } from "../plugins/status-strip"; import { ChatRouteError, mapHttpError, mapProxyChainFailure, type RateLimitInfo, type RateLimitScope } from "./errors"; +import { + messagesHaveImage, + messagesToOpenAi, + messagesToPlainText, + modelSupportsVision, +} from "./message-openai"; import { setLastCompletionMeta, type CompletionMeta } from "./routing-metadata"; import { emitOpenAiSseAsStreamEvents, emitTextAsStreamEvents } from "./sse"; +import { + TierOrchestrator, + qualityApiTierUnavailable, + searxngTierUnavailable, + webUiTierUnavailable, +} from "./tiers/orchestrator"; +import { + broadcastDiscoveryResults, + searchFreeChatCandidates, +} from "./tiers/searxng-discovery-tier"; +import { loadProviderTierSettings } from "./tiers/settings"; +import { TierOrchestratorError, TierSkipError } from "./tiers/types"; +import { streamFromWebRunner } from "./tiers/web-ui-tier"; type StatusListener = (status: string) => void; @@ -26,16 +44,6 @@ function endpointUrl(base: string): string { : `${trimmed}/v1/chat/completions`; } -function messagesToOpenAi(messages: readonly Message[]): { role: string; content: string }[] { - return messages.map((m) => { - const text = m.blocks - .filter((b) => b.type === "text") - .map((b) => (b.type === "text" ? b.text : "")) - .join(""); - return { role: m.role, content: text }; - }); -} - function readRoutingHeaders(res: Response): Pick { const modelHeader = res.headers.get("x-litellm-model-name") || @@ -240,92 +248,189 @@ export class FailoverProvider implements ChatProvider { throw mapProxyChainFailure(lastError, lastRateLimit); } - async streamChat(request: ChatRequest, onEvent: (event: StreamEvent) => void): Promise { + private buildChatBody(request: ChatRequest): { + config: AppConfig; + model: string; + body: Record; + plainMessages: { role: string; content: string }[]; + hasImage: boolean; + } { const config = this.getRuntimeConfig(); const model = this.resolveModel(request); - const openAiMessages = messagesToOpenAi(request.messages); + // Proxy body carries multimodal content parts; browser/BYOK routes use the + // text-only projection (they cannot forward inline images yet). const body = { model, - messages: openAiMessages, + messages: messagesToOpenAi(request.messages), max_tokens: request.options.max_tokens ?? config.maxTokens, }; - - const keys = loadKeys(); - const userKeys = keys; - - const tryBrowser = async (onEv: (event: StreamEvent) => void): Promise => { - const result = await chatWithBrowserFallback({ - model, - messages: openAiMessages, - maxTokens: body.max_tokens as number, - catalog: this.catalog, - providerUrls: this.providerUrls, - keys: userKeys, - onStatus: (s) => this.setStatus(s), - }); - this.lastRoute = result.route; - window.LLM_FALLBACKS_ROUTE = result.route; - setLastCompletionMeta({ - endpoint: result.route, - fallbackCount: 0, - }); - emitTextAsStreamEvents(result.content, onEv); + return { + config, + model, + body, + plainMessages: messagesToPlainText(request.messages), + hasImage: messagesHaveImage(request.messages), }; + } - if (config.endpoints.length) { - try { - await this.streamWithCompletionTracking( - (onEv) => this.streamProxyFallback(body, config, onEv, request.signal), - onEvent - ); - return; - } catch (proxyErr) { - if (!shouldTryBrowser(model, this.catalog, userKeys)) throw proxyErr; - this.setStatus("cloud proxy unavailable — trying optional browser route …"); - } + // quality_api tier: direct/BYOK provider routes only. Disjoint from + // proxy_failover (KTD7) — this tier never touches the cloud proxy chain, so + // a single request is not attempted twice against the same endpoint. Without + // a usable key it skips, letting the orchestrator advance to proxy_failover. + private async streamQualityApiRoute( + request: ChatRequest, + onEvent: (event: StreamEvent) => void + ): Promise { + const { model, body, plainMessages, hasImage } = this.buildChatBody(request); + const userKeys = loadKeys(); + + // Direct BYOK routes cannot forward inline images yet — skip so the + // orchestrator advances to a proxy tier rather than dropping the image. + if (hasImage) { + throw new TierSkipError( + "quality_api", + "Direct BYOK routes do not support image attachments yet — using the proxy tier." + ); } - if (model !== "free" && !shouldTryBrowser(model, this.catalog, userKeys)) { - if (config.endpoints.length) { - await this.streamWithCompletionTracking( - (onEv) => this.streamProxyFallback({ ...body, model: "free" }, config, onEv, request.signal), - onEvent + if (!shouldTryBrowser(model, this.catalog, userKeys)) { + throw qualityApiTierUnavailable(); + } + + const result = await chatWithBrowserFallback({ + model, + messages: plainMessages, + maxTokens: body.max_tokens as number, + catalog: this.catalog, + providerUrls: this.providerUrls, + keys: userKeys, + onStatus: (s) => this.setStatus(s), + }); + this.lastRoute = result.route; + window.LLM_FALLBACKS_ROUTE = this.lastRoute; + setLastCompletionMeta({ + endpoint: result.route, + fallbackCount: 0, + }); + emitTextAsStreamEvents(result.content, onEvent); + } + + private async streamProxyFailoverRoute( + request: ChatRequest, + onEvent: (event: StreamEvent) => void + ): Promise { + const { config, body } = this.buildChatBody(request); + await this.streamProxyFallback(body, config, onEvent, request.signal); + } + + private async streamWebUiRoute( + request: ChatRequest, + onEvent: (event: StreamEvent) => void + ): Promise { + const settings = loadProviderTierSettings(); + if (!settings.webRunnerUrl) { + throw webUiTierUnavailable(); + } + const { model, body, plainMessages, hasImage } = this.buildChatBody(request); + if (hasImage) { + throw new TierSkipError( + "web_ui", + "The web runner does not support image attachments — using the proxy tier." + ); + } + this.setStatus(`web runner: ${settings.webRunnerUrl} …`); + let metaSet = false; + await streamFromWebRunner({ + runnerUrl: settings.webRunnerUrl, + model, + messages: plainMessages, + maxTokens: body.max_tokens as number, + signal: request.signal, + onEvent: (event) => { + // Record the route once the runner actually starts streaming, so a + // pre-stream failure never leaves stale web_ui routing metadata. + if (!metaSet) { + metaSet = true; + this.lastRoute = `web_ui/${settings.webRunnerUrl}`; + window.LLM_FALLBACKS_ROUTE = this.lastRoute; + setLastCompletionMeta({ endpoint: this.lastRoute, fallbackCount: 0 }); + } + onEvent(event); + }, + }); + } + + private async streamSearxngDiscoveryRoute( + request: ChatRequest, + _onEvent: (event: StreamEvent) => void + ): Promise { + const settings = loadProviderTierSettings(); + if (!settings.searxngUrl) { + throw searxngTierUnavailable(); + } + this.setStatus("searxng: searching for free chat sites …"); + const candidates = await searchFreeChatCandidates({ + searxngUrl: settings.searxngUrl, + signal: request.signal, + }); + broadcastDiscoveryResults(candidates); + // Discovery suggests links (R39) — it cannot answer the prompt itself. + // Record a descriptive attempt so the orchestrator moves to the next tier. + throw new Error( + `SearXNG found ${candidates.length} candidate chat site${ + candidates.length === 1 ? "" : "s" + } — see suggestions below the chat.` + ); + } + + async streamChat(request: ChatRequest, onEvent: (event: StreamEvent) => void): Promise { + // Vision guard (R29): never silently drop attachments. If the turn carries + // an image but the selected model is not vision-capable, block clearly and + // point at the vision-badged models in the explorer (R30). + if (messagesHaveImage(request.messages)) { + const model = this.resolveModel(request); + if (!modelSupportsVision(model, this.catalog)) { + throw new ChatRouteError( + "vision_unsupported", + `"${model}" can't read images. Pick a vision-capable model (filter by vision in the model explorer) or remove the attachment.` ); - return; } - throw new Error( - "Selected model requires an API key for its provider. Choose free or add the provider key in Settings." - ); } - if (shouldTryBrowser(model, this.catalog, userKeys)) { - try { - await this.streamWithCompletionTracking((onEv) => tryBrowser(onEv), onEvent); - return; - } catch (browserErr) { - const err = browserErr instanceof Error ? browserErr : new Error(String(browserErr)); - if (config.endpoints.length && shouldFallbackToProxy(err)) { - this.setStatus("browser route failed — retrying cloud proxy …"); - await this.streamWithCompletionTracking( - (onEv) => this.streamProxyFallback(body, config, onEv, request.signal), - onEvent - ); - return; - } - throw err; + const orchestrator = new TierOrchestrator({ + qualityApi: (req, onEv) => + this.streamWithCompletionTracking((inner) => this.streamQualityApiRoute(req, inner), onEv), + webUi: (req, onEv) => + this.streamWithCompletionTracking((inner) => this.streamWebUiRoute(req, inner), onEv), + searxngDiscovery: (req, onEv) => + this.streamWithCompletionTracking( + (inner) => this.streamSearxngDiscoveryRoute(req, inner), + onEv + ), + proxyFailover: (req, onEv) => + this.streamWithCompletionTracking((inner) => this.streamProxyFailoverRoute(req, inner), onEv), + }); + + try { + await orchestrator.streamChat(request, onEvent); + } catch (err) { + if (err instanceof TierOrchestratorError) { + throw this.mapTierFailure(err); } + throw err; } + } - if (config.endpoints.length) { - await this.streamWithCompletionTracking( - (onEv) => this.streamProxyFallback(body, config, onEv, request.signal), - onEvent + // Preserve the ChatRouteError taxonomy (rate-limit / quota / cold-start) while + // surfacing which tiers were tried and their last error (R40). A no-attempt + // failure means every tier skipped or none were enabled. + private mapTierFailure(err: TierOrchestratorError): Error { + if (err.attempts.length === 0) { + return mapProxyChainFailure( + "No chat routes are available yet. Enable a provider tier in Settings, or wait for the demo proxy to finish deploying." ); - return; } - - throw mapProxyChainFailure( - "No chat routes are available yet. The demo proxy is still deploying — refresh in a minute." - ); + const summary = err.attempts.map((a) => `${a.tier} → ${a.error}`).join(" | "); + return mapProxyChainFailure(summary); } } diff --git a/webui/src/providers/message-openai.test.ts b/webui/src/providers/message-openai.test.ts new file mode 100644 index 0000000..533584d --- /dev/null +++ b/webui/src/providers/message-openai.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { Message } from "murm-ui"; +import type { CatalogEntry } from "./browser-router"; +import { + getVisionCatalogModels, + messagesHaveImage, + messagesToOpenAi, + messagesToPlainText, + modelSupportsVision, +} from "./message-openai"; + +const PNG_DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANS"; + +function textMessage(role: Message["role"], text: string): Message { + return { id: `${role}-1`, role, blocks: [{ id: "b1", type: "text", text }] }; +} + +function imageMessage(text: string): Message { + return { + id: "user-img", + role: "user", + blocks: [ + { id: "t1", type: "text", text }, + { id: "f1", type: "file", mimeType: "image/png", name: "shot.png", data: PNG_DATA_URL }, + ], + }; +} + +const catalog: CatalogEntry[] = [ + { id: "vision/model", supports_vision: true }, + { id: "text/model", supports_vision: false }, +]; + +describe("messagesToOpenAi", () => { + it("keeps text-only turns as string content", () => { + const result = messagesToOpenAi([textMessage("user", "hello")]); + expect(result).toEqual([{ role: "user", content: "hello" }]); + }); + + it("maps an image file block to an image_url content part", () => { + const result = messagesToOpenAi([imageMessage("describe this")]); + expect(result).toHaveLength(1); + const content = result[0].content; + expect(Array.isArray(content)).toBe(true); + const parts = content as { type: string }[]; + expect(parts).toContainEqual({ type: "text", text: "describe this" }); + expect(parts).toContainEqual({ type: "image_url", image_url: { url: PNG_DATA_URL } }); + }); + + it("omits the text part when the turn is image-only", () => { + const result = messagesToOpenAi([ + { id: "u", role: "user", blocks: [{ id: "f", type: "file", mimeType: "image/png", data: PNG_DATA_URL }] }, + ]); + const parts = result[0].content as { type: string }[]; + expect(parts).toEqual([{ type: "image_url", image_url: { url: PNG_DATA_URL } }]); + }); +}); + +describe("messagesToPlainText", () => { + it("drops image blocks and keeps text", () => { + const result = messagesToPlainText([imageMessage("caption")]); + expect(result).toEqual([{ role: "user", content: "caption" }]); + }); +}); + +describe("image + vision helpers", () => { + it("detects image attachments", () => { + expect(messagesHaveImage([imageMessage("x")])).toBe(true); + expect(messagesHaveImage([textMessage("user", "x")])).toBe(false); + }); + + it("resolves vision capability from the catalog", () => { + expect(modelSupportsVision("vision/model", catalog)).toBe(true); + expect(modelSupportsVision("text/model", catalog)).toBe(false); + // Unknown / alias models (e.g. "free") are treated as non-vision. + expect(modelSupportsVision("free", catalog)).toBe(false); + }); + + it("lists vision-capable catalog models (R30)", () => { + expect(getVisionCatalogModels(catalog).map((e) => e.id)).toEqual(["vision/model"]); + }); +}); diff --git a/webui/src/providers/message-openai.ts b/webui/src/providers/message-openai.ts new file mode 100644 index 0000000..abc70a0 --- /dev/null +++ b/webui/src/providers/message-openai.ts @@ -0,0 +1,82 @@ +import type { Message } from "murm-ui"; +import type { CatalogEntry } from "./browser-router"; + +// OpenAI-compatible chat content. A string for text-only turns; an array of +// parts for multimodal turns (text + inline image data URLs). LiteLLM / the +// proxy accept `image_url` parts when the resolved model supports vision. +export type OpenAiContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string } }; + +export interface OpenAiMessage { + role: string; + content: string | OpenAiContentPart[]; +} + +interface ImageFileBlock { + type: "file"; + mimeType: string; + name?: string; + data: string; +} + +function isImageFileBlock(block: { type: string }): block is ImageFileBlock { + return ( + block.type === "file" && + typeof (block as ImageFileBlock).mimeType === "string" && + (block as ImageFileBlock).mimeType.startsWith("image/") && + typeof (block as ImageFileBlock).data === "string" + ); +} + +function messageText(message: Message): string { + return message.blocks + .filter((b) => b.type === "text") + .map((b) => (b.type === "text" ? b.text : "")) + .join(""); +} + +export function messageHasImage(message: Message): boolean { + return message.blocks.some((b) => isImageFileBlock(b)); +} + +export function messagesHaveImage(messages: readonly Message[]): boolean { + return messages.some((m) => messageHasImage(m)); +} + +// Multimodal projection: text-only turns stay strings; turns with image file +// blocks become an OpenAI content-part array so images are never dropped. +export function messagesToOpenAi(messages: readonly Message[]): OpenAiMessage[] { + return messages.map((message) => { + const text = messageText(message); + const images = message.blocks.filter((b): b is ImageFileBlock => isImageFileBlock(b)); + if (images.length === 0) { + return { role: message.role, content: text }; + } + const parts: OpenAiContentPart[] = []; + if (text) parts.push({ type: "text", text }); + for (const image of images) { + parts.push({ type: "image_url", image_url: { url: image.data } }); + } + return { role: message.role, content: parts }; + }); +} + +// Text-only projection for routes that cannot carry inline images yet +// (browser/BYOK direct calls). Image blocks are omitted here on purpose; the +// vision guard and quality_api image skip ensure such turns never reach a +// route that would silently drop the attachment. +export function messagesToPlainText( + messages: readonly Message[] +): { role: string; content: string }[] { + return messages.map((message) => ({ role: message.role, content: messageText(message) })); +} + +export function modelSupportsVision(modelId: string, catalog: readonly CatalogEntry[]): boolean { + const entry = catalog.find((e) => e.id === modelId); + return entry?.supports_vision === true; +} + +export function getVisionCatalogModels(catalog: readonly CatalogEntry[]): CatalogEntry[] { + return catalog.filter((e) => e.supports_vision === true); +} diff --git a/webui/src/providers/tiers/defaults.test.ts b/webui/src/providers/tiers/defaults.test.ts new file mode 100644 index 0000000..b148a0f --- /dev/null +++ b/webui/src/providers/tiers/defaults.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { defaultProviderTierSettings, normalizeTierSettings } from "./defaults"; +import type { ProviderTierSettings } from "./types"; + +describe("tier defaults", () => { + it("enables quality_api and proxy_failover by default (zero-config routing)", () => { + const settings = defaultProviderTierSettings(); + const enabled = settings.tiers.filter((t) => t.enabled).map((t) => t.id); + expect(enabled).toContain("quality_api"); + expect(enabled).toContain("proxy_failover"); + expect(enabled).not.toContain("web_ui"); + expect(enabled).not.toContain("searxng_discovery"); + }); +}); + +describe("normalizeTierSettings", () => { + it("preserves the user's saved tier order", () => { + const raw: ProviderTierSettings = { + tiers: [ + { id: "proxy_failover", enabled: true }, + { id: "quality_api", enabled: false }, + { id: "searxng_discovery", enabled: true }, + { id: "web_ui", enabled: false }, + ], + webRunnerUrl: "", + searxngUrl: "", + }; + const normalized = normalizeTierSettings(raw); + expect(normalized.tiers.map((t) => t.id)).toEqual([ + "proxy_failover", + "quality_api", + "searxng_discovery", + "web_ui", + ]); + expect(normalized.tiers[0].enabled).toBe(true); + expect(normalized.tiers[1].enabled).toBe(false); + }); + + it("appends omitted tiers at their default-enabled state", () => { + const raw: ProviderTierSettings = { + tiers: [{ id: "proxy_failover", enabled: false }], + webRunnerUrl: "", + searxngUrl: "", + }; + const normalized = normalizeTierSettings(raw); + expect(normalized.tiers.map((t) => t.id)).toEqual([ + "proxy_failover", + "quality_api", + "web_ui", + "searxng_discovery", + ]); + // Explicit stored value wins over the default. + expect(normalized.tiers.find((t) => t.id === "proxy_failover")?.enabled).toBe(false); + // Omitted quality_api falls back to its default-enabled state. + expect(normalized.tiers.find((t) => t.id === "quality_api")?.enabled).toBe(true); + }); + + it("dedupes and drops unknown tier ids", () => { + const raw = { + tiers: [ + { id: "quality_api", enabled: true }, + { id: "quality_api", enabled: false }, + { id: "bogus_tier", enabled: true }, + ], + webRunnerUrl: "", + searxngUrl: "", + } as unknown as ProviderTierSettings; + const normalized = normalizeTierSettings(raw); + const ids = normalized.tiers.map((t) => t.id); + expect(ids.filter((id) => id === "quality_api")).toHaveLength(1); + expect(ids).not.toContain("bogus_tier"); + // First occurrence wins. + expect(normalized.tiers.find((t) => t.id === "quality_api")?.enabled).toBe(true); + }); +}); diff --git a/webui/src/providers/tiers/defaults.ts b/webui/src/providers/tiers/defaults.ts new file mode 100644 index 0000000..f7cbf1e --- /dev/null +++ b/webui/src/providers/tiers/defaults.ts @@ -0,0 +1,52 @@ +import type { ProviderTierSettings, TierEntry, TierId } from "./types"; + +export const TIER_IDS: TierId[] = [ + "quality_api", + "web_ui", + "searxng_discovery", + "proxy_failover", +]; + +// Zero-config default: BYOK direct routes first (when keys exist), then the +// public proxy chain. quality_api skips instantly without keys, so a keyless +// visitor is served by proxy_failover — matching pre-4B proxy-first behavior. +export const DEFAULT_TIER_ENTRIES: TierEntry[] = [ + { id: "quality_api", enabled: true }, + { id: "web_ui", enabled: false }, + { id: "searxng_discovery", enabled: false }, + { id: "proxy_failover", enabled: true }, +]; + +const DEFAULT_ENABLED_BY_ID = new Map( + DEFAULT_TIER_ENTRIES.map((t) => [t.id, t.enabled]) +); + +export function defaultProviderTierSettings(): ProviderTierSettings { + return { + tiers: DEFAULT_TIER_ENTRIES.map((t) => ({ ...t })), + webRunnerUrl: "", + searxngUrl: "", + }; +} + +// Preserve the user's saved tier order (R36) — dedupe and drop unknown ids, +// then append any tier the stored settings omitted at its default-enabled +// state. Rebuilding from a fixed TIER_IDS order would silently discard the +// ordering the settings panel writes. +export function normalizeTierSettings(raw: ProviderTierSettings): ProviderTierSettings { + const seen = new Set(); + const tiers: TierEntry[] = []; + for (const entry of raw.tiers ?? []) { + if (!DEFAULT_ENABLED_BY_ID.has(entry.id) || seen.has(entry.id)) continue; + seen.add(entry.id); + tiers.push({ id: entry.id, enabled: !!entry.enabled }); + } + for (const id of TIER_IDS) { + if (!seen.has(id)) tiers.push({ id, enabled: DEFAULT_ENABLED_BY_ID.get(id) ?? false }); + } + return { + tiers, + webRunnerUrl: raw.webRunnerUrl?.trim() ?? "", + searxngUrl: raw.searxngUrl?.trim() ?? "", + }; +} diff --git a/webui/src/providers/tiers/orchestrator.test.ts b/webui/src/providers/tiers/orchestrator.test.ts new file mode 100644 index 0000000..ad918fe --- /dev/null +++ b/webui/src/providers/tiers/orchestrator.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatRequest, StreamEvent } from "murm-ui"; +import { STORAGE_KEYS, saveJson } from "../../storage-keys"; +import { defaultProviderTierSettings } from "./defaults"; +import { + TierOrchestrator, + searxngTierUnavailable, + webUiTierUnavailable, +} from "./orchestrator"; +import { TierOrchestratorError, TierSkipError } from "./types"; + +const baseRequest: ChatRequest = { + messages: [{ id: "1", role: "user", blocks: [{ type: "text", text: "hi" }] }], + options: {}, + signal: new AbortController().signal, +}; + +describe("TierOrchestrator", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("runs enabled tiers in saved order (quality_api before proxy_failover)", async () => { + saveJson(STORAGE_KEYS.providerTiers, defaultProviderTierSettings()); + const calls: string[] = []; + const qualityApi = vi.fn(async (_req, onEvent: (e: StreamEvent) => void) => { + calls.push("quality_api"); + onEvent({ type: "text_delta", delta: "ok" }); + }); + const proxyFailover = vi.fn(async () => { + calls.push("proxy_failover"); + }); + const orchestrator = new TierOrchestrator({ + qualityApi, + webUi: vi.fn(), + searxngDiscovery: vi.fn(), + proxyFailover, + }); + + await orchestrator.streamChat(baseRequest, () => {}); + + expect(qualityApi).toHaveBeenCalledOnce(); + expect(proxyFailover).not.toHaveBeenCalled(); + expect(calls).toEqual(["quality_api"]); + }); + + it("honors a reordered tier list (proxy_failover first)", async () => { + saveJson(STORAGE_KEYS.providerTiers, { + ...defaultProviderTierSettings(), + tiers: [ + { id: "proxy_failover", enabled: true }, + { id: "quality_api", enabled: true }, + { id: "web_ui", enabled: false }, + { id: "searxng_discovery", enabled: false }, + ], + }); + const qualityApi = vi.fn(); + const proxyFailover = vi.fn(async (_req, onEvent: (e: StreamEvent) => void) => { + onEvent({ type: "text_delta", delta: "proxy first" }); + }); + const orchestrator = new TierOrchestrator({ + qualityApi, + webUi: vi.fn(), + searxngDiscovery: vi.fn(), + proxyFailover, + }); + + await orchestrator.streamChat(baseRequest, () => {}); + + expect(proxyFailover).toHaveBeenCalledOnce(); + expect(qualityApi).not.toHaveBeenCalled(); + }); + + it("skips disabled web_ui tier", async () => { + saveJson(STORAGE_KEYS.providerTiers, defaultProviderTierSettings()); + const webUi = vi.fn(async () => { + throw webUiTierUnavailable(); + }); + const qualityApi = vi.fn(async (_req, onEvent: (e: StreamEvent) => void) => { + onEvent({ type: "text_delta", delta: "ok" }); + }); + const orchestrator = new TierOrchestrator({ + qualityApi, + webUi, + searxngDiscovery: vi.fn(), + proxyFailover: vi.fn(), + }); + + await orchestrator.streamChat(baseRequest, () => {}); + + expect(webUi).not.toHaveBeenCalled(); + }); + + it("falls through to proxy_failover when quality_api fails", async () => { + saveJson(STORAGE_KEYS.providerTiers, { + ...defaultProviderTierSettings(), + tiers: [ + { id: "quality_api", enabled: true }, + { id: "web_ui", enabled: false }, + { id: "searxng_discovery", enabled: false }, + { id: "proxy_failover", enabled: true }, + ], + }); + const qualityApi = vi.fn(async () => { + throw new Error("api down"); + }); + const proxyFailover = vi.fn(async (_req, onEvent: (e: StreamEvent) => void) => { + onEvent({ type: "text_delta", delta: "proxy ok" }); + }); + const orchestrator = new TierOrchestrator({ + qualityApi, + webUi: vi.fn(), + searxngDiscovery: vi.fn(), + proxyFailover, + }); + + await orchestrator.streamChat(baseRequest, () => {}); + + expect(proxyFailover).toHaveBeenCalledOnce(); + }); + + it("throws TierOrchestratorError with attempt list when all tiers fail", async () => { + saveJson(STORAGE_KEYS.providerTiers, { + ...defaultProviderTierSettings(), + tiers: [ + { id: "quality_api", enabled: true }, + { id: "web_ui", enabled: true }, + { id: "searxng_discovery", enabled: false }, + { id: "proxy_failover", enabled: false }, + ], + webRunnerUrl: "", + }); + const orchestrator = new TierOrchestrator({ + qualityApi: vi.fn(async () => { + throw new Error("api fail"); + }), + webUi: vi.fn(async () => { + throw webUiTierUnavailable(); + }), + searxngDiscovery: vi.fn(), + proxyFailover: vi.fn(), + }); + + await expect(orchestrator.streamChat(baseRequest, () => {})).rejects.toBeInstanceOf( + TierOrchestratorError + ); + + try { + await orchestrator.streamChat(baseRequest, () => {}); + } catch (err) { + const orchestratorErr = err as TierOrchestratorError; + expect(orchestratorErr.attempts.length).toBeGreaterThanOrEqual(2); + expect(orchestratorErr.attempts[0].tier).toBe("quality_api"); + } + }); + + it("TierSkipError is recorded but does not abort the chain", async () => { + saveJson(STORAGE_KEYS.providerTiers, { + ...defaultProviderTierSettings(), + tiers: [ + { id: "quality_api", enabled: false }, + { id: "web_ui", enabled: true }, + { id: "searxng_discovery", enabled: false }, + { id: "proxy_failover", enabled: true }, + ], + }); + const orchestrator = new TierOrchestrator({ + qualityApi: vi.fn(), + webUi: vi.fn(async () => { + throw new TierSkipError("web_ui", "no runner url"); + }), + searxngDiscovery: vi.fn(), + proxyFailover: vi.fn(async (_req, onEvent: (e: StreamEvent) => void) => { + onEvent({ type: "text_delta", delta: "ok" }); + }), + }); + + await orchestrator.streamChat(baseRequest, () => {}); + }); +}); diff --git a/webui/src/providers/tiers/orchestrator.ts b/webui/src/providers/tiers/orchestrator.ts new file mode 100644 index 0000000..0bd4b46 --- /dev/null +++ b/webui/src/providers/tiers/orchestrator.ts @@ -0,0 +1,86 @@ +import type { ChatRequest, StreamEvent } from "murm-ui"; +import { loadProviderTierSettings } from "./settings"; +import type { TierAttempt, TierId } from "./types"; +import { TierOrchestratorError, TierSkipError } from "./types"; + +export interface TierHandlers { + qualityApi: (request: ChatRequest, onEvent: (event: StreamEvent) => void) => Promise; + webUi: (request: ChatRequest, onEvent: (event: StreamEvent) => void) => Promise; + searxngDiscovery: ( + request: ChatRequest, + onEvent: (event: StreamEvent) => void + ) => Promise; + proxyFailover: (request: ChatRequest, onEvent: (event: StreamEvent) => void) => Promise; +} + +function formatAttemptError(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} + +export class TierOrchestrator { + constructor(private readonly handlers: TierHandlers) {} + + async streamChat( + request: ChatRequest, + onEvent: (event: StreamEvent) => void + ): Promise { + const settings = loadProviderTierSettings(); + const attempts: TierAttempt[] = []; + + for (const entry of settings.tiers) { + if (!entry.enabled) continue; + + const handler = this.handlerFor(entry.id); + if (!handler) continue; + + try { + await handler(request, onEvent); + return; + } catch (err) { + if (err instanceof TierSkipError) { + attempts.push({ tier: entry.id, error: err.message }); + continue; + } + attempts.push({ tier: entry.id, error: formatAttemptError(err) }); + if (request.signal.aborted) throw err; + } + } + + const summary = attempts.map((a) => `${a.tier}: ${a.error}`).join("; "); + throw new TierOrchestratorError( + summary || "No provider tiers are enabled.", + attempts + ); + } + + private handlerFor(id: TierId): TierHandlers[keyof TierHandlers] | null { + switch (id) { + case "quality_api": + return this.handlers.qualityApi; + case "web_ui": + return this.handlers.webUi; + case "searxng_discovery": + return this.handlers.searxngDiscovery; + case "proxy_failover": + return this.handlers.proxyFailover; + default: + return null; + } + } +} + +export function qualityApiTierUnavailable(): TierSkipError { + return new TierSkipError( + "quality_api", + "No BYOK API key set for the selected model — skipping direct routes." + ); +} + +export function webUiTierUnavailable(): TierSkipError { + return new TierSkipError("web_ui", "Web UI tier is not configured."); +} + +export function searxngTierUnavailable(): TierSkipError { + return new TierSkipError("searxng_discovery", "SearXNG discovery is not configured."); +} diff --git a/webui/src/providers/tiers/searxng-discovery-tier.test.ts b/webui/src/providers/tiers/searxng-discovery-tier.test.ts new file mode 100644 index 0000000..18be587 --- /dev/null +++ b/webui/src/providers/tiers/searxng-discovery-tier.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; +import { + DEFAULT_DISCOVERY_QUERY, + DiscoveryEmptyError, + DiscoveryUnavailableError, + discoverySearchUrl, + filterChatCandidates, + searchFreeChatCandidates, +} from "./searxng-discovery-tier"; + +const SEARX = "http://127.0.0.1:8080"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("filterChatCandidates", () => { + it("keeps https chat-looking results, one per host, capped", () => { + const results = [ + { url: "https://chat.example.com", title: "Example AI Chat", content: "free chat" }, + { url: "https://chat.example.com/other", title: "Same host again", content: "chat" }, + { url: "http://insecure.example.org", title: "AI chat", content: "chat" }, + { url: "https://en.wikipedia.org/wiki/Chatbot", title: "Chatbot", content: "chat" }, + { url: "https://plain.example.net", title: "Cooking recipes", content: "food" }, + { url: "https://gpt.example.io", title: "Free GPT playground", content: "" }, + ]; + const candidates = filterChatCandidates(results); + expect(candidates.map((c) => c.url)).toEqual([ + "https://chat.example.com", + "https://gpt.example.io", + ]); + }); +}); + +describe("searchFreeChatCandidates", () => { + it("returns ≥1 candidate from mock SearXNG JSON (AE4)", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + results: [ + { url: "https://chat.example.com", title: "Example AI Chat", content: "no signup" }, + ], + }) + ); + const candidates = await searchFreeChatCandidates({ searxngUrl: SEARX, fetchImpl }); + expect(candidates).toHaveLength(1); + expect(candidates[0].url).toBe("https://chat.example.com"); + expect(fetchImpl).toHaveBeenCalledWith( + discoverySearchUrl(SEARX, DEFAULT_DISCOVERY_QUERY), + expect.objectContaining({ headers: { Accept: "application/json" } }) + ); + }); + + it("throws a typed empty diagnostic when nothing matches (R40)", async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ results: [] })); + await expect( + searchFreeChatCandidates({ searxngUrl: SEARX, fetchImpl }) + ).rejects.toBeInstanceOf(DiscoveryEmptyError); + }); + + it("maps fetch failure (CORS/network) to a clear diagnostic", async () => { + const fetchImpl = vi.fn(async () => { + throw new TypeError("Failed to fetch"); + }); + await expect( + searchFreeChatCandidates({ searxngUrl: SEARX, fetchImpl }) + ).rejects.toMatchObject({ + name: "DiscoveryUnavailableError", + message: expect.stringContaining("CORS"), + }); + }); + + it("maps HTTP errors and non-JSON bodies to unavailable diagnostics", async () => { + const fetch403 = vi.fn(async () => new Response("forbidden", { status: 403 })); + await expect( + searchFreeChatCandidates({ searxngUrl: SEARX, fetchImpl: fetch403 }) + ).rejects.toBeInstanceOf(DiscoveryUnavailableError); + + const fetchHtml = vi.fn(async () => new Response("", { status: 200 })); + await expect( + searchFreeChatCandidates({ searxngUrl: SEARX, fetchImpl: fetchHtml }) + ).rejects.toMatchObject({ message: expect.stringContaining("JSON format") }); + }); +}); diff --git a/webui/src/providers/tiers/searxng-discovery-tier.ts b/webui/src/providers/tiers/searxng-discovery-tier.ts new file mode 100644 index 0000000..dfa3be3 --- /dev/null +++ b/webui/src/providers/tiers/searxng-discovery-tier.ts @@ -0,0 +1,130 @@ +/** + * SearXNG discovery tier (R39): query a user-configured SearXNG instance for + * candidate free web chat URLs. Discovery never chats by itself — results are + * presented as a pick list (Q5) or fed to the opt-in web runner. Errors are + * typed so the orchestrator surfaces actionable diagnostics (R40). + */ + +export interface DiscoveryCandidate { + url: string; + title: string; + snippet: string; +} + +export class DiscoveryEmptyError extends Error { + constructor(query: string) { + super(`SearXNG returned no candidate free chat sites for "${query}".`); + this.name = "DiscoveryEmptyError"; + } +} + +export class DiscoveryUnavailableError extends Error { + constructor(endpoint: string, cause: string) { + super( + `SearXNG at ${endpoint} is unreachable (${cause}). ` + + "Check the URL in Tiers settings and that the instance allows browser requests (CORS)." + ); + this.name = "DiscoveryUnavailableError"; + } +} + +export const DEFAULT_DISCOVERY_QUERY = "free AI chat online no signup"; + +const CHAT_HINT_RE = /\b(chat|gpt|assistant|llm|ai)\b/i; +const EXCLUDED_HOST_RE = + /(^|\.)(wikipedia\.org|youtube\.com|reddit\.com|github\.com|medium\.com|x\.com|twitter\.com|facebook\.com|linkedin\.com)$/i; +const MAX_CANDIDATES = 6; + +interface SearxngResult { + url?: string; + title?: string; + content?: string; +} + +function hostOf(url: string): string | null { + try { + return new URL(url).hostname; + } catch { + return null; + } +} + +/** Heuristic filter: https chat-looking results, one per host, capped. */ +export function filterChatCandidates(results: SearxngResult[]): DiscoveryCandidate[] { + const seenHosts = new Set(); + const candidates: DiscoveryCandidate[] = []; + for (const result of results) { + const url = result.url?.trim() ?? ""; + if (!url.startsWith("https://")) continue; + const host = hostOf(url); + if (!host || seenHosts.has(host) || EXCLUDED_HOST_RE.test(host)) continue; + const haystack = `${url} ${result.title ?? ""} ${result.content ?? ""}`; + if (!CHAT_HINT_RE.test(haystack)) continue; + seenHosts.add(host); + candidates.push({ + url, + title: result.title?.trim() || host, + snippet: (result.content ?? "").trim().slice(0, 200), + }); + if (candidates.length >= MAX_CANDIDATES) break; + } + return candidates; +} + +export function discoverySearchUrl(searxngUrl: string, query: string): string { + const base = searxngUrl.replace(/\/$/, ""); + return `${base}/search?q=${encodeURIComponent(query)}&format=json`; +} + +export async function searchFreeChatCandidates(options: { + searxngUrl: string; + query?: string; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +}): Promise { + const query = options.query?.trim() || DEFAULT_DISCOVERY_QUERY; + const doFetch = options.fetchImpl ?? fetch; + const url = discoverySearchUrl(options.searxngUrl, query); + + let res: Response; + try { + res = await doFetch(url, { + headers: { Accept: "application/json" }, + signal: options.signal, + }); + } catch (err) { + if (options.signal?.aborted) throw err; + // Browser fetch failures (including CORS) surface as opaque TypeErrors. + const cause = err instanceof Error ? err.message : String(err); + throw new DiscoveryUnavailableError(options.searxngUrl, cause || "network/CORS error"); + } + + if (!res.ok) { + throw new DiscoveryUnavailableError(options.searxngUrl, `HTTP ${res.status}`); + } + + let parsed: { results?: SearxngResult[] }; + try { + parsed = (await res.json()) as { results?: SearxngResult[] }; + } catch { + throw new DiscoveryUnavailableError( + options.searxngUrl, + "non-JSON response — enable the JSON format in SearXNG settings" + ); + } + + const candidates = filterChatCandidates(parsed.results ?? []); + if (candidates.length === 0) { + throw new DiscoveryEmptyError(query); + } + return candidates; +} + +/** Event fired with discovered candidates so the pick-list plugin can render them. */ +export const DISCOVERY_RESULTS_EVENT = "llm-fallbacks:discovery-results"; + +export function broadcastDiscoveryResults(candidates: DiscoveryCandidate[]): void { + window.dispatchEvent( + new CustomEvent(DISCOVERY_RESULTS_EVENT, { detail: { candidates } }) + ); +} diff --git a/webui/src/providers/tiers/settings.ts b/webui/src/providers/tiers/settings.ts new file mode 100644 index 0000000..9973710 --- /dev/null +++ b/webui/src/providers/tiers/settings.ts @@ -0,0 +1,17 @@ +import { STORAGE_KEYS, loadJson, saveJson } from "../../storage-keys"; +import { defaultProviderTierSettings, normalizeTierSettings } from "./defaults"; +import type { ProviderTierSettings } from "./types"; + +export function loadProviderTierSettings(): ProviderTierSettings { + const fallback = defaultProviderTierSettings(); + const raw = loadJson(STORAGE_KEYS.providerTiers, fallback); + return normalizeTierSettings({ + tiers: raw.tiers ?? fallback.tiers, + webRunnerUrl: raw.webRunnerUrl ?? "", + searxngUrl: raw.searxngUrl ?? "", + }); +} + +export function saveProviderTierSettings(settings: ProviderTierSettings): void { + saveJson(STORAGE_KEYS.providerTiers, normalizeTierSettings(settings)); +} diff --git a/webui/src/providers/tiers/types.ts b/webui/src/providers/tiers/types.ts new file mode 100644 index 0000000..79c898e --- /dev/null +++ b/webui/src/providers/tiers/types.ts @@ -0,0 +1,37 @@ +export type TierId = "quality_api" | "web_ui" | "searxng_discovery" | "proxy_failover"; + +export interface TierEntry { + id: TierId; + enabled: boolean; +} + +export interface ProviderTierSettings { + tiers: TierEntry[]; + webRunnerUrl: string; + searxngUrl: string; +} + +export interface TierAttempt { + tier: TierId; + error: string; +} + +export class TierOrchestratorError extends Error { + readonly attempts: readonly TierAttempt[]; + + constructor(message: string, attempts: readonly TierAttempt[]) { + super(message); + this.name = "TierOrchestratorError"; + this.attempts = attempts; + } +} + +export class TierSkipError extends Error { + readonly tier: TierId; + + constructor(tier: TierId, reason: string) { + super(reason); + this.name = "TierSkipError"; + this.tier = tier; + } +} diff --git a/webui/src/providers/tiers/web-ui-tier.test.ts b/webui/src/providers/tiers/web-ui-tier.test.ts new file mode 100644 index 0000000..407d54e --- /dev/null +++ b/webui/src/providers/tiers/web-ui-tier.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import type { StreamEvent } from "murm-ui"; +import { + WebRunnerNotConfiguredError, + WebRunnerUnavailableError, + runnerChatUrl, + streamFromWebRunner, +} from "./web-ui-tier"; + +const RUNNER = "http://127.0.0.1:8815"; + +function sseResponse(lines: string[]): Response { + const body = lines.map((l) => `data: ${l}\n\n`).join("") + "data: [DONE]\n\n"; + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream; charset=utf-8" }, + }); +} + +function baseOptions(onEvent: (e: StreamEvent) => void, fetchImpl: typeof fetch) { + return { + runnerUrl: RUNNER, + model: "web-ui", + messages: [{ role: "user", content: "hi" }], + signal: new AbortController().signal, + onEvent, + fetchImpl, + }; +} + +describe("streamFromWebRunner", () => { + it("streams OpenAI-shaped SSE deltas from the runner", async () => { + const fetchImpl = vi.fn(async () => + sseResponse([ + JSON.stringify({ choices: [{ delta: { content: "runner " } }] }), + JSON.stringify({ choices: [{ delta: { content: "reply" } }] }), + JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] }), + ]) + ); + const events: StreamEvent[] = []; + await streamFromWebRunner(baseOptions((e) => events.push(e), fetchImpl)); + + const text = events + .filter((e): e is Extract => e.type === "text_delta") + .map((e) => e.delta) + .join(""); + expect(text).toBe("runner reply"); + expect(fetchImpl).toHaveBeenCalledWith(runnerChatUrl(RUNNER), expect.anything()); + }); + + it("maps HTTP 501 to a not-configured diagnostic", async () => { + const fetchImpl = vi.fn( + async () => new Response(JSON.stringify({ error: { message: "no adapter" } }), { status: 501 }) + ); + await expect( + streamFromWebRunner(baseOptions(() => {}, fetchImpl)) + ).rejects.toBeInstanceOf(WebRunnerNotConfiguredError); + }); + + it("maps network failure to an unreachable diagnostic", async () => { + const fetchImpl = vi.fn(async () => { + throw new TypeError("Failed to fetch"); + }); + await expect( + streamFromWebRunner(baseOptions(() => {}, fetchImpl)) + ).rejects.toBeInstanceOf(WebRunnerUnavailableError); + }); +}); diff --git a/webui/src/providers/tiers/web-ui-tier.ts b/webui/src/providers/tiers/web-ui-tier.ts new file mode 100644 index 0000000..5ac693b --- /dev/null +++ b/webui/src/providers/tiers/web-ui-tier.ts @@ -0,0 +1,77 @@ +/** + * web_ui tier (R38): stream from a user-run companion runner that automates + * free web chat UIs. The runner speaks OpenAI-shaped SSE at + * /v1/chat/completions, so the browser client here is a thin fetch + SSE + * bridge. Off by default; the public Pages demo never requires it. + */ +import type { StreamEvent } from "murm-ui"; +import { emitOpenAiSseAsStreamEvents } from "../sse"; + +export class WebRunnerNotConfiguredError extends Error { + constructor(runnerUrl: string, detail: string) { + super( + `Web runner at ${runnerUrl} has no adapter configured (${detail}). ` + + "Set up runner/runner.config.json — see runner/README.md." + ); + this.name = "WebRunnerNotConfiguredError"; + } +} + +export class WebRunnerUnavailableError extends Error { + constructor(runnerUrl: string, cause: string) { + super( + `Web runner at ${runnerUrl} is unreachable (${cause}). ` + + "Check that the runner is started and the URL in Tiers settings is correct." + ); + this.name = "WebRunnerUnavailableError"; + } +} + +export function runnerChatUrl(runnerUrl: string): string { + return `${runnerUrl.replace(/\/$/, "")}/v1/chat/completions`; +} + +export async function streamFromWebRunner(options: { + runnerUrl: string; + model: string; + messages: { role: string; content: string }[]; + maxTokens?: number; + signal: AbortSignal; + onEvent: (event: StreamEvent) => void; + fetchImpl?: typeof fetch; +}): Promise { + const doFetch = options.fetchImpl ?? fetch; + + let res: Response; + try { + res = await doFetch(runnerChatUrl(options.runnerUrl), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: options.model, + messages: options.messages, + max_tokens: options.maxTokens, + stream: true, + }), + signal: options.signal, + }); + } catch (err) { + if (options.signal.aborted) throw err; + const cause = err instanceof Error ? err.message : String(err); + throw new WebRunnerUnavailableError(options.runnerUrl, cause || "network/CORS error"); + } + + if (res.status === 501) { + const bodyText = await res.text(); + throw new WebRunnerNotConfiguredError(options.runnerUrl, bodyText.slice(0, 160) || "HTTP 501"); + } + if (!res.ok) { + const bodyText = await res.text(); + throw new WebRunnerUnavailableError( + options.runnerUrl, + `HTTP ${res.status} — ${bodyText.slice(0, 160)}` + ); + } + + await emitOpenAiSseAsStreamEvents(res, options.onEvent); +} diff --git a/webui/src/shell-panels.ts b/webui/src/shell-panels.ts index 2d977e7..7f7e5ab 100644 --- a/webui/src/shell-panels.ts +++ b/webui/src/shell-panels.ts @@ -39,6 +39,7 @@ export function closeShellPanel(_id?: string): void { export function bindTopBarButtons(): void { document.getElementById("sysSetting")?.addEventListener("click", () => openShellPanel("failover")); document.getElementById("byokSetting")?.addEventListener("click", () => openShellPanel("byok")); + document.getElementById("tiersSetting")?.addEventListener("click", () => openShellPanel("tiers")); document.getElementById("explorerSetting")?.addEventListener("click", () => openShellPanel("explorer")); document.getElementById("closeSet")?.addEventListener("click", () => closeShellPanel()); document.getElementById("sysMask")?.addEventListener("click", (e) => { diff --git a/webui/src/storage-keys.ts b/webui/src/storage-keys.ts index fcabb6c..dc10a96 100644 --- a/webui/src/storage-keys.ts +++ b/webui/src/storage-keys.ts @@ -3,6 +3,7 @@ export const STORAGE_KEYS = { guestToken: "llm_fallbacks_guest_token", defaultModel: "llm_fallbacks_default_model", apiKeys: "llm_fallbacks_api_keys", + providerTiers: "llm_fallbacks_provider_tiers", } as const; export function loadJson(key: string, fallback: T): T { diff --git a/webui/vitest.config.ts b/webui/vitest.config.ts new file mode 100644 index 0000000..0acce29 --- /dev/null +++ b/webui/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + setupFiles: ["./vitest.setup.ts"], + }, +}); diff --git a/webui/vitest.setup.ts b/webui/vitest.setup.ts new file mode 100644 index 0000000..6d72d7e --- /dev/null +++ b/webui/vitest.setup.ts @@ -0,0 +1,65 @@ +// In-memory browser seams for Vitest's node environment. +// Tier settings, API keys, and runtime config persist through localStorage, +// and FailoverProvider writes window.LLM_FALLBACKS_ROUTE. Neither exists in +// node, so provide minimal shims instead of pulling in full jsdom. + +class MemoryStorage { + private store = new Map(); + + get length(): number { + return this.store.size; + } + + clear(): void { + this.store.clear(); + } + + getItem(key: string): string | null { + return this.store.has(key) ? (this.store.get(key) as string) : null; + } + + key(index: number): string | null { + return Array.from(this.store.keys())[index] ?? null; + } + + removeItem(key: string): void { + this.store.delete(key); + } + + setItem(key: string, value: string): void { + this.store.set(key, String(value)); + } +} + +if (typeof globalThis.localStorage === "undefined") { + Object.defineProperty(globalThis, "localStorage", { + value: new MemoryStorage(), + configurable: true, + writable: true, + }); +} + +// Routing metadata broadcasts via window.dispatchEvent; provide inert event +// methods so provider success paths run under the node environment. +const eventTarget = globalThis as { + dispatchEvent?: (event: unknown) => boolean; + addEventListener?: (...args: unknown[]) => void; + removeEventListener?: (...args: unknown[]) => void; +}; +if (typeof eventTarget.dispatchEvent !== "function") { + eventTarget.dispatchEvent = () => true; +} +if (typeof eventTarget.addEventListener !== "function") { + eventTarget.addEventListener = () => {}; +} +if (typeof eventTarget.removeEventListener !== "function") { + eventTarget.removeEventListener = () => {}; +} + +if (typeof (globalThis as { window?: unknown }).window === "undefined") { + Object.defineProperty(globalThis, "window", { + value: globalThis, + configurable: true, + writable: true, + }); +}