diff --git a/.gitignore b/.gitignore index 6b8038c..804673b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ tmp-* # OpenCode plans .opencode/plans/ + +# Temp development +public/vendor/ diff --git a/public/app/components/answer.js b/public/app/components/answer.js index abb1b87..86baef0 100644 --- a/public/app/components/answer.js +++ b/public/app/components/answer.js @@ -1,7 +1,9 @@ -import { useState, Fragment } from "react"; +import { useState, useMemo, Fragment } from "react"; import { marked } from "marked"; import DOMPurify from "dompurify"; import { html, openTextInNewWindow } from "../util/html.js"; +import { parseThinking } from "../util/think.js"; +import { CopyButton } from "./copy-button.js"; import { useSettings } from "../hooks/use-settings.js"; import { ALL_PROVIDERS, getModelCfg } from "../../config.js"; import { formatInt, formatFloat, formatElapsed } from "../../shared-util.js"; @@ -52,6 +54,31 @@ const PromptDataLink = ({ data }) => { `; }; +/** + * Icon link that opens the model's `` reasoning in a new page. Only rendered when the + * answer actually carries reasoning (i.e. `thinking` is non-empty). + */ +const ThinkingDataLink = ({ thinking }) => { + if (!thinking) return null; + + const handleOpen = (e) => { + e.preventDefault(); + e.stopPropagation(); + openTextInNewWindow(thinking); + }; + + return html` + + `; +}; + /** * Icon link that opens the full context (XML chunks) prettified in a new page. */ @@ -265,45 +292,30 @@ const QueryInfo = ({ `; }; -/* global navigator:false, setTimeout:false */ - -const CopyButton = ({ text }) => { - const [copied, setCopied] = useState(false); - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - // Clipboard write failed (e.g. permissions denied) - } - }; - return html` - - `; -}; - -export const Answer = ({ answer, queryInfo, onNewConversation }) => { +export const Answer = ({ answer, think, queryInfo, onNewConversation }) => { const [isRaw, setIsRaw] = useState(false); const [settings] = useSettings(); const { isDeveloperMode } = settings; + // Reasoning models wrap chain-of-thought in ; keep it out of the visible answer + // (and the copy button) — it's surfaced separately via the dev-mode ThinkingDataLink. Reuse the + // parse the caller already memoized (chat passes it per entry); otherwise parse here, memoized on + // `answer`, so the component stays correct and cheap for any other caller. + const parsed = useMemo(() => think ?? parseThinking(answer), [think, answer]); + const visibleAnswer = parsed.visible; + let answerSection; if (isRaw && isDeveloperMode) { answerSection = html`
- ${answer + ${visibleAnswer .split("\n") .map((par, i) => html`

${par}

`)}
`; } else { - const renderedHtml = marked.parse(answer, { breaks: true, gfm: true }); + const renderedHtml = marked.parse(visibleAnswer, { + breaks: true, + gfm: true, + }); const sanitizedHtml = DOMPurify.sanitize(renderedHtml); answerSection = html`
{ ${isDeveloperMode && queryInfo && html`<${QueryInfo} ...${queryInfo} />`} ${isDeveloperMode && queryInfo?.prompt && html`<${PromptDataLink} data=${queryInfo.prompt} />`} ${isDeveloperMode && queryInfo?.rawContext && html`<${ContextDataLink} data=${queryInfo.rawContext} />`} + ${isDeveloperMode && html`<${ThinkingDataLink} thinking=${parsed.thinking} />`} ${ isDeveloperMode && html` @@ -335,7 +348,7 @@ export const Answer = ({ answer, queryInfo, onNewConversation }) => { ` } - <${CopyButton} text=${answer} /> + <${CopyButton} text=${visibleAnswer} />
<${ContextLimitWarning} finishReason=${queryInfo?.finishReason} diff --git a/public/app/components/copy-button.js b/public/app/components/copy-button.js new file mode 100644 index 0000000..8793aad --- /dev/null +++ b/public/app/components/copy-button.js @@ -0,0 +1,68 @@ +/* global navigator:false, document:false, setTimeout:false */ +import { useState } from "react"; +import { html } from "../util/html.js"; + +/** + * Copy `text` to the clipboard, returning whether it actually succeeded. Prefers the async Clipboard + * API; falls back to a hidden textarea + execCommand for non-secure contexts / older WebKit where + * `navigator.clipboard` is missing or throws. Returns false if every path fails so callers don't show + * a false "Copied!". + * @param {string} text + * @returns {Promise} + */ +const copyText = async (text) => { + // Guard the call (don't just optional-chain the await): a missing clipboard resolves to undefined + // rather than throwing, which would skip the fallback and look like success. + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // fall through to the execCommand fallback + } + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + ta.remove(); + return ok; + } catch { + return false; + } +}; + +/** + * Icon button that copies `text` to the clipboard with a brief check-mark affordance. Falls back to + * a hidden textarea + execCommand for non-secure contexts / browsers without the async clipboard API + * (e.g. older WebKit), so the copy still works where `navigator.clipboard` is unavailable. + * @param {{ text: string, className?: string, title?: string }} props + */ +export const CopyButton = ({ + text, + className = "answer-actions-btn", + title = "Copy to clipboard", +}) => { + const [copied, setCopied] = useState(false); + const handleCopy = async () => { + // Only show the "Copied!" affordance when the copy actually succeeded. + if (!(await copyText(text))) return; + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + return html` + + `; +}; diff --git a/public/app/components/crashes-panel.js b/public/app/components/crashes-panel.js new file mode 100644 index 0000000..3f2146a --- /dev/null +++ b/public/app/components/crashes-panel.js @@ -0,0 +1,326 @@ +/* global window:false */ +import { useState } from "react"; +import { Link } from "react-router"; +import { html } from "../util/html.js"; +import { Alert } from "./alert.js"; +import { CopyButton } from "./copy-button.js"; +import { + dismissRecovered, + reportMemoryPressure, + resetCrashbox, +} from "../../local/data/telemetry.js"; +import { useCrashbox } from "../hooks/use-crashbox.js"; + +const REASON_LABELS = { + "webgpu-device-lost": { + label: "WebGPU device lost", + className: "status-unsupported", + }, + oom: { label: "Out of memory", className: "status-unsupported" }, + "hard-kill": { label: "Hard kill", className: "status-warning" }, + unknown: { label: "Unknown", className: "status-warning" }, +}; + +const WARNING_LABELS = { + "memory-pressure": { + label: "Memory pressure", + className: "status-warning", + icon: "iconoir-warning-triangle", + }, + "device-loss-imminent": { + label: "GPU device-loss imminent", + className: "status-unsupported", + icon: "iconoir-warning-circle", + }, +}; + +// How many of the newest live warnings the panel renders (crashbox retains more in its buffer). +const MAX_VISIBLE_WARNINGS = 3; + +const formatTime = (t) => new Date(t).toLocaleTimeString(); + +const RecoveredCrash = ({ record, onDismiss }) => { + const [showCrumbs, setShowCrumbs] = useState(false); + const [showSnapshot, setShowSnapshot] = useState(false); + const reason = REASON_LABELS[record.reason] ?? REASON_LABELS.unknown; + const isWebgpu = record.reason === "webgpu-device-lost"; + + return html` + <${Alert} type="error"> +

+ ${" "} + Previous session crashed +

+
+ Reason:${" "} + + ${reason.label} + +
+
+ Time: ${new Date(record.lastSeen).toLocaleString()} +
+
+ Session:${" "} + ${record.sessionId} +
+ ${ + isWebgpu && + html` +

+ Suggestion: switch the embeddings extractor to WASM in${" "} + <${Link} to="/settings">Settings${" "} + (turn off Experimental WebGPU Embeddings) for a more stable load. +

+ ` + } +
+ ${" "} + ${" "} + +
+ ${ + showCrumbs && + html` +
+${record.breadcrumbs
+              .map(
+                (c) =>
+                  `[${formatTime(c.t)}] ${c.msg}${c.data ? " " + JSON.stringify(c.data) : ""}`,
+              )
+              .join("\n")}
+ ` + } + ${ + showSnapshot && + html` +
+${JSON.stringify(record.snapshot ?? {}, null, 2)}
+ ` + } + + `; +}; + +const isEmpty = (v) => + v == null || (typeof v === "object" && Object.keys(v).length === 0); + +const InlineView = ({ view }) => { + if (isEmpty(view.payload)) { + return html` +

+ No data for "${view.label}" — nothing to show. +

+ `; + } + const text = JSON.stringify(view.payload, null, 2); + return html` +
+ <${CopyButton} + text=${text} + className="crashes-copy-button" + title="Copy" + /> +
+${text}
+
+ `; +}; + +// Summarize a memory-pressure info object: "serious · 87% · performance.memory". +const warningDetail = (info) => { + if (!info) { + return ""; + } + const parts = []; + if (info.level) { + parts.push(info.level); + } + if (typeof info.ratio === "number") { + parts.push(`${Math.round(info.ratio * 100)}%`); + } + if (info.source) { + parts.push(info.source); + } + if (info.reason) { + parts.push(info.reason); // device-loss-imminent carries a reason instead + } + return parts.length ? ` — ${parts.join(" · ")}` : ""; +}; + +const WarningRow = ({ warning }) => { + const cfg = WARNING_LABELS[warning.kind] ?? { + label: warning.kind, + className: "status-warning", + icon: "iconoir-warning-triangle", + }; + return html` +
+ ${" "} + ${cfg.label} + + ${formatTime(warning.t)}${warningDetail(warning.info)} + +
+ `; +}; + +export const CrashesPanel = () => { + // Re-renders on crashbox events (recovered dismiss, new live warning). + const { recovered: record, status } = useCrashbox(); + + // inlineView: null = nothing shown; { label, payload } = a button has been clicked. + // payload may itself be null/empty — that's how "no data" is rendered. + const [inlineView, setInlineView] = useState(null); + // crashbox keeps the full buffer; the panel shows only the most recent few (newest first) so a + // long session — or a steady-state pressure signal that re-fires periodically — doesn't flood it. + const warnings = status?.warnings ?? []; + const recentWarnings = warnings.slice(-MAX_VISIBLE_WARNINGS).reverse(); + const hiddenWarnings = warnings.length - recentWarnings.length; + + return html` +
+ ${record && + html`<${RecoveredCrash} + record=${record} + onDismiss=${dismissRecovered} + />`} + +

Live session warnings

+ ${warnings.length === 0 + ? html`

+ No warnings this session. +

` + : html`
+ ${recentWarnings.map( + (w, i) => + html`<${WarningRow} key=${`${w.t}-${i}`} warning=${w} />`, + )} + ${hiddenWarnings > 0 && + html`
+ + ${hiddenWarnings} earlier + ${hiddenWarnings === 1 ? "warning" : "warnings"} +
`} +
`} + +

Session diagnostics

+ ${status + ? html` +
+
+ Session:${" "} + ${status.sessionId} +
+
+ Breadcrumbs: ${status.breadcrumbCount} +
+
+ Last seen:${" "} + ${new Date(status.lastSeen).toLocaleTimeString()} +
+
+ ` + : html`

Telemetry not initialized.

`} + +
+ Debug actions +

+ Available on the global window.__crashbox handle:${" "} + dump(), clear(), recovered(). +

+
+ + + + +
+ ${inlineView !== null && html`<${InlineView} view=${inlineView} />`} +
+
+ `; +}; diff --git a/public/app/components/forms.js b/public/app/components/forms.js index 4ad7a01..7d11087 100644 --- a/public/app/components/forms.js +++ b/public/app/components/forms.js @@ -389,6 +389,11 @@ const MODEL_STATUS_CONFIG = { cls: "loading-status-loaded", title: "Loaded", }, + cached: { + icon: "iconoir-database", + cls: "loading-status-cached", + title: "Cached on disk", + }, error: { icon: "iconoir-warning-circle-solid", cls: "loading-status-error", @@ -420,7 +425,7 @@ export const ModelChatSelect = ({ }) => { const [settings] = useSettings(); const { isDeveloperMode, displayModelStats } = settings; - const { getStatus } = useLoading(); + const { getStatus, getCached } = useLoading(); const getLabel = (label, { provider, model }) => { if (!displayModelStats) { @@ -478,7 +483,11 @@ export const ModelChatSelect = ({ // Custom format for options showing status icon const formatOptionLabel = (option) => { const modelId = option.model; - const status = modelId ? getStatus(`llm_${modelId}`) : "not_loaded"; + const loadStatus = modelId ? getStatus(`llm_${modelId}`) : "not_loaded"; + const status = + loadStatus === "not_loaded" && modelId && getCached(`llm_${modelId}`) + ? "cached" + : loadStatus; return html` <${ModelStatusIcon} status=${status} /> diff --git a/public/app/components/layout.js b/public/app/components/layout.js index 60d7d83..cf0de68 100644 --- a/public/app/components/layout.js +++ b/public/app/components/layout.js @@ -1,5 +1,10 @@ -import { useState } from "react"; -import { BrowserRouter as Router, Routes, Route } from "react-router"; +import { useEffect, useState } from "react"; +import { + BrowserRouter as Router, + Routes, + Route, + useLocation, +} from "react-router"; import config from "../../config.js"; import { Menu } from "./menu.js"; @@ -14,6 +19,16 @@ import { Search } from "../pages/search.js"; import { Chat } from "../pages/chat.js"; import { Data } from "../pages/data.js"; import { BASE_PATH } from "../../local/data/util.js"; +import { mergeSnapshot, breadcrumb } from "../../local/data/telemetry.js"; + +const RouteTracker = () => { + const location = useLocation(); + useEffect(() => { + mergeSnapshot({ route: location.pathname }); + breadcrumb("route", { path: location.pathname }); + }, [location.pathname]); + return null; +}; const PAGE_COMPONENTS = { Home, @@ -47,6 +62,7 @@ export const Layout = () => { return html`
<${Router} basename="${BASE_PATH}"> + <${RouteTracker} />
diff --git a/public/app/hooks/use-chat-session.js b/public/app/hooks/use-chat-session.js index 3d67776..3556d85 100644 --- a/public/app/hooks/use-chat-session.js +++ b/public/app/hooks/use-chat-session.js @@ -38,6 +38,7 @@ export const useChatSession = ({ modelResourceId, modelStatus, conversationsEnabled: conversationsEnabledSetting, + enableThinking, }) => { // ============================================================================ // State Management @@ -197,6 +198,7 @@ export const useChatSession = ({ provider: modelObj.provider, model: modelObj.model, temperature, + enableThinking, }); let usage = null; diff --git a/public/app/hooks/use-crashbox.js b/public/app/hooks/use-crashbox.js new file mode 100644 index 0000000..8a8da7d --- /dev/null +++ b/public/app/hooks/use-crashbox.js @@ -0,0 +1,11 @@ +import { useSyncExternalStore } from "react"; +import { subscribe, getCrashboxSnapshot } from "../../local/data/telemetry.js"; + +/** + * Subscribe to crashbox telemetry and re-render when a crash is recovered/dismissed, a live warning + * fires, or crashbox is bootstrapped/torn down. Returns the current `{ recovered, status }` snapshot, + * read tear-free via `useSyncExternalStore` — replacing the hand-rolled `subscribe` + tick boilerplate. + * @returns {ReturnType} + */ +export const useCrashbox = () => + useSyncExternalStore(subscribe, getCrashboxSnapshot); diff --git a/public/app/hooks/use-settings.js b/public/app/hooks/use-settings.js index f2eb9a4..f388250 100644 --- a/public/app/hooks/use-settings.js +++ b/public/app/hooks/use-settings.js @@ -18,7 +18,18 @@ const DEFAULT_SETTINGS = { // Experimental settings experimentalChat: false, experimentalChatConversations: false, + // Reasoning models (Qwen3, DeepSeek-R1) emit a block before the answer. Off = ask web-llm + // to skip it (faster, cleaner answers); on = let the model reason and view it via the dev-mode + // "thinking" icon. No effect on non-reasoning models. + enableThinking: false, experimentalWebgpuEmbeddings: false, + experimentalCrashbox: false, + // Off (default) = one web-llm model in memory at a time (switching unloads the previous, which + // stays cached on disk for a fast reload). On = keep multiple loaded (faster switching, more OOM risk). + experimentalMultipleModels: false, + // Optional manual memory-budget override in MB (0/unset = auto-detect from navigator.deviceMemory). + // Escape hatch for big desktops where deviceMemory caps at 8 and under-reports true RAM. + memoryBudgetMb: 0, }; /** diff --git a/public/app/pages/chat.js b/public/app/pages/chat.js index 7c323d2..5011f1a 100644 --- a/public/app/pages/chat.js +++ b/public/app/pages/chat.js @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Link, useSearchParams } from "react-router"; import { html } from "../util/html.js"; @@ -39,6 +39,7 @@ import { Alert } from "../components/alert.js"; import { ContextExceededError } from "../components/context-messages.js"; import { SuggestedQueries } from "../components/suggested-queries.js"; import { LoadingBubble } from "../components/loading-bubble.js"; +import { parseThinking } from "../util/think.js"; import { QueryDisplay } from "../components/query-display.js"; import { Description } from "../components/description.js"; import { @@ -124,6 +125,29 @@ const DescriptionButton = () => { `; }; +// One conversation turn. Parses the answer's reasoning once, memoized on the answer text, so a +// streaming render only re-parses the entry that's actively growing — prior entries' answers are +// stable, so their memo short-circuits (vs. re-scanning every entry on every token). Gate the answer +// box on the VISIBLE answer (reasoning stripped): reasoning models stream `` before +// any real answer text, so this keeps the loading dots up while the model is only reasoning instead +// of flashing an empty answer box with action icons. +const ConversationEntry = ({ entry, onNewConversation }) => { + const think = useMemo(() => parseThinking(entry.answer), [entry.answer]); + return html` +
+ <${QueryDisplay} query=${entry.query} /> + ${entry.isLoading && !think.visible && html`<${LoadingBubble} />`} + ${think.visible && + html`<${Answer} + answer=${entry.answer} + think=${think} + queryInfo=${entry.queryInfo} + onNewConversation=${onNewConversation} + />`} +
+ `; +}; + export const Chat = () => { const [searchParams, setSearchParams] = useSearchParams(); @@ -197,6 +221,7 @@ export const Chat = () => { modelResourceId, modelStatus, conversationsEnabled: settings.experimentalChatConversations, + enableThinking: settings.enableThinking, }); const handleSubmit = (event) => { @@ -241,21 +266,12 @@ export const Chat = () => { } ${conversation.map( - (entry, idx) => html` -
+ html`<${ConversationEntry} key=${`conversation-entry-${idx}`} - className="conversation-entry" - > - <${QueryDisplay} query=${entry.query} /> - ${entry.isLoading && !entry.answer && html`<${LoadingBubble} />`} - ${entry.answer && - html`<${Answer} - answer=${entry.answer} - queryInfo=${entry.queryInfo} - onNewConversation=${handleReset} - />`} -
- `, + entry=${entry} + onNewConversation=${handleReset} + />`, )} <${ContextExceededError} diff --git a/public/app/pages/data.js b/public/app/pages/data.js index 6ece2af..c90fc23 100644 --- a/public/app/pages/data.js +++ b/public/app/pages/data.js @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Link } from "react-router"; import { html } from "../util/html.js"; import { Page } from "../components/page.js"; @@ -21,12 +21,35 @@ import { import { useLoading } from "../../local/app/context/loading.js"; import { getLoadedData } from "../../local/data/loading.js"; import { checkAvailability } from "../../local/data/api/providers/chrome.js"; +import { CrashesPanel } from "../components/crashes-panel.js"; +import { + pickBestModel, + tierClass, + tierLabel, +} from "../../local/data/recommendations.js"; +import { useCrashbox } from "../hooks/use-crashbox.js"; -const TABS = [ +const BASE_TABS = [ { id: "resources", label: "Resources", icon: "iconoir-database" }, { id: "system", label: "System", icon: "iconoir-cpu" }, { id: "models", label: "AI Models", icon: "iconoir-brain" }, ]; +const CRASHES_TAB = { + id: "crashes", + label: "Crashes", + icon: "iconoir-warning-triangle", +}; + +// Stable reference for the "no warnings" case so fitCtx memoization below doesn't invalidate every +// render (a fresh `[]` each call would change identity and defeat the useMemo). +const EMPTY_WARNINGS = []; + +// The live `{ warnings, recovered }` state used to drive the device-fit recommendations. Re-renders +// (via useCrashbox) when crashbox emits a warning or a recovery is dismissed. +const useCrashboxState = () => { + const { recovered, status } = useCrashbox(); + return { warnings: status?.warnings ?? EMPTY_WARNINGS, recovered }; +}; // Get model short name from resource id (provider-agnostic) const modelShortName = (modelId) => { @@ -92,13 +115,37 @@ const ResourcesPanel = ({ experimentalChat }) => { `; }; -const SystemPanel = ({ systemInfo }) => { +const deviceClassLabel = (deviceInfo) => { + if (!deviceInfo) return "Unknown"; + const platform = deviceInfo.isIOS + ? "iOS" + : deviceInfo.isAndroid + ? "Android" + : "Desktop"; + const browser = deviceInfo.isSafari + ? "Safari" + : deviceInfo.isChrome + ? "Chrome" + : "Other"; + const form = deviceInfo.isMobile ? "Mobile" : "Desktop"; + return `${form} / ${platform} / ${browser}`; +}; + +const SystemPanel = ({ systemInfo, deviceInfo, experimentalChat }) => { const { webgpu, limits, gpuInfo, ramGb } = systemInfo; const { getStatus } = useLoading(); const extractorStatus = getStatus(LOADING.EXTRACTOR); const extractor = extractorStatus === "loaded" ? getLoadedData(LOADING.EXTRACTOR) : null; const device = extractor?._device ?? null; + const { warnings, recovered } = useCrashboxState(); + const fitCtx = useMemo( + () => ({ systemInfo, deviceInfo, warnings, recovered }), + [systemInfo, deviceInfo, warnings, recovered], + ); + // Pick the best model only when chat is enabled — otherwise the recommendation isn't + // actionable. The card is still gated on experimentalChat below. + const best = experimentalChat ? pickBestModel(MODELS, fitCtx) : null; const embeddingsBadge = device === "webgpu" @@ -123,7 +170,12 @@ const SystemPanel = ({ systemInfo }) => { id="tabpanel-system" aria-labelledby="tab-system" > +

Device Profile

+
+ Class: ${deviceClassLabel(deviceInfo)} +
+
WebGPU: @@ -133,7 +185,10 @@ const SystemPanel = ({ systemInfo }) => {
- System RAM: ${ramGb != null ? `${ramGb} GB` : "N/A"} + System RAM:${" "} + ${ramGb != null + ? `${ramGb} GB` + : "Unknown (iOS Safari does not expose deviceMemory)"}
@@ -143,6 +198,17 @@ const SystemPanel = ({ systemInfo }) => {
+
+ Cache backend:${" "} + + ${useIndexedDBCache ? "IndexedDB" : "Cache API"} + + ${useIndexedDBCache && + html`(common on iOS Safari)`} +
+ ${webgpu.adapterAvailable && html`
@@ -173,13 +239,50 @@ const SystemPanel = ({ systemInfo }) => {
`}
+ + ${experimentalChat && + html` +

Best for this device

+
+ ${best + ? html` +
+ ${best.model.model} + ${best.model.vramMb != null && + html` + (${best.model.vramMb} MB VRAM) + `} + + ${tierLabel(best.fit.tier)} + +
+
+ ${best.fit.reasons.join(" ")} +
+ ` + : html` +
+ No clearly-safe model on this device — see the${" "} + AI Models${" "}tab for the smallest options. +
+ `} +
+ `}
`; }; -const ModelsPanel = ({ experimentalChat }) => { +const ModelsPanel = ({ experimentalChat, systemInfo, deviceInfo }) => { const [promptStatus, setPromptStatus] = useState(null); const [writerStatus, setWriterStatus] = useState(null); + const { warnings, recovered } = useCrashboxState(); + const fitCtx = useMemo( + () => ({ systemInfo, deviceInfo, warnings, recovered }), + [systemInfo, deviceInfo, warnings, recovered], + ); useEffect(() => { if (!experimentalChat) return; @@ -254,20 +357,27 @@ const ModelsPanel = ({ experimentalChat }) => { the model is loaded in memory, currently loading, or available for download.

- <${ModelsTable} models=${MODELS} /> + <${ModelsTable} models=${MODELS} fitCtx=${fitCtx} />
`; }; export const Data = () => { const [settings] = useSettings(); - const { systemInfo } = useConfig(); + const { systemInfo, deviceInfo } = useConfig(); const [activeTab, setActiveTab] = useState("resources"); + // The Crashes tab is dev-mode-only; without dev mode the user wouldn't even reach + // this page (Data itself is dev-only), but gate the tab anyway so it's explicit. + // Also gated on the experimentalCrashbox flag — when off, bootstrap() is skipped at + // app boot so there's no telemetry to show. + const crashboxOn = settings.isDeveloperMode && settings.experimentalCrashbox; + const tabs = crashboxOn ? [...BASE_TABS, CRASHES_TAB] : BASE_TABS; + return html` <${Page} name="Data & Models" icon="iconoir-cpu">

Data, system information, and AI models used by the app.

- <${Tabs} tabs=${TABS} activeTab=${activeTab} onTabChange=${setActiveTab} /> + <${Tabs} tabs=${tabs} activeTab=${activeTab} onTabChange=${setActiveTab} /> ${ activeTab === "resources" && html`<${ResourcesPanel} @@ -276,12 +386,21 @@ export const Data = () => { } ${ activeTab === "system" && - html`<${SystemPanel} systemInfo=${systemInfo} />` + html`<${SystemPanel} + systemInfo=${systemInfo} + deviceInfo=${deviceInfo} + experimentalChat=${settings.experimentalChat} + />` } ${ activeTab === "models" && - html`<${ModelsPanel} experimentalChat=${settings.experimentalChat} />` + html`<${ModelsPanel} + experimentalChat=${settings.experimentalChat} + systemInfo=${systemInfo} + deviceInfo=${deviceInfo} + />` } + ${activeTab === "crashes" && crashboxOn && html`<${CrashesPanel} />`} `; }; diff --git a/public/app/pages/settings.js b/public/app/pages/settings.js index 21c8c19..010e6bf 100644 --- a/public/app/pages/settings.js +++ b/public/app/pages/settings.js @@ -5,6 +5,12 @@ import { Page } from "../components/page.js"; import { Form, Checkbox } from "../components/forms.js"; import { useSettings } from "../hooks/use-settings.js"; import { Alert } from "../components/alert.js"; +import { + getStatus as getCrashboxStatus, + bootstrap as bootstrapCrashbox, + shutdown as shutdownCrashbox, + breadcrumb, +} from "../../local/data/telemetry.js"; // Duration to show success message (in milliseconds) const SUCCESS_MESSAGE_DURATION = 3000; @@ -14,11 +20,29 @@ export const Settings = () => { const [showSuccess, setShowSuccess] = useState(false); const [hasChanges, setHasChanges] = useState(false); const [pendingSettings, setPendingSettings] = useState(settings); + // Reflects the actual booted state, not the pending toggle — `getStatus()` is null until + // crashbox is bootstrapped (at app load, or live via handleSubmit below), and null again after + // shutdown. Recomputed each render, so it flips as soon as Save bootstraps/tears down. + const crashboxActive = getCrashboxStatus() !== null; const handleSubmit = (event) => { event.preventDefault(); if (hasChanges) { updateSettings(pendingSettings); + // Apply the crash-detection toggle live (no reload): bootstrap on enable, tear down on + // disable. `settings` still holds the previously-applied value here, so it's the diff base. + if ( + pendingSettings.experimentalCrashbox && + !settings.experimentalCrashbox + ) { + bootstrapCrashbox(); + breadcrumb("app:boot"); + } else if ( + !pendingSettings.experimentalCrashbox && + settings.experimentalCrashbox + ) { + shutdownCrashbox(); + } setShowSuccess(true); setHasChanges(false); // Hide success message after specified duration @@ -35,6 +59,16 @@ export const Settings = () => { setHasChanges(JSON.stringify(newSettings) !== JSON.stringify(settings)); }; + const handleNumberChange = (settingKey) => (event) => { + const parsed = Number(event.target.value); + const newSettings = { + ...pendingSettings, + [settingKey]: Number.isFinite(parsed) && parsed > 0 ? parsed : 0, + }; + setPendingSettings(newSettings); + setHasChanges(JSON.stringify(newSettings) !== JSON.stringify(settings)); + }; + return html` <${Page} name="Settings" icon="iconoir-tools">

@@ -115,6 +149,19 @@ export const Settings = () => { Enable multi-turn conversations in Chat. + <${Checkbox} + id="enable-thinking" + label="Model Thinking" + checked=${pendingSettings.enableThinking} + onChange=${handleSettingChange("enableThinking")} + > + Let reasoning models (e.g. Qwen3) generate their ${""} + chain-of-thought. Off (default) asks web-llm to skip it for + faster, cleaner answers. When on, the reasoning is hidden from + the answer and viewable via the ${" "} + icon under each response. + + <${Checkbox} id="display-model-stats" label="Display Model Stats" @@ -124,6 +171,19 @@ export const Settings = () => { Show model token limits and info in the UI. + <${Checkbox} + id="experimental-multiple-models" + label="Multiple Models in Memory" + checked=${pendingSettings.experimentalMultipleModels} + onChange=${handleSettingChange("experimentalMultipleModels")} + > + Keep more than one LLM loaded in memory at once. Faster + switching, but each loaded model adds to GPU/RAM use — higher + out-of-memory risk on limited devices. Off (default) keeps one + model in memory; switching unloads the previous (it stays cached + on disk for a fast reload). + +

Embeddings

<${Checkbox} @@ -134,6 +194,45 @@ export const Settings = () => { > Use WebGPU for embeddings extraction when available. + +

Diagnostics

+ + <${Checkbox} + id="experimental-crashbox" + label="Crash Detection" + checked=${pendingSettings.experimentalCrashbox} + onChange=${handleSettingChange("experimentalCrashbox")} + > + Capture browser crashes, WebGPU device-loss events, and + breadcrumbs for recovery on next load. Stores session state in + localStorage.${" "} + + ${crashboxActive ? "Active" : "Inactive"} + + + +
+ + + + Manual memory budget for pressure detection. Leave 0 to + auto-detect from the device. Useful on large desktops where + the browser caps reported memory at 8 GB and under-reports + true RAM. + +
` } diff --git a/public/app/util/think.js b/public/app/util/think.js new file mode 100644 index 0000000..0e19ff3 --- /dev/null +++ b/public/app/util/think.js @@ -0,0 +1,46 @@ +// Helpers for reasoning models (Qwen3, DeepSeek-R1-Distill) that wrap their chain-of-thought in +// `` before the real answer. We hide that block from the rendered answer and expose +// it separately via a developer-mode viewer. Pure string functions — no DOM, safe to import anywhere. + +// Matched non-greedily and case-insensitively; [\s\S] so it spans newlines. +const THINK_BLOCK = /([\s\S]*?)<\/think>/gi; +const OPEN_TAG = ""; + +/** + * Split model output into the user-visible answer and its reasoning in a SINGLE pass over the text. + * + * - `visible`: every `` block removed so the user sees only the answer. A still-open + * `` with no closing tag yet (reasoning mid-stream) is dropped too, so raw tags never flash + * into the answer as it arrives. + * - `thinking`: the contents of every block concatenated (tags stripped), including an unterminated + * trailing block so a viewer updates live while reasoning streams. Empty when there's none — also + * the case when web-llm disables thinking (it emits an empty ``). + * - `hasThinking`: whether `thinking` is non-empty (gates the developer-mode "thinking" icon). + * + * Replaces separate stripThinking/extractThinking/hasThinking calls, which each re-scanned the text; + * callers memoize this per answer so a streaming render re-parses only the entry that changed. + * @param {string} text + * @returns {{ visible: string, thinking: string, hasThinking: boolean }} + */ +export const parseThinking = (text) => { + if (!text) return { visible: text || "", thinking: "", hasThinking: false }; + /** @type {string[]} */ + const blocks = []; + let visible = text.replace(THINK_BLOCK, (_match, inner) => { + blocks.push(inner.trim()); + return ""; + }); + // Any `` left after complete blocks were removed is an unterminated one still streaming: + // everything from it onward is reasoning, not answer. + const open = visible.toLowerCase().indexOf(OPEN_TAG); + if (open !== -1) { + blocks.push(visible.slice(open + OPEN_TAG.length).trim()); + visible = visible.slice(0, open); + } + const thinking = blocks.filter(Boolean).join("\n\n---\n\n"); + return { + visible: visible.replace(/^\s+/, ""), + thinking, + hasThinking: thinking.length > 0, + }; +}; diff --git a/public/index.html b/public/index.html index 5289247..da31db5 100644 --- a/public/index.html +++ b/public/index.html @@ -57,13 +57,18 @@ })(); - +