diff --git a/CHANGELOG.md b/CHANGELOG.md index ed8fed9e..626da204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ notes. Entries describe what users experience, not internal refactors. ### Fixed +- First-visit editor preparation waits for the service worker to control the + page, so warmed font resources remain available in its offline cache. +- A failed recovery snapshot no longer removes an older document when storage + remains full after a retry. +- Recovery files that exceed the storage budget are rejected before their + contents are copied into another memory buffer. +- Unresponsive OpenAI-compatible, Gemini and Ollama requests now time out, + including stalled response reads, so another request can be sent. +- Gemini authentication, rate-limit and service failures now show actionable + guidance without exposing the service's response body. +- Plain assistant requests omit unused tool-calling settings for better + compatibility with configured AI services. +- Oversized AI responses stop before unbounded data accumulates in memory. +- OpenAI-compatible, Gemini, Ollama and local-service chat answers appear + progressively; interrupted streams are not treated as complete replies. +- AI offline preparation now downloads only the runtime capabilities actually + used. Cloud-only setup avoids local inference downloads, and prepared + capabilities remain available after app updates. + - A failed download started while switching documents no longer leaves the editor empty. Closing the editor still cancels an unfinished replacement. - A document still loading can recover from a temporary conversion failure diff --git a/bin/offline-assets.mjs b/bin/offline-assets.mjs index b857d687..34e1733a 100644 --- a/bin/offline-assets.mjs +++ b/bin/offline-assets.mjs @@ -4,27 +4,40 @@ import { join } from 'node:path'; const ASSISTANT_ENTRY = 'lib/agent-plugin/agent-plugin.ts'; const GENERATED_ASSET = /^assets\/[A-Za-z0-9_.-]+\.(?:m?js|css|wasm|woff2)$/; -/** Follow app edges, optionally deferring lazy imports and attached runtime assets. */ -function collectAssets(manifest, roots, excluded = new Set(), includeRuntime = true) { +const CAPABILITIES = ['webgpu', 'wllama', 'wllama-compat', 'cpu', 'cpu-compat', 'pdf']; + +// Match source identities, never emitted hashes or arbitrary page-supplied URLs. +function capabilityFor(source) { + if (/\/wllama-count\/native\/compat\//.test(source)) return 'cpu-compat'; + if (/\/wllama-count\//.test(source) || /\/wllama-cpu-runtime\.(?:js|ts)$/.test(source)) return 'cpu'; + if (/\/node_modules\/@wllama\/wllama-compat\//.test('/' + source)) return 'wllama-compat'; + if (/\/node_modules\/@wllama\/wllama\//.test('/' + source)) return 'wllama'; + if (/\/node_modules\/@mlc-ai\/web-llm\//.test('/' + source)) return 'webgpu'; + if (/\/node_modules\/pdfjs-dist\//.test('/' + source) || source.endsWith('/pdf-reference-reader.ts')) return 'pdf'; +} + +/** Follow edges within one capability; shared UI cycles never pull other engines in. */ +function collectAssets(manifest, roots, assetOwners, allowed, owner) { const visited = new Set(); const assets = new Set(); + const add = (asset) => { + if (!GENERATED_ASSET.test(asset)) throw new Error(`Unsupported offline asset: ${asset}`); + const capability = assetOwners.get(asset) ?? (/(?:^|\/)webllm\.worker-/.test(asset) ? 'webgpu' : undefined); + if (!capability || capability === owner) assets.add(`/${asset}`); + }; const visit = (key) => { - if (visited.has(key) || excluded.has(key)) return; + if (visited.has(key)) return; visited.add(key); const chunk = manifest[key]; if (!chunk) throw new Error(`Missing offline manifest dependency: ${key}`); - // Worker outputs can appear in a static UI chunk's assets without being a - // static import. Keep its styles and fonts, but defer executable payloads. - const attachedAssets = (chunk.assets ?? []).filter((asset) => includeRuntime || !/\.(?:m?js|wasm)$/.test(asset)); - for (const asset of [chunk.file, ...(chunk.css ?? []), ...attachedAssets]) { - if (!GENERATED_ASSET.test(asset)) throw new Error(`Unsupported offline asset: ${asset}`); - assets.add(`/${asset}`); - } - for (const imported of [...(chunk.imports ?? []), ...(includeRuntime ? (chunk.dynamicImports ?? []) : [])]) - visit(imported); + const capability = capabilityFor(chunk.src ?? key); + if (capability && capability !== owner) return; + if (allowed && !allowed.has(key)) return; + for (const asset of [chunk.file, ...(chunk.css ?? []), ...(chunk.assets ?? [])]) add(asset); + for (const imported of [...(chunk.imports ?? []), ...(chunk.dynamicImports ?? [])]) visit(imported); }; for (const root of roots) visit(root); - return assets; + return { assets, visited }; } /** Core app dependencies install first; optional inference assets need explicit opt-in. */ @@ -37,15 +50,24 @@ export async function stampOfflineAssets(directory) { (key) => key === ASSISTANT_ENTRY || manifest[key].src === ASSISTANT_ENTRY, ); if (!entries.length || assistant.length !== 1) throw new Error('Missing offline app or assistant manifest entry'); - const core = collectAssets(manifest, entries, new Set(assistant)); - // Retain the small UI graph for old live pages whose lazy import URLs must - // survive a deploy. Inference engines, Workers and WASM remain optional. - for (const asset of collectAssets(manifest, assistant, new Set(), false)) core.add(asset); - const optional = collectAssets(manifest, assistant); - // Worker outputs may be emitted outside the main manifest's dependency graph. - // They belong to the optional inference runtime, never to the first visit. - for (const name of await readdir(join(directory, 'assets'))) { - if (/\.(?:m?js|css|wasm)$/.test(name)) optional.add(`/assets/${name}`); + const assetOwners = new Map(); + for (const [key, chunk] of Object.entries(manifest)) { + const capability = capabilityFor(chunk.src ?? key); + if (capability) for (const asset of [chunk.file, ...(chunk.assets ?? [])]) assetOwners.set(asset, capability); + } + const { assets: core } = collectAssets(manifest, [...entries, ...assistant], assetOwners); + const capabilityAssets = {}; + // Traversing a runtime root can point back into the UI chunk. Cut those + // shared edges at the already-collected core graph before following lazy imports. + const runtimeKeys = new Set(Object.keys(manifest).filter((key) => !core.has(`/${manifest[key].file}`))); + for (const capability of CAPABILITIES) { + const roots = Object.keys(manifest).filter((key) => capabilityFor(manifest[key].src ?? key) === capability); + const { assets } = collectAssets(manifest, roots, assetOwners, runtimeKeys, capability); + if (capability === 'webgpu') { + for (const chunk of Object.values(manifest)) + for (const asset of chunk.assets ?? []) if (/(?:^|\/)webllm\.worker-/.test(asset)) assets.add(`/${asset}`); + } + capabilityAssets[capability] = [...assets].filter((asset) => !core.has(asset)).sort(); } const fonts = (await readdir(join(directory, 'ran-fonts'))).filter((name) => name.endsWith('.woff2')); for (const name of fonts) core.add(`/ran-fonts/${name}`); @@ -55,11 +77,10 @@ export async function stampOfflineAssets(directory) { if (/^(?:ran-tokens(?:\.[A-Za-z0-9_-]+)?|app-tokens)\.css$/.test(name)) core.add(`/${name}`); if ((await readdir(join(directory, 'ran-fonts'))).includes('fonts.css')) core.add('/ran-fonts/fonts.css'); const assets = [...core].sort(); - const assistantAssets = [...optional].filter((asset) => !core.has(asset)).sort(); const path = join(directory, 'sw.js'); const source = await readFile(path, 'utf8'); const marker = 'const APP_ASSETS_TO_CACHE = []; // BUILD_APP_ASSETS'; - const assistantMarker = 'const ASSISTANT_ASSETS_TO_CACHE = []; // BUILD_ASSISTANT_ASSETS'; + const assistantMarker = 'const ASSISTANT_CAPABILITY_ASSETS = {}; // BUILD_ASSISTANT_CAPABILITIES'; if (!source.includes(marker) || !source.includes(assistantMarker)) throw new Error('Missing offline asset build marker'); await writeFile( @@ -68,7 +89,7 @@ export async function stampOfflineAssets(directory) { .replace(marker, `const APP_ASSETS_TO_CACHE = ${JSON.stringify(assets)}; // BUILD_APP_ASSETS`) .replace( assistantMarker, - `const ASSISTANT_ASSETS_TO_CACHE = ${JSON.stringify(assistantAssets)}; // BUILD_ASSISTANT_ASSETS`, + `const ASSISTANT_CAPABILITY_ASSETS = ${JSON.stringify(capabilityAssets)}; // BUILD_ASSISTANT_CAPABILITIES`, ), ); return assets; diff --git a/docs/explorations/2026-10-17-pr279-ci-followup.md b/docs/explorations/2026-10-17-pr279-ci-followup.md new file mode 100644 index 00000000..77bf9da0 --- /dev/null +++ b/docs/explorations/2026-10-17-pr279-ci-followup.md @@ -0,0 +1,48 @@ +# PR 279: first-visit editor cache race + +The Docker E2E run at commit `5ae9dfdf` failed its landing-page cache assertion +in shard 2. The other 109 cases in that shard passed. The retained trace from +GitHub Actions run `39027953673` showed that the sole missing entry was +`/sdkjs/common/libfont/engine/fonts.js`. + +The font request succeeded, but occurred after `serviceWorker.ready` resolved +and before `clients.claim()` controlled the first-visit page. It populated only +the browser HTTP cache. The prefetcher's request deduplication then prevented a +controlled retry that would populate the worker's offline cache. + +`public/landing-prefetch.js` now waits for actual page control before issuing +warm-up requests. It installs the control listener before rechecking the +controller, removes listeners when settled, and cancels the wait on departure +or worker upgrade. Resuming keeps the URL eligible for a new request. Existing +controlled pages continue immediately, and the upgrade gate still applies. + +Two deterministic regressions first failed against the previous implementation: +one verifies that a resolved readiness promise cannot start an uncontrolled +fetch; the other checks cancellation, late control changes and retry after +returning. Both pass with the fix, along with all 15 landing-prefetch unit tests. +Independent read-only review also checked upgrade and BFCache interactions and +reported no actionable findings. + +The browser assertion now reports the actual list of missing URLs rather than +only their count. Its existing 120-second deadline is unchanged. + +## Verification + +- Full unit coverage: 6,290 tests in 238 files pass; 89.56% lines and 83.08% + branches. All repository coverage gates pass. +- A fresh production Docker image passes all four landing-prefetch Chromium + cases, including all editor engines cached, background core caching, actual + font-catalog requests and an editor open served from the warmed worker cache. +- Archive integrity, repository-wide formatting, oxlint, refreshed workspace + declarations and application TypeScript checks pass. + +```sh +pnpm run test:coverage --maxWorkers=2 +pnpm run format:check +pnpm run lint:ts +docker build -t document:pr279-ci . +docker run -d --rm --name document-pr279-ci -p 127.0.0.1:4199:80 document:pr279-ci +E2E_BASE_URL=http://127.0.0.1:4199 E2E_PORT=4199 pnpm exec playwright test \ + test/e2e/landing-prefetch.spec.ts --workers=1 --reporter=line +docker stop document-pr279-ci +``` diff --git a/docs/explorations/2026-10-17-project-followups.md b/docs/explorations/2026-10-17-project-followups.md new file mode 100644 index 00000000..8f75de57 --- /dev/null +++ b/docs/explorations/2026-10-17-project-followups.md @@ -0,0 +1,144 @@ +# Complete the project analysis follow-ups + +Baseline: `a3b9eb3c`; continuation of PR #279. The user authorized completing +all six items in the original analysis, preserving separate commits and updating +the existing PR. Recovery rollback, request deadlines and Gemini classification +already have verified fixes. This plan covers the remaining gaps. + +## Scope and evidence + +| Item | Current evidence | Planned result | +| ------------------------ | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Response memory bound | `provider-http.ts` appends decoded bytes without a size check | Enforce a configurable total byte ceiling for JSON and streams; cancel on overflow. | +| Optional offline runtime | `offline-assets.mjs` gathers every emitted runtime; `agent-entry.ts` requests all of it | Prepare only selected capability assets; retain prepared capability state through updates. | +| Panel ownership | `panel.ts` owns configuration controls, request execution and workspace coordination | Extract cohesive owners with explicit narrow dependencies; preserve lifecycle and product behavior. | +| Incremental answers | OpenAI, Gemini and Loopback expose only `chat` | Implement their native incremental protocols using existing `chatStream`; keep tool and document writes on completed validated responses. | + +## Change boundaries + +- Request work: `packages/agent-core/src/llm/{provider-http,openai,gemini,ollama,loopback}.ts`, + small protocol helpers, provider unit tests and streaming browser regressions. +- Offline work: `bin/offline-assets.mjs`, `public/{sw,sw-register}.js`, + `lib/assistant-offline.ts`, capability activation call sites and offline tests. +- Panel work: `lib/agent-plugin/ui/panel.ts`, cohesive adjacent owners and focused + UI tests. The offline task owns runtime activation hooks; coordinate overlapping + call sites before editing. +- Parent owns shared changelog, documentation, Git commits/push and PR updates. +- Do not edit immutable evaluation archives, vendored editor/runtime code, + dependencies, save destinations, document approval policy or model defaults. + +## Compatibility and simplest alternatives + +Use existing neutral provider methods and runtime events rather than a new UI +streaming contract. Stream only tool-free requests initially; tool-bearing +requests fall back to the complete response path. Use build-owned capability +allowlists rather than accepting asset URLs from pages. Extract concrete panel +responsibilities without introducing a generic dependency framework. Preserve +caller cancellation, the five-minute total deadline and safe status errors. + +A missing terminal stream marker is a failure, not a completed answer. Keep +schema-constrained document generation complete and validated before applying. +Cloud requests never start local inference downloads. Prepared capabilities must +remain usable after service worker updates and in offline sessions. + +## Execution and acceptance + +1. Add failing response-size and native streaming regressions; implement bounded + shared transport plus OpenAI/Gemini/Ollama and Loopback support. +2. In parallel, split offline capabilities and panel owners in disjoint files; + run focused tests and reverse-verify changed behavior. +3. Review every task against its consumers, then run full unit coverage, lint, + types, formatting, build and targeted real-browser regressions including + recovery, requests, streaming and offline upgrades. +4. Commit each topic independently, push and update PR #279 with final evidence. + +This document records the accepted implementation boundary for the user's +request to complete the original list. Regression tests establish runtime +behavior; source inspection alone does not establish memory or timing gains. + +## Implementation evidence + +- Added a configurable 8 MiB response ceiling. Twelve byte-bound regressions + failed without it; 47 combined HTTP lifetime/bound tests passed afterward. + Limits count the actual stream bytes, so a missing or false Content-Length + cannot bypass them. Host-only `json()` transports retain a post-read check. +- Shared transport now owns bounded incremental decoding and early consumer + cancellation. Tool-free OpenAI/Ollama use SSE, Gemini uses + `streamGenerateContent?alt=sse`, Loopback uses native NDJSON. Compatible complete + JSON responses remain accepted without resending the request. Schema generation + and tool-bearing requests keep the complete-response path. +- Thirty-two streaming regressions cover byte-split UTF-8 and CRLF, early text, + incomplete EOF, total deadline, size ceiling, host JSON responses, callback + failure, reentrant Stop, thought exclusion and safe in-band errors. The initial + 24 cases failed while the APIs were absent. Independent review reproduced two + further bugs (Gemini terminal event waited for EOF; Loopback rejected bodyless + host JSON); their tests failed then passed with fixes. Scoped re-review is clean. +- Native protocol references checked during implementation: + [OpenAI streaming events](https://developers.openai.com/api/reference/resources/chat/subresources/completions/streaming-events), + [Gemini generate content](https://ai.google.dev/api/generate-content), and + [Ollama chat](https://docs.ollama.com/api/chat). Fixtures exercise local adapters; + they do not certify a live account or endpoint. +- Offline build graphs now separate WebGPU, Wllama, CPU, compatibility runtimes + and PDF. Runtime activation callbacks request only the matching allowlist. + Per-capability markers restore the union across older worker caches and retain + legacy prepared users. Remote-only setup performs no local runtime preparation. +- Panel model configuration, destination policy, deterministic command execution + and workspace navigation have concrete adjacent owners. Request/session + lifetimes remain with the existing lifecycle owners. The panel shrank from + 3,480 to 3,116 lines. Cross-review also removed unwanted WebGPU cache discovery + during service/CPU setup; explicit cache inventory remains available. +- Integrated review found the same unwanted scan after CPU model preload. Its + regression observed four GPU cache checks while the CPU model was already + usable; both automatic refresh sites now require WebGPU selection or explicit + inventory visibility. A native delayed disclosure event also triggered scans + after panel removal; its failing regression now checks the connected-state + guard. Tests wait for the native event instead of manually dispatching it. +- The first combined coverage/build run reported three wall-clock timeouts in + unchanged large-inflation/model-byte fixtures. All 18 tests in those two files + passed in a single-worker rerun without changing test deadlines. Two SW + lifetime cases exposed an incomplete Cache mock; adding native `match` + semantics preserved their existing retention/deletion assertions, and 197 + SW/offline tests passed. Final verification below uses a new assembled run. + +## Final verification + +- Full unit coverage: **6,288 tests in 238 files pass**, with 89.56% lines, + 83.08% branches, 87.90% functions and 86.99% statements. Every repository gate + passes. Command: `pnpm run test:coverage --maxWorkers=2`. +- Archive integrity, full lint/workspace declaration/app type checks and full + formatting pass. The final cache-lifecycle changes additionally pass focused + oxlint and app TypeScript checks against refreshed workspace declarations. +- Production build passes on final sources. Existing large-chunk and ineffective + dynamic-import warnings remain; this work does not claim to eliminate every + bundler warning. +- Independent scoped and integrated reviews are clean after the terminal-stream, + host-JSON, post-load CPU-cache and detached-disclosure corrections. +- All 35 distinct Chromium cases are verified. The main run passed 34 and found + a rewritten offline fixture missing its baseline online editor warm-up. The + trace identified the intentionally on-demand vendor SDK chunk, while CPU + assets were already available offline. Restoring that precondition yielded + 3/3 passing offline cases in 9.1 seconds; no production change was needed. + +Measured optional asset sizes from the production capability lists (raw files, +not compressed transfer size): WebGPU 11.42 MiB; Wllama 8.26 MiB; its compatibility +runtime 14.82 MiB; CPU 6.42 MiB; CPU compatibility 12.52 MiB; PDF 1.41 MiB. A +runtime prepares its compatibility capability only when the platform needs it. + +Browser verification command (production build served by Vite preview): + +```sh +E2E_BASE_URL=http://127.0.0.1:4197 E2E_PORT=4197 pnpm exec playwright test \ + test/e2e/history-quota.spec.ts test/e2e/agent-error-recovery.spec.ts \ + test/e2e/autosave-recovery.spec.ts test/e2e/main-site.spec.ts \ + test/e2e/agent-request-queue.spec.ts test/e2e/agent-cloud-streaming.spec.ts \ + test/e2e/assistant-offline-assets.spec.ts test/e2e/agent-document-references.spec.ts \ + test/e2e/agent-workspace.spec.ts test/e2e/agent-workspace-consistency.spec.ts \ + test/e2e/agent-sources-artifacts.spec.ts --workers=1 --reporter=line +``` + +This verification uses Chromium and deterministic endpoint/storage faults. It +covers real editor/UI, IndexedDB, CacheStorage and service-worker behavior. It +does not certify live cloud provider accounts, physical disk exhaustion, a real +model inference on every backend, or complete Firefox/WebKit/Docker/hosting +coverage of this final revision. Upgrade restoration uses genuine persisted +older-build CacheStorage markers, without changing a deployed worker script. diff --git a/docs/explorations/2026-10-17-recovery-and-provider-request-safety.md b/docs/explorations/2026-10-17-recovery-and-provider-request-safety.md new file mode 100644 index 00000000..c9a9ba89 --- /dev/null +++ b/docs/explorations/2026-10-17-recovery-and-provider-request-safety.md @@ -0,0 +1,140 @@ +# Recovery and provider request safety + +Baseline: `ac2dcade`. Scope: failed quota retries, fetch-provider deadlines and +Gemini service-error guidance. No storage schema, retention budget, model default, +provider destination, inference runtime or document-write behavior changes. + +## Recovery quota retry + +Previously, the first quota failure caused an independent transaction to delete +the coldest recovery document before retrying the new snapshot. If the retry +also failed, the old recovery was already gone and the new snapshot was absent. + +The retry now owns both eviction and replacement in one transaction across +metadata, snapshot bytes and disk handles. Cancellation or any second write +failure aborts that transaction. Commit-only save-target cleanup therefore runs +only after a successful replacement. There is still at most one retry, and the +current document remains protected from eviction. Successful quota recovery and +ordinary retention keep their existing behavior. A browser that cannot reclaim +space within the transaction may still refuse the write; it must retain the +previous recovery instead of trading it for a failed snapshot. + +Unit regressions inject both a repeated quota failure and a different error on +retry, then check metadata and bytes for the old and current documents. A browser +regression edits a real document, triggers its departure snapshot and injects +two quota errors in real IndexedDB. It checks that the old document remains in +the history page with byte-for-byte identical recovery content. + +## AI HTTP request lifetime + +OpenAI-compatible, Gemini and Ollama adapters share a small HTTP boundary. +The default total deadline is five minutes, matching the existing Loopback +duration; constructor/factory callers may override `timeoutMs`. One timer covers +headers and body consumption. Cancellation forwards the original caller reason; +deadline expiry produces `TimeoutError`. The boundary races transport/body work +with cancellation so a non-cooperative injected transport cannot retain the +controller indefinitely. The boundary owns the response stream reader and +cancels it directly on timeout or Stop, then releases the reader lock. Late +responses are rejected and their unused bodies cancelled. Settlement removes +timers and caller listeners. + +Ollama also accepts the existing neutral provider cancellation argument. The +Anthropic SDK, Loopback and browser inference adapters are unchanged. + +HTTP errors carry `status` and only a safe provider/status message. Error bodies +are cancelled without reading them, avoiding stalled error streams and private +upstream details. The UI recognizes Gemini as well as existing provider messages +when a boundary serializes the error to text. Existing localized guidance is +reused. A timeout is a failure rather than a user-requested Stop, and no implicit +resend occurs. A fresh explicit request may run after the previous one settles. + +## Verification + +The initial history regressions failed against the baseline because the old +document was absent after the retry. The initial provider regressions failed +because requests remained pending or HTTP errors lacked structured status. +An assembled reverse run against the baseline reproduced 22 expected failing +regressions across history and provider handling. + +Independent read-only review identified that native `response.json()` could +retain a locked body when a host transport ignored cancellation. Three real +stream regressions failed before replacing it with an owned reader, then passed. +The reviewer rechecked cleanup, including a stream whose cancellation promise +never settles, and confirmed equivalent UTF-8/BOM decoding across 59 split-chunk +probe cases. No actionable findings remain from that review. + +The two new Chromium regressions also failed against an isolated build with +the baseline history and cloud-provider implementations: recovery metadata was +empty after quota failure, and Gemini displayed generic failure instead of +rate-limit guidance. Source files were not changed for this browser check. + +Initial assembled verification: + +- Full unit coverage: 6,223 tests in 233 files pass; 89.38% lines and 82.90% + branches. All repository coverage gates pass. +- Ten Chromium cases pass: quota failure with original bytes retained, Gemini + and existing service-error recovery, Word/PPT request queues and Stop, actual + autosave/reopen/embedded isolation, Word editing/save and Excel create/save. +- Archive integrity, oxlint, workspace declaration builds and application + TypeScript checks pass via `pnpm run lint:ts`. +- The production build passes. Existing large-chunk and ineffective dynamic + import warnings remain outside this change. + +Verification commands: + +```sh +pnpm run test:coverage --maxWorkers=4 +pnpm run lint:ts +pnpm run format:check +pnpm run build +E2E_BASE_URL=http://127.0.0.1:4197 E2E_PORT=4197 pnpm exec playwright test \ + test/e2e/history-quota.spec.ts test/e2e/agent-error-recovery.spec.ts \ + test/e2e/autosave-recovery.spec.ts test/e2e/main-site.spec.ts \ + test/e2e/agent-request-queue.spec.ts --workers=1 --reporter=line +``` + +The browser commands use the production build served by Vite preview. This pass +does not claim complete E2E, Firefox/WebKit, Docker, Cloudflare hosting or live +cloud-provider acceptance. Network failures and full storage are deterministic +fault injections; real account quotas and physical disk exhaustion are not +required or exercised. + +## Follow-up: reject oversized exports before reading bytes + +The snapshot store previously checked its byte budget after materializing a +Blob/File. Autosave also eagerly read the exported File before delegating to the +store. A known oversized export therefore still allocated another full buffer. + +The store now checks the known input size before conversion, and autosave passes +the exported File directly with its existing cancellation signal. The later +actual-byte check remains. Three regressions first failed because `arrayBuffer` +was called once; the fix must reduce that count to zero for oversized Blob, +File and actual autosave inputs while retaining the previous recovery bytes. +This is an operation-count improvement, not a measured heap-size claim. + +## Follow-up: omit unused tool-calling fields + +OpenAI-compatible and Ollama requests previously included an empty `tools` array +and `tool_choice: auto` even for plain chat or writing without tools. They now +omit both fields when the registry is empty, following the existing Gemini +adapter's conditional tool declaration. Requests with tools keep the same tool +definitions and automatic selection. Two strict-endpoint regression fixtures +reject unused tool fields; both failed with HTTP 400 before the fix and return +their plain answer afterward. These fixtures validate the adapter contract and +do not claim a live vendor endpoint was tested. + +## Final follow-up verification + +Independent review of the two follow-ups found no actionable defects, including +the autosave cancellation/session boundary and requests with nonempty tools. + +- Full unit coverage: 6,228 tests in 233 files pass; 89.40% lines and 82.91% + branches. All repository coverage gates pass. +- The same ten Chromium cases pass on the final production build in 58.9 seconds. +- Archive integrity, lint, workspace declaration builds, application TypeScript + and the production build pass. The existing bundler warnings are unchanged. +- All five added follow-up regressions were reverse-verified before the fixes: + three oversized-input cases and two strict-compatible-endpoint cases. + +The verification commands and coverage limits described above also apply to +this follow-up pass. diff --git a/lib/agent-entry.ts b/lib/agent-entry.ts index 7e4af86d..fe19e2ad 100644 --- a/lib/agent-entry.ts +++ b/lib/agent-entry.ts @@ -1,4 +1,3 @@ -import { prepareAssistantOffline } from './assistant-offline'; import { t } from '@ranuts/shared/i18n'; import { isEmbedMode } from './embed-mode'; import { readAssistantEnabled, saveAssistantEnabled } from './agent-preferences'; @@ -8,8 +7,6 @@ import { mountPanelViewport } from './agent-plugin/ui/panel-viewport'; /** Keep the discovery and opt-in surface independent of the inference runtime. */ export function createAgentEntry() { let enabled = readAssistantEnabled(); - // A persisted opt-in prepares this build without importing or starting a model. - if (enabled && !isEmbedMode()) void prepareAssistantOffline(); let open = false; let disposed = false; let busy = false; @@ -87,7 +84,6 @@ export function createAgentEntry() { }, }); await runtime.setAgentPanelEnabled(true); - void prepareAssistantOffline(); } else { enabled = false; saveAssistantEnabled(false); @@ -143,10 +139,6 @@ export function createAgentEntry() { note.textContent = t('agentStartNote'); }; window.addEventListener('languagechange', onLanguage); - const onControllerChange = () => { - if (enabled && !disposed && !isEmbedMode()) void prepareAssistantOffline(); - }; - navigator.serviceWorker?.addEventListener('controllerchange', onControllerChange); note.textContent = t('agentStartNote'); sync(); const onPageHide = (event: PageTransitionEvent) => { @@ -155,7 +147,6 @@ export function createAgentEntry() { if (!event.persisted) { disposed = true; window.removeEventListener('languagechange', onLanguage); - navigator.serviceWorker?.removeEventListener('controllerchange', onControllerChange); } }; // Preserve the shell after a BFCache restore; no model is restarted. @@ -164,7 +155,6 @@ export function createAgentEntry() { sidebar = mountSidebar(); viewportDispose = mountPanelViewport(shell); sync(); - onControllerChange(); } }; window.addEventListener('pagehide', onPageHide); diff --git a/lib/agent-plugin/connectors/pdf-reference-reader.ts b/lib/agent-plugin/connectors/pdf-reference-reader.ts index 56a52097..d38d3d16 100644 --- a/lib/agent-plugin/connectors/pdf-reference-reader.ts +++ b/lib/agent-plugin/connectors/pdf-reference-reader.ts @@ -1,7 +1,9 @@ +import { prepareAssistantOffline } from '../../assistant-offline'; import { withReferenceAbort } from './reference-lifetime'; /** PDF.js runs parsing in its bundled worker; no editor, rendering, fonts or remote URLs. */ export async function readPdfReference(bytes: Uint8Array, signal?: AbortSignal): Promise { signal?.throwIfAborted(); + void prepareAssistantOffline('pdf'); const pdfjs = await (signal ? withReferenceAbort(import('pdfjs-dist'), signal) : import('pdfjs-dist')); const { default: workerUrl } = await (signal ? withReferenceAbort(import('pdfjs-dist/build/pdf.worker.min.mjs?url'), signal) diff --git a/lib/agent-plugin/ui/direct-document-request.ts b/lib/agent-plugin/ui/direct-document-request.ts new file mode 100644 index 00000000..1d3ec90a --- /dev/null +++ b/lib/agent-plugin/ui/direct-document-request.ts @@ -0,0 +1,96 @@ +import { getLanguage, t } from '@ranuts/shared/i18n'; +import type { LLMMessage } from '@ranuts/agent-core/llm/types'; +import type { DirectDocumentIntent } from '../direct-intent'; +import { getEditorApi } from '../editor-bridge'; +import { agentTools } from '../tools'; +import { DocumentToolAction, type DocumentToolTarget } from '../document-tool-action'; +import type { ChatTurn } from './controller'; + +type DirectRequestMessage = LLMMessage & { hostGuidance?: 'tool' | 'status' | 'error' }; +/** Prepare scope synchronously before queue acceptance; execute only the closed command grammar. */ +export function prepareDirectDocumentRequest( + intent: Exclude, + text: string, + target: DocumentToolTarget | null, + answer: string, +) { + const chinese = getLanguage().startsWith('zh'); + if (!target?.isCurrent(false)) throw new Error(t('agentPlanExpired')); + const readOnly = + (intent.kind === 'sum_range' && !intent.target) || (intent.kind === 'slide' && intent.action === 'navigate'); + const directTarget = readOnly ? target : (target.prepareWrite?.() ?? target); + if (!directTarget.isCurrent(!readOnly)) throw new Error(t('agentPlanExpired')); + if (intent.kind === 'write_reply') { + if (!answer.trim()) throw new Error('agentNoCompletedAnswer'); + if (directTarget.context.kind !== 'word') throw new Error(t('agentWordOnly')); + if (intent.requireSelection && !directTarget.selectedText.trim()) throw new Error(t('agentNoSelection')); + } + if ( + intent.kind === 'align' && + /当前段落|current paragraph/i.test(text) && + getEditorApi()?.pluginMethod_GetSelectedText().trim() + ) + throw new Error('agentParagraphSelectionConflict'); + return { + async execute(signal: AbortSignal) { + let name: string; + let input: Record; + let result: unknown; + if (intent.kind === 'write_reply') { + const target = directTarget; + name = 'insert_text'; + input = { text: answer, format: 'markdown' }; + const action = new DocumentToolAction(target, { tool: 'insert_text', input, readOnly: false }); + try { + result = { outcome: await action.apply(signal) }; + } finally { + action.dispose(); + } + } else if (intent.kind === 'sum_range' || intent.kind === 'sort_range' || intent.kind === 'slide') { + name = intent.kind === 'slide' ? 'slide_action' : intent.kind; + const { kind: _kind, ...parameters } = intent; + input = parameters; + result = await agentTools[name].execute(input, signal); + } else { + name = intent.kind === 'bold' ? 'set_bold' : 'set_paragraph_alignment'; + input = intent.kind === 'bold' ? { enabled: intent.enabled } : { alignment: intent.alignment }; + result = await agentTools[name].execute(input, signal); + } + signal.throwIfAborted(); + if (intent.kind !== 'write_reply' && (result as { verified?: unknown } | undefined)?.verified !== true) + throw new Error(t('agentPlanUnverified')); + const status = + intent.kind === 'write_reply' + ? (result as { outcome?: string }).outcome === 'verified' + ? ('success' as const) + : ('failed' as const) + : ('success' as const); + const messages: DirectRequestMessage[] = []; + const id = `direct-${crypto.randomUUID()}`; + messages.push( + { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] }, + { role: 'user', content: [{ type: 'tool_result', toolUseId: id, content: JSON.stringify(result) }] }, + ); + if (intent.kind === 'sum_range' && !intent.target) + messages.push({ + role: 'assistant', + content: `${intent.range}: ${(result as { sum: number }).sum}`, + hostGuidance: 'tool', + }); + const turn: ChatTurn = { + role: 'tool', + text: + intent.kind === 'sum_range' && !intent.target + ? `${intent.range}: ${(result as { sum: number }).sum}` + : intent.kind === 'slide' && intent.action === 'navigate' + ? chinese + ? `已切换到第 ${intent.page} 页幻灯片` + : `Opened slide ${intent.page}` + : intent.kind === 'write_reply' && (result as { outcome?: string }).outcome !== 'verified' + ? t('agentPlanApplied') + : t('agentPlanVerified'), + }; + return { status, messages, turn }; + }, + }; +} diff --git a/lib/agent-plugin/ui/model-cache-settings.ts b/lib/agent-plugin/ui/model-cache-settings.ts index 9901ea95..167ec624 100644 --- a/lib/agent-plugin/ui/model-cache-settings.ts +++ b/lib/agent-plugin/ui/model-cache-settings.ts @@ -235,7 +235,8 @@ export function createModelCacheSettings(options: { } }; cacheList.addEventListener('toggle', () => { - if (cacheList.open) void refreshCache(); + // Native toggle events are queued and may settle after this panel is removed. + if (cacheList.isConnected && cacheList.open) void refreshCache(); }); const release = ranButton(t('agentReleaseMemory'), 'agent-release-memory'); release.hidden = true; diff --git a/lib/agent-plugin/ui/model-configuration-form.ts b/lib/agent-plugin/ui/model-configuration-form.ts new file mode 100644 index 00000000..a4aef315 --- /dev/null +++ b/lib/agent-plugin/ui/model-configuration-form.ts @@ -0,0 +1,362 @@ +import 'ranui/button'; +import 'ranui/input'; +import 'ranui/select'; +import { Div, Span, View } from 'ranui/builder'; +import { localStorageGetItem, localStorageSetItem } from 'ranuts/utils'; +import { t, type I18nMessages } from '@ranuts/shared/i18n'; +import type { ProviderId } from '@ranuts/agent-core/llm/factory'; +import { DEFAULT_WEBLLM_MODEL, WEBLLM_MODELS } from '@ranuts/agent-core/llm/webllm'; +import { clearEndpointKey, getEndpointKey, setEndpointKey } from '@ranuts/agent-core/llm/keys'; +import { DEFAULT_LOOPBACK_ENDPOINT, type WritingEndpointKind } from '@ranuts/agent-core/llm/endpoint'; +import { resolveModelArtifactUrl } from '@ranuts/agent-core/llm/model-source'; +import { readTaskModelPreferences } from './task-model-preferences'; +import { + ENDPOINT_SETTINGS_KEY, + isCloudEndpointKind, + writeEndpointSettings, + type EndpointSettings, +} from './endpoint-settings'; + +/** ranui custom elements expose a `value` accessor (r-select / r-input). */ +type ValueEl = HTMLElement & { value: string }; +type InputEl = ValueEl & { placeholder: string }; +export function rememberedModelUrl(value: string | null): string { + if (!value?.trim()) return ''; + try { + return resolveModelArtifactUrl(value.trim()); + } catch { + return ''; + } +} + +const PROVIDER_LABEL_KEY: Record = { + anthropic: 'agentProviderClaude', + openai: 'agentProviderOpenAI', + gemini: 'agentProviderGemini', + webllm: 'agentProviderLocal', + wllama: 'agentProviderWllama', + ollama: 'agentProviderOllama', + loopback: 'agentProviderLoopback', +}; +// The loopback service is not a browser engine: it has its own settings block +// rather than a slot in the provider dropdown. +const PROVIDER_IDS: ProviderId[] = ['webllm', 'wllama']; + +/** An r-button (ranui builder) with a label and class. */ +const ranButton = (text: string, className: string): HTMLElement => + View('r-button').class(className).text(text).build(); + +/** Programmatic focus on a ranui host must retain keyboard activation. */ +const focusableAction = (button: HTMLElement): void => { + button.tabIndex = -1; + button.addEventListener('keydown', (event) => { + // The component's inner button already handles ordinary tab navigation. + if (event.defaultPrevented || button.hasAttribute('disabled') || !['Enter', ' '].includes(event.key)) return; + event.preventDefault(); + button.click(); + }); +}; + +/** An r-select with r-option children and an initial value (ranui builder). */ +const ranSelect = (className: string, options: Array<{ value: string; label: string }>, value: string): ValueEl => + View('r-select') + .class(className) + .attr('value', value) + .children(options.map((o) => View('r-option').attr('value', o.value).text(o.label).build())) + .build() as ValueEl; + +/** An r-input of the given type (ranui builder). */ +const ranInput = (className: string, type: string): InputEl => + View('r-input').class(className).attr('type', type).build() as InputEl; + +/** Form controls, public model preferences and accessible feedback; never activates a runtime. */ +export function createModelConfigurationForm(options: { + endpointSettings: EndpointSettings; + load: () => void; + stop: (loadControl: HTMLElement) => void; + sourceChanged: () => void; +}) { + let endpointSettings = options.endpointSettings; + const providerSelect = ranSelect( + 'agent-panel-provider', + PROVIDER_IDS.map((id) => ({ value: id, label: t(PROVIDER_LABEL_KEY[id]) })), + localStorageGetItem('agent-panel-provider') === 'wllama' ? 'wllama' : 'webllm', + ); + providerSelect.setAttribute('aria-label', t('agentProviderLabel')); + + const ggufUrl = ranInput('agent-panel-gguf-url', 'text'); + ggufUrl.placeholder = 'https://…/model.gguf'; + ggufUrl.value = rememberedModelUrl(localStorageGetItem('agent-panel-gguf-url')); + const ggufFiles = document.createElement('input'); + ggufFiles.type = 'file'; + ggufFiles.multiple = true; + ggufFiles.accept = '.gguf'; + ggufFiles.className = 'agent-panel-gguf-files'; + const ggufChooseText = document.createElement('span'); + const ggufChoose = document.createElement('label'); + ggufChoose.className = 'agent-panel-gguf-choose'; + ggufChoose.append(ggufChooseText, ggufFiles); + const ggufNames = document.createElement('span'); + ggufNames.className = 'agent-panel-gguf-filenames'; + ggufNames.setAttribute('aria-live', 'polite'); + const syncFileChoice = () => { + ggufChooseText.textContent = t('agentChooseModelFiles'); + ggufFiles.setAttribute('aria-label', t('agentChooseModelFiles')); + ggufNames.textContent = Array.from(ggufFiles.files ?? []) + .map((file) => file.name) + .join(', '); + ggufNames.title = ggufNames.textContent; + }; + syncFileChoice(); + const ggufLoad = ranButton(t('agentLoadModel'), 'agent-panel-gguf-load'); + focusableAction(ggufLoad); + ggufLoad.addEventListener('click', () => options.load()); + const ggufCpu = document.createElement('input'); + ggufCpu.type = 'checkbox'; + ggufCpu.checked = true; + ggufCpu.className = 'agent-panel-gguf-cpu'; + const ggufStop = ranButton(t('agentStop'), 'agent-panel-gguf-stop'); + ggufStop.setAttribute('type', 'text'); + focusableAction(ggufStop); + ggufStop.hidden = true; + ggufStop.addEventListener('click', () => { + options.stop(ggufLoad); + }); + const ggufRow = Div().class('agent-panel-gguf-row').children(ggufUrl, ggufChoose, ggufNames, ggufLoad).build(); + + // Public artifact URLs and API endpoint preferences; never store a key here. + const sourceInput = (className: string, placeholder: string): InputEl => { + const input = ranInput(className, 'text'); + input.placeholder = placeholder; + input.setAttribute('aria-label', placeholder); + input.value = + localStorageGetItem(className) || + ( + { + 'agent-local-model-id': import.meta.env.VITE_LOCAL_MODEL_ID, + 'agent-local-model-url': import.meta.env.VITE_LOCAL_MODEL_URL, + 'agent-local-model-lib': import.meta.env.VITE_LOCAL_MODEL_LIB_URL, + } as Record + )[className] || + ''; + input.addEventListener('change', () => { + localStorageSetItem(className, input.value.trim()); + options.sourceChanged(); + }); + return input; + }; + const localModelId = sourceInput('agent-local-model-id', 'Custom MLC model ID (optional)'); + const localModelUrl = sourceInput('agent-local-model-url', 'MLC model directory URL (/models/…)'); + const localModelLib = sourceInput('agent-local-model-lib', 'Compatible model WASM URL (/models/…/model.wasm)'); + // Local: model picker + load button + const modelSelect = ranSelect( + 'agent-panel-model', + WEBLLM_MODELS.map((model) => ({ + value: model.id, + label: model.label, + })), + DEFAULT_WEBLLM_MODEL, + ); + const presetIds = WEBLLM_MODELS.map((model) => model.id); + const taskPreferences = readTaskModelPreferences(localStorageGetItem('agent-task-models'), presetIds); + const savedPreset = localStorageGetItem('agent-local-preset'); + if (WEBLLM_MODELS.some((model) => model.id === savedPreset)) modelSelect.value = savedPreset!; + else if (savedPreset) localStorageSetItem('agent-local-preset', DEFAULT_WEBLLM_MODEL); + modelSelect.setAttribute('aria-label', t('agentModelLabel')); + const loadBtn = ranButton(t('agentLoadModel'), 'agent-panel-load'); + focusableAction(loadBtn); + loadBtn.addEventListener('click', () => options.load()); + const modelRow = Div().class('agent-panel-model-row').children(modelSelect, loadBtn).build(); + const modelMemory = document.createElement('p'); + modelMemory.className = 'agent-model-memory'; + modelMemory.id = 'agent-model-memory'; + modelSelect.setAttribute('aria-describedby', 'agent-model-memory'); + const syncModelMemory = () => { + modelMemory.hidden = providerSelect.value === 'wllama'; + const model = WEBLLM_MODELS.find((item) => item.id === modelSelect.value); + modelMemory.textContent = model ? t('agentModelMemory').replace('{memory}', (model.vramMB / 1000).toFixed(2)) : ''; + }; + modelSelect.addEventListener('change', syncModelMemory); + providerSelect.addEventListener('change', syncModelMemory); + syncModelMemory(); + const loadStop = ranButton(t('agentStop'), 'agent-panel-load-stop'); + loadStop.setAttribute('type', 'text'); + focusableAction(loadStop); + loadStop.hidden = true; + loadStop.addEventListener('click', () => { + options.stop(loadBtn); + }); + + // Detailed cache and download hints remain in settings; the compact runtime + // row exposes progress and cancellation in the main conversation view. + const note = Div().class('agent-panel-note').build(); + note.setAttribute('role', 'status'); + + // ── Writing destination ────────────────────────────────────────────────── + // Writing into a document is high-stakes, and the browser-local engines never + // reached seven-language quality acceptance. The user may instead point the + // assistant at a model server: a loopback service on this machine (the text + // stays on the device) or a cloud endpoint with their own key (the selected + // text is sent there). The resolved destination is always shown, because the + // two are not equivalent and the choice belongs to the user. + const endpointKind = ranSelect( + 'agent-endpoint-kind', + [ + { value: 'loopback', label: t('agentEndpointLoopback') }, + { value: 'openai-compatible', label: t('agentEndpointOpenAICompatible') }, + { value: 'anthropic', label: t('agentEndpointAnthropic') }, + { value: 'gemini', label: t('agentEndpointGemini') }, + ], + endpointSettings.kind, + ); + endpointKind.setAttribute('aria-label', t('agentEndpointKind')); + const endpointBaseUrl = ranInput('agent-panel-endpoint-url', 'text'); + endpointBaseUrl.value = + endpointSettings.baseUrl || (endpointSettings.kind === 'loopback' ? DEFAULT_LOOPBACK_ENDPOINT : ''); + endpointBaseUrl.placeholder = + endpointSettings.kind === 'loopback' ? DEFAULT_LOOPBACK_ENDPOINT : 'https://api.example.com/v1'; + endpointBaseUrl.setAttribute('aria-label', t('agentEndpointUrl')); + const endpointModel = ranInput('agent-panel-endpoint-model', 'text'); + endpointModel.value = endpointSettings.model; + endpointModel.placeholder = 'qwen3:8b'; + endpointModel.setAttribute('aria-label', t('agentEndpointModel')); + const endpointKey = ranInput('agent-panel-endpoint-key', 'password'); + endpointKey.placeholder = t('agentEndpointKey'); + endpointKey.setAttribute('aria-label', t('agentEndpointKey')); + const endpointConnect = ranButton(t('agentEndpointConnect'), 'agent-panel-endpoint-connect'); + const endpointStatus = Span() + .class('agent-panel-endpoint-status') + .attr('role', 'status') + .id('agent-endpoint-status') + .build(); + for (const control of [endpointKind, endpointBaseUrl, endpointModel, endpointKey]) + control.setAttribute('aria-describedby', 'agent-endpoint-status'); + const writeDestination = Span().class('agent-panel-write-destination').attr('role', 'status').build(); + const writePreference = ranSelect( + 'agent-write-preference', + [ + { value: 'device-first', label: t('agentWritePreferDevice') }, + { value: 'remote-first', label: t('agentWritePreferRemote') }, + ], + endpointSettings.preference, + ); + writePreference.setAttribute('aria-label', t('agentWritePreference')); + const localWritingConsent = document.createElement('input'); + localWritingConsent.type = 'checkbox'; + localWritingConsent.className = 'agent-panel-local-writing'; + localWritingConsent.checked = endpointSettings.localWritingConsent; + const consentLabel = document.createElement('label'); + consentLabel.className = 'agent-panel-local-writing-label'; + consentLabel.append(localWritingConsent, t('agentLocalWritingConsent')); + const endpointRow = Div() + .class('agent-panel-endpoint') + .children( + Span().class('agent-panel-endpoint-title').text(t('agentWriteDestinationTitle')).build(), + endpointKind, + endpointBaseUrl, + endpointModel, + endpointKey, + endpointConnect, + endpointStatus, + writePreference, + writeDestination, + consentLabel, + ) + .build(); + + const endpointKindValue = (): WritingEndpointKind => endpointKind.value as WritingEndpointKind; + const endpointKeySlot = (kind: WritingEndpointKind): string => + kind === 'openai-compatible' ? endpointBaseUrl.value.trim() : kind; + const rememberEndpoint = (): void => { + const next: EndpointSettings = { + kind: endpointKindValue(), + baseUrl: endpointBaseUrl.value.trim(), + model: endpointModel.value.trim(), + preference: writePreference.value === 'remote-first' ? 'remote-first' : 'device-first', + localWritingConsent: localWritingConsent.checked, + }; + endpointSettings = next; + try { + localStorageSetItem(ENDPOINT_SETTINGS_KEY, writeEndpointSettings(next)); + } catch { + // An endpoint the route would refuse is never persisted. + } + if (isCloudEndpointKind(next.kind)) { + const slot = endpointKeySlot(next.kind); + // No address yet means no slot to address; the key is simply not stored. + // Clearing the field removes the stored key: a secret the user cannot + // delete is worse than one they have to retype. + if (slot) { + if (endpointKey.value.trim()) setEndpointKey(slot, endpointKey.value.trim()); + else clearEndpointKey(slot); + } + } + }; + + // Filling the key field is a deliberate act (mount, or a different endpoint + // kind). Rendering status must not do it: it would overwrite a key the user is + // in the middle of typing, before it has been stored. + const loadStoredKeyIntoForm = (): void => { + const kind = endpointKindValue(); + const slot = isCloudEndpointKind(kind) ? endpointKeySlot(kind) : ''; + endpointKey.value = slot ? (getEndpointKey(slot) ?? '') : ''; + }; + + const syncLabels = (): void => { + providerSelect.setAttribute('aria-label', t('agentProviderLabel')); + modelSelect.setAttribute('aria-label', t('agentModelLabel')); + const selected = providerSelect.value; + for (const option of providerSelect.querySelectorAll('r-option')) + option.textContent = t(PROVIDER_LABEL_KEY[(option.getAttribute('value') as ProviderId) ?? 'anthropic']); + providerSelect.setAttribute('value', selected); + syncFileChoice(); + loadStop.textContent = t('agentStop'); + ggufStop.textContent = t('agentStop'); + for (const option of modelSelect.querySelectorAll('r-option')) { + const model = WEBLLM_MODELS.find((item) => item.id === option.getAttribute('value')); + if (model) option.textContent = model.label; + } + syncModelMemory(); + }; + return { + providerSelect, + ggufUrl, + ggufFiles, + ggufNames, + ggufLoad, + ggufCpu, + ggufStop, + ggufRow, + localModelId, + localModelUrl, + localModelLib, + modelSelect, + taskPreferences, + savedPreset, + loadBtn, + modelRow, + modelMemory, + loadStop, + note, + endpointKind, + endpointBaseUrl, + endpointModel, + endpointKey, + endpointConnect, + endpointStatus, + writeDestination, + writePreference, + localWritingConsent, + consentLabel, + endpointRow, + syncFileChoice, + syncLabels, + endpointKindValue, + endpointKeySlot, + rememberEndpoint, + loadStoredKeyIntoForm, + get endpointSettings() { + return endpointSettings; + }, + }; +} diff --git a/lib/agent-plugin/ui/panel-workspace.ts b/lib/agent-plugin/ui/panel-workspace.ts new file mode 100644 index 00000000..5ffecc4f --- /dev/null +++ b/lib/agent-plugin/ui/panel-workspace.ts @@ -0,0 +1,53 @@ +import type { ChatView } from '@ranuts/chat-ui'; +import type { createWorkspaceNavigation } from './workspace-navigation'; + +export type PanelWorkspaceView = 'chat' | 'settings' | 'history' | 'sources' | 'artifacts'; +interface WorkspaceSurface { + el: HTMLElement; + trigger: HTMLElement; + focus: () => void; +} + +/** Presentation navigation only. Request/session invalidation remains with the panel's lifecycle owners. */ +export function createPanelWorkspace(options: { + panel: HTMLElement; + chat: Pick; + navigation: Pick, 'capture' | 'restore'>; + conversation: () => string; + isOpen: () => boolean; + runtime: { row: HTMLElement; settings: HTMLElement; modelRow: HTMLElement; modelActions: HTMLElement }; + views: Record, WorkspaceSurface>; + beforeChange: (view: PanelWorkspaceView) => void; + changed: (view: PanelWorkspaceView) => void; +}) { + const { panel, chat, views, runtime, navigation } = options; + const show = (view: PanelWorkspaceView): void => { + options.beforeChange(view); + if (panel.dataset.view === 'chat' && view !== 'chat') navigation.capture(options.conversation()); + panel.dataset.view = view; + if (view === 'settings') runtime.modelRow.insertBefore(runtime.row, runtime.modelActions); + else panel.insertBefore(runtime.row, runtime.settings); + chat.el.hidden = view !== 'chat'; + for (const [name, surface] of Object.entries(views)) { + if (name === 'settings') surface.el.classList.toggle('agent-panel-settings-hidden', view !== name); + else surface.el.hidden = view !== name; + surface.trigger.setAttribute('aria-expanded', String(view === name)); + } + options.changed(view); + if (!options.isOpen()) return; + if (view !== 'chat') views[view].focus(); + else if (!navigation.restore(options.conversation(), true)) chat.focus(); + }; + for (const [name, surface] of Object.entries(views)) { + surface.trigger.addEventListener('click', () => + show(panel.dataset.view === name ? 'chat' : (name as PanelWorkspaceView)), + ); + surface.el.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || event.isComposing || event.defaultPrevented || event.keyCode === 229) return; + event.stopPropagation(); + show('chat'); + surface.trigger.focus(); + }); + } + return { show }; +} diff --git a/lib/agent-plugin/ui/panel.ts b/lib/agent-plugin/ui/panel.ts index b63f9140..54762f53 100644 --- a/lib/agent-plugin/ui/panel.ts +++ b/lib/agent-plugin/ui/panel.ts @@ -13,6 +13,7 @@ import { readReceipt } from './read-receipt'; import type { ReviewOutcome } from '../review-outcome'; import { generateReadAnswer } from '../document-read-answer'; import type { DocumentAgentToolOptions } from '../document-agent-tools'; +import { prepareAssistantOffline } from '../../assistant-offline'; import { mountSpeechInput } from './speech-input'; /** * Assistant sidebar product orchestration over {@link AgentChatController}. @@ -22,8 +23,9 @@ import { mountSpeechInput } from './speech-input'; * main panel is a clean chat; a toolbar (review-mode toggle + quote + clear); and * the reusable @ranuts/chat-ui ChatView. Form controls are ranui Web Components * built with the ranui `builder` (View/Div/... fluent factories). Context, model-cache settings and review controls have dedicated lifecycle - * owners. This module coordinates request routes and delegates execution to - * the controller, document-task modules and provider factory. Loaded after an explicit product opt-in. + * owners. Model configuration, destination policy, deterministic commands and + * workspace presentation each have a concrete owner. This module binds request + * lifetimes, review and conversation state to those owners. Loaded after an explicit product opt-in. */ import 'ranui/button'; import 'ranui/input'; @@ -31,9 +33,8 @@ import 'ranui/select'; import 'ranui/checkbox'; import { ButtonBuilder, Div, Label, Span, View, createEffect, signal } from 'ranui/builder'; import { localStorageGetItem, localStorageSetItem } from 'ranuts/utils'; -import { getLanguage, type I18nMessages, t } from '@ranuts/shared/i18n'; +import { getLanguage, t } from '@ranuts/shared/i18n'; import { getReadonlyMode } from '../../onlyoffice/readonly'; -import { getEditorApi } from '../editor-bridge'; import { mountReviewSettings } from './review-settings'; import { createModelCacheSettings } from './model-cache-settings'; import { mountPanelContext, syncPanelContext } from './panel-context'; @@ -45,27 +46,18 @@ import { agentTools } from '../tools'; import { parseDirectDocumentIntent } from '../direct-intent'; import { captureDocumentContext, resolveContextCommand } from '../document-context'; import type { LLMMessage } from '@ranuts/agent-core/llm/types'; -import type { ProviderId } from '@ranuts/agent-core/llm/factory'; import { DEFAULT_CPU_MODEL_URL, LocalInferenceProvider } from '@ranuts/agent-core/llm/local'; -import { DEFAULT_WEBLLM_MODEL, isModelCached, WEBLLM_MODELS } from '@ranuts/agent-core/llm/webllm'; +import { isModelCached, WEBLLM_MODELS } from '@ranuts/agent-core/llm/webllm'; import type { LocalLLMProvider } from '@ranuts/agent-core/llm/types'; -import { resolveModelArtifactUrl } from '@ranuts/agent-core/llm/model-source'; import { resolveTaskModel, type ModelTask } from '@ranuts/agent-core/llm/task-model'; -import { resolveWritingRoute, type WritingRoute } from '@ranuts/agent-core/llm/writing-route'; -import { - DEFAULT_LOOPBACK_ENDPOINT, - createEndpointProvider, - type WritingEndpointKind, -} from '@ranuts/agent-core/llm/endpoint'; -import { clearEndpointKey, getEndpointKey, setEndpointKey } from '@ranuts/agent-core/llm/keys'; -import { readTaskModelPreferences } from './task-model-preferences'; +import type { WritingRoute } from '@ranuts/agent-core/llm/writing-route'; +import { DEFAULT_LOOPBACK_ENDPOINT, createEndpointProvider } from '@ranuts/agent-core/llm/endpoint'; +import { getEndpointKey } from '@ranuts/agent-core/llm/keys'; import { ENDPOINT_SETTINGS_KEY, configuredEndpoint, isCloudEndpointKind, readEndpointSettings, - writeEndpointSettings, - type EndpointSettings, } from './endpoint-settings'; import { WllamaProvider } from '@ranuts/agent-core/llm/wllama'; import { @@ -109,22 +101,14 @@ import { captureRequestContext, serializeRequestContext } from './request-contex import { classifyRequest, resolveRequestIntent, safeChatIntent } from './request-intent'; import { createDocumentControllerBindings, createNativeDocumentRequest } from './controller-bindings'; import { EndpointConnection } from './endpoint-lifecycle'; +import { createModelConfigurationForm, rememberedModelUrl } from './model-configuration-form'; +import { prepareDirectDocumentRequest } from './direct-document-request'; +import { createRequestRouting } from './request-routing'; +import { createPanelWorkspace, type PanelWorkspaceView } from './panel-workspace'; import { createPanelSettingsView } from './settings-view'; import { generateDocumentDraft } from './document-draft'; import { listCachedGGUF, rememberModelSource } from '@ranuts/agent-core/llm/model-cache'; -/** ranui custom elements expose a `value` accessor (r-select / r-input). */ -type ValueEl = HTMLElement & { value: string }; -type InputEl = ValueEl & { placeholder: string }; -function rememberedModelUrl(value: string | null): string { - if (!value?.trim()) return ''; - try { - return resolveModelArtifactUrl(value.trim()); - } catch { - return ''; - } -} - function compactSelect( className: string, options: Array<{ value: string; label: string }>, @@ -142,46 +126,6 @@ function compactSelect( return select; } -const PROVIDER_LABEL_KEY: Record = { - anthropic: 'agentProviderClaude', - openai: 'agentProviderOpenAI', - gemini: 'agentProviderGemini', - webllm: 'agentProviderLocal', - wllama: 'agentProviderWllama', - ollama: 'agentProviderOllama', - loopback: 'agentProviderLoopback', -}; -// The loopback service is not a browser engine: it has its own settings block -// rather than a slot in the provider dropdown. -const PROVIDER_IDS: ProviderId[] = ['webllm', 'wllama']; - -/** An r-button (ranui builder) with a label and class. */ -const ranButton = (text: string, className: string): HTMLElement => - View('r-button').class(className).text(text).build(); - -/** Programmatic focus on a ranui host must retain keyboard activation. */ -const focusableAction = (button: HTMLElement): void => { - button.tabIndex = -1; - button.addEventListener('keydown', (event) => { - // The component's inner button already handles ordinary tab navigation. - if (event.defaultPrevented || button.hasAttribute('disabled') || !['Enter', ' '].includes(event.key)) return; - event.preventDefault(); - button.click(); - }); -}; - -/** An r-select with r-option children and an initial value (ranui builder). */ -const ranSelect = (className: string, options: Array<{ value: string; label: string }>, value: string): ValueEl => - View('r-select') - .class(className) - .attr('value', value) - .children(options.map((o) => View('r-option').attr('value', o.value).text(o.label).build())) - .build() as ValueEl; - -/** An r-input of the given type (ranui builder). */ -const ranInput = (className: string, type: string): InputEl => - View('r-input').class(className).attr('type', type).build() as InputEl; - /** * Singleton handle to the live panel, so external triggers (the AI button * mounted in the editor's right rail, posting `agent:toggle`) can open/close @@ -355,9 +299,6 @@ export function createAgentPanel( .aria('label', t('agentSettings')) .attr('aria-expanded', 'false') .attr('aria-controls', 'agent-settings') - .on('click', () => { - showView(panel.dataset.view === 'settings' ? 'chat' : 'settings'); - }) .build(); const closeBtn = ButtonBuilder() .class('agent-panel-close') @@ -383,80 +324,58 @@ export function createAgentPanel( const header = Div().class('agent-panel-header').children(title, settingsBtn, closeBtn).build(); // ── Settings (collapsed by default; opened via the gear) ────────────────── - const providerSelect = ranSelect( - 'agent-panel-provider', - PROVIDER_IDS.map((id) => ({ value: id, label: t(PROVIDER_LABEL_KEY[id]) })), - localStorageGetItem('agent-panel-provider') === 'wllama' ? 'wllama' : 'webllm', - ); - providerSelect.setAttribute('aria-label', t('agentProviderLabel')); - - const ggufUrl = ranInput('agent-panel-gguf-url', 'text'); - ggufUrl.placeholder = 'https://…/model.gguf'; - ggufUrl.value = rememberedModelUrl(localStorageGetItem('agent-panel-gguf-url')); - const ggufFiles = document.createElement('input'); - ggufFiles.type = 'file'; - ggufFiles.multiple = true; - ggufFiles.accept = '.gguf'; - ggufFiles.className = 'agent-panel-gguf-files'; - const ggufChooseText = document.createElement('span'); - const ggufChoose = document.createElement('label'); - ggufChoose.className = 'agent-panel-gguf-choose'; - ggufChoose.append(ggufChooseText, ggufFiles); - const ggufNames = document.createElement('span'); - ggufNames.className = 'agent-panel-gguf-filenames'; - ggufNames.setAttribute('aria-live', 'polite'); - const syncFileChoice = () => { - ggufChooseText.textContent = t('agentChooseModelFiles'); - ggufFiles.setAttribute('aria-label', t('agentChooseModelFiles')); - ggufNames.textContent = Array.from(ggufFiles.files ?? []) - .map((file) => file.name) - .join(', '); - ggufNames.title = ggufNames.textContent; - }; - syncFileChoice(); - const ggufLoad = ranButton(t('agentLoadModel'), 'agent-panel-gguf-load'); - focusableAction(ggufLoad); - ggufLoad.addEventListener('click', () => void loadModel()); - const ggufCpu = document.createElement('input'); - ggufCpu.type = 'checkbox'; - ggufCpu.checked = true; - ggufCpu.className = 'agent-panel-gguf-cpu'; - const ggufStop = ranButton(t('agentStop'), 'agent-panel-gguf-stop'); - ggufStop.setAttribute('type', 'text'); - focusableAction(ggufStop); - ggufStop.hidden = true; - ggufStop.addEventListener('click', () => { - resetController(); - note.textContent = t('agentStopped'); - if (panel.dataset.view === 'settings') ggufLoad.focus(); - else chat.focus(); - }); - const ggufRow = Div().class('agent-panel-gguf-row').children(ggufUrl, ggufChoose, ggufNames, ggufLoad).build(); - - // Public artifact URLs and API endpoint preferences; never store a key here. - const sourceInput = (className: string, placeholder: string): InputEl => { - const input = ranInput(className, 'text'); - input.placeholder = placeholder; - input.setAttribute('aria-label', placeholder); - input.value = - localStorageGetItem(className) || - ( - { - 'agent-local-model-id': import.meta.env.VITE_LOCAL_MODEL_ID, - 'agent-local-model-url': import.meta.env.VITE_LOCAL_MODEL_URL, - 'agent-local-model-lib': import.meta.env.VITE_LOCAL_MODEL_LIB_URL, - } as Record - )[className] || - ''; - input.addEventListener('change', () => { - localStorageSetItem(className, input.value.trim()); + const initialEndpointSettings = readEndpointSettings(localStorageGetItem(ENDPOINT_SETTINGS_KEY)); + let selectedSource = configuredEndpoint(initialEndpointSettings) ? 'service' : 'local'; + let cachedLocalModel = false; + const configuration = createModelConfigurationForm({ + endpointSettings: initialEndpointSettings, + load: () => void loadModel(), + sourceChanged: () => resetController(), + stop: (loadControl) => { resetController(); - }); - return input; - }; - const localModelId = sourceInput('agent-local-model-id', 'Custom MLC model ID (optional)'); - const localModelUrl = sourceInput('agent-local-model-url', 'MLC model directory URL (/models/…)'); - const localModelLib = sourceInput('agent-local-model-lib', 'Compatible model WASM URL (/models/…/model.wasm)'); + note.textContent = t('agentStopped'); + if (panel.dataset.view === 'settings') loadControl.focus(); + else chat.focus(); + }, + }); + const { + providerSelect, + ggufUrl, + ggufFiles, + ggufNames, + ggufLoad, + ggufCpu, + ggufStop, + ggufRow, + localModelId, + localModelUrl, + localModelLib, + modelSelect, + taskPreferences, + savedPreset, + loadBtn, + modelRow, + modelMemory, + loadStop, + note, + endpointKind, + endpointBaseUrl, + endpointModel, + endpointKey, + endpointConnect, + endpointStatus, + writeDestination, + writePreference, + localWritingConsent, + consentLabel, + endpointRow, + syncFileChoice, + endpointKindValue, + endpointKeySlot, + rememberEndpoint, + loadStoredKeyIntoForm, + } = configuration; + runtimeRow.append(loadStop, ggufStop, loadProgress); const localSource = () => activeTaskModel().source !== 'default' ? {} @@ -466,141 +385,11 @@ export function createAgentPanel( }; const selectedLocalModel = () => activeTaskModel().binding.model; - // Local: model picker + load button - const modelSelect = ranSelect( - 'agent-panel-model', - WEBLLM_MODELS.map((model) => ({ - value: model.id, - label: model.label, - })), - DEFAULT_WEBLLM_MODEL, - ); - const presetIds = WEBLLM_MODELS.map((model) => model.id); - const taskPreferences = readTaskModelPreferences(localStorageGetItem('agent-task-models'), presetIds); - const savedPreset = localStorageGetItem('agent-local-preset'); - if (WEBLLM_MODELS.some((model) => model.id === savedPreset)) modelSelect.value = savedPreset!; - else if (savedPreset) localStorageSetItem('agent-local-preset', DEFAULT_WEBLLM_MODEL); - modelSelect.setAttribute('aria-label', t('agentModelLabel')); - const loadBtn = ranButton(t('agentLoadModel'), 'agent-panel-load'); - focusableAction(loadBtn); - loadBtn.addEventListener('click', () => void loadModel()); - const modelRow = Div().class('agent-panel-model-row').children(modelSelect, loadBtn).build(); - const modelMemory = document.createElement('p'); - modelMemory.className = 'agent-model-memory'; - modelMemory.id = 'agent-model-memory'; - modelSelect.setAttribute('aria-describedby', 'agent-model-memory'); - const syncModelMemory = () => { - modelMemory.hidden = providerSelect.value === 'wllama'; - const model = WEBLLM_MODELS.find((item) => item.id === modelSelect.value); - modelMemory.textContent = model ? t('agentModelMemory').replace('{memory}', (model.vramMB / 1000).toFixed(2)) : ''; - }; - modelSelect.addEventListener('change', syncModelMemory); - providerSelect.addEventListener('change', syncModelMemory); - syncModelMemory(); - const loadStop = ranButton(t('agentStop'), 'agent-panel-load-stop'); - loadStop.setAttribute('type', 'text'); - focusableAction(loadStop); - loadStop.hidden = true; - loadStop.addEventListener('click', () => { - resetController(); - note.textContent = t('agentStopped'); - if (panel.dataset.view === 'settings') loadBtn.focus(); - else chat.focus(); - }); - runtimeRow.append(loadStop, ggufStop, loadProgress); - - // Detailed cache and download hints remain in settings; the compact runtime - // row exposes progress and cancellation in the main conversation view. - const note = Div().class('agent-panel-note').build(); - note.setAttribute('role', 'status'); - - // ── Writing destination ────────────────────────────────────────────────── - // Writing into a document is high-stakes, and the browser-local engines never - // reached seven-language quality acceptance. The user may instead point the - // assistant at a model server: a loopback service on this machine (the text - // stays on the device) or a cloud endpoint with their own key (the selected - // text is sent there). The resolved destination is always shown, because the - // two are not equivalent and the choice belongs to the user. - let endpointSettings: EndpointSettings = readEndpointSettings(localStorageGetItem(ENDPOINT_SETTINGS_KEY)); - let selectedSource = configuredEndpoint(endpointSettings) ? 'service' : 'local'; - let cachedLocalModel = false; - const endpointKind = ranSelect( - 'agent-endpoint-kind', - [ - { value: 'loopback', label: t('agentEndpointLoopback') }, - { value: 'openai-compatible', label: t('agentEndpointOpenAICompatible') }, - { value: 'anthropic', label: t('agentEndpointAnthropic') }, - { value: 'gemini', label: t('agentEndpointGemini') }, - ], - endpointSettings.kind, - ); - endpointKind.setAttribute('aria-label', t('agentEndpointKind')); - const endpointBaseUrl = ranInput('agent-panel-endpoint-url', 'text'); - endpointBaseUrl.value = - endpointSettings.baseUrl || (endpointSettings.kind === 'loopback' ? DEFAULT_LOOPBACK_ENDPOINT : ''); - endpointBaseUrl.placeholder = - endpointSettings.kind === 'loopback' ? DEFAULT_LOOPBACK_ENDPOINT : 'https://api.example.com/v1'; - endpointBaseUrl.setAttribute('aria-label', t('agentEndpointUrl')); - const endpointModel = ranInput('agent-panel-endpoint-model', 'text'); - endpointModel.value = endpointSettings.model; - endpointModel.placeholder = 'qwen3:8b'; - endpointModel.setAttribute('aria-label', t('agentEndpointModel')); - const endpointKey = ranInput('agent-panel-endpoint-key', 'password'); - endpointKey.placeholder = t('agentEndpointKey'); - endpointKey.setAttribute('aria-label', t('agentEndpointKey')); - const endpointConnect = ranButton(t('agentEndpointConnect'), 'agent-panel-endpoint-connect'); - const endpointStatus = Span() - .class('agent-panel-endpoint-status') - .attr('role', 'status') - .id('agent-endpoint-status') - .build(); - for (const control of [endpointKind, endpointBaseUrl, endpointModel, endpointKey]) - control.setAttribute('aria-describedby', 'agent-endpoint-status'); - const writeDestination = Span().class('agent-panel-write-destination').attr('role', 'status').build(); - const writePreference = ranSelect( - 'agent-write-preference', - [ - { value: 'device-first', label: t('agentWritePreferDevice') }, - { value: 'remote-first', label: t('agentWritePreferRemote') }, - ], - endpointSettings.preference, - ); - writePreference.setAttribute('aria-label', t('agentWritePreference')); - const localWritingConsent = document.createElement('input'); - localWritingConsent.type = 'checkbox'; - localWritingConsent.className = 'agent-panel-local-writing'; - localWritingConsent.checked = endpointSettings.localWritingConsent; - const consentLabel = document.createElement('label'); - consentLabel.className = 'agent-panel-local-writing-label'; - consentLabel.append(localWritingConsent, t('agentLocalWritingConsent')); - const endpointRow = Div() - .class('agent-panel-endpoint') - .children( - Span().class('agent-panel-endpoint-title').text(t('agentWriteDestinationTitle')).build(), - endpointKind, - endpointBaseUrl, - endpointModel, - endpointKey, - endpointConnect, - endpointStatus, - writePreference, - writeDestination, - consentLabel, - ) - .build(); - const settings = Div() .class('agent-panel-settings agent-panel-settings-hidden') .id('agent-settings') .children(providerSelect, modelRow, ggufRow, endpointRow) .build(); - settings.addEventListener('keydown', (event) => { - if (event.key !== 'Escape' || event.defaultPrevented || event.isComposing || event.keyCode === 229) return; - event.stopPropagation(); - showView('chat'); - settingsBtn.focus(); - }); - // ── Compose actions (mounted into ChatView's slot above the input) ──────── const reviewCheck = View('r-checkbox').build(); const reviewText = Span().text(t('agentReviewMode')).build(); @@ -694,7 +483,7 @@ export function createAgentPanel( if ( !!webllmProvider?.isReady() || (!!endpointConnection.current?.isReady() && - (endpointSettings.kind === 'loopback' || navigator.onLine !== false)) + (configuration.endpointSettings.kind === 'loopback' || navigator.onLine !== false)) ) return true; const context = captureDocumentContext(); @@ -705,7 +494,7 @@ export function createAgentPanel( }, getSendPreparation: () => { if (!enabled || runtimeCleanupFailed) return; - const endpoint = configuredEndpoint(endpointSettings); + const endpoint = configuredEndpoint(configuration.endpointSettings); if ( (selectedSource === 'service' || endpointConnection.current?.isReady()) && endpoint && @@ -787,7 +576,7 @@ export function createAgentPanel( if (endpointConnection.pending || modelLoading || runtimeCleanup || cacheBusy || !enabled) return; if ( navigator.onLine === false && - ((selectedSource === 'service' && endpointSettings.kind !== 'loopback') || + ((selectedSource === 'service' && configuration.endpointSettings.kind !== 'loopback') || (selectedSource === 'local' && !(currentProvider() === 'webllm' && cachedLocalModel) && !webllmProvider?.isReady() && @@ -804,7 +593,7 @@ export function createAgentPanel( return; } if (selectedSource === 'service') { - const endpoint = configuredEndpoint(endpointSettings); + const endpoint = configuredEndpoint(configuration.endpointSettings); if (endpoint && (!isCloudEndpointKind(endpoint.kind) || getEndpointKey(endpointKeySlot(endpoint.kind)))) void connectEndpoint(); else showView('settings'); @@ -1187,67 +976,29 @@ export function createAgentPanel( // Independent of the browser-local lifecycle: switching or unloading a browser // model must not disconnect a writing endpoint the user deliberately connected. const endpointConnection = new EndpointConnection(() => syncRuntimeStatus()); - const endpointKindValue = (): WritingEndpointKind => endpointKind.value as WritingEndpointKind; - const endpointKeySlot = (kind: WritingEndpointKind): string => - kind === 'openai-compatible' ? endpointBaseUrl.value.trim() : kind; - const rememberEndpoint = (): void => { - const next: EndpointSettings = { - kind: endpointKindValue(), - baseUrl: endpointBaseUrl.value.trim(), - model: endpointModel.value.trim(), - preference: writePreference.value === 'remote-first' ? 'remote-first' : 'device-first', - localWritingConsent: localWritingConsent.checked, - }; - endpointSettings = next; - try { - localStorageSetItem(ENDPOINT_SETTINGS_KEY, writeEndpointSettings(next)); - } catch { - // An endpoint the route would refuse is never persisted. - } - if (isCloudEndpointKind(next.kind)) { - const slot = endpointKeySlot(next.kind); - // No address yet means no slot to address; the key is simply not stored. - // Clearing the field removes the stored key: a secret the user cannot - // delete is worse than one they have to retype. - if (slot) { - if (endpointKey.value.trim()) setEndpointKey(slot, endpointKey.value.trim()); - else clearEndpointKey(slot); - } - } - }; - const useEndpointForChat = (): boolean => { - if (!endpointConnection.current?.isReady() || (endpointSettings.kind !== 'loopback' && navigator.onLine === false)) - return false; - return ( - endpointSettings.kind === 'loopback' || - endpointSettings.preference === 'remote-first' || - !webllmProvider?.isReady() - ); - }; - const resolveRoute = (): WritingRoute => { - const endpoint = endpointConnection.current?.isReady() ? configuredEndpoint(endpointSettings) : null; - return resolveWritingRoute({ - loopback: endpoint?.kind === 'loopback' ? endpoint : null, - remote: endpoint && endpoint.kind !== 'loopback' ? endpoint : null, - preference: endpointSettings.preference, - // Offline the only workable destinations are on this device, so a cloud - // endpoint is dropped here rather than attempted and then failed. - offline: navigator.onLine === false, - localWritingConsent: localWritingConsent.checked, - local: { backend: currentProvider(), model: selectedLocalModel() }, - }); - }; + const routing = createRequestRouting(() => ({ + settings: configuration.endpointSettings, + endpointReady: !!endpointConnection.current?.isReady(), + localReady: !!webllmProvider?.isReady(), + offline: navigator.onLine === false, + localWritingConsent: localWritingConsent.checked, + local: { backend: currentProvider(), model: selectedLocalModel() }, + })); + const useEndpointForChat = routing.useEndpointForChat; + const resolveRoute = routing.writingRoute; const syncWriteDestination = (): void => { writeDestination.hidden = false; if (writingSelect.value === 'chat' || writingSelect.value === 'tools') { const endpoint = useEndpointForChat(); const ready = endpoint || !!webllmProvider?.isReady(); const where = - endpoint && endpointSettings.kind !== 'loopback' + endpoint && configuration.endpointSettings.kind !== 'loopback' ? 'agentWriteDestinationRemote' : 'agentWriteDestinationDevice'; const unavailable = - endpointConnection.current?.isReady() && endpointSettings.kind !== 'loopback' && navigator.onLine === false; + endpointConnection.current?.isReady() && + configuration.endpointSettings.kind !== 'loopback' && + navigator.onLine === false; writeDestination.hidden = !ready && !unavailable; writeDestination.textContent = `${t('agentWriteDestinationTitle')}: ${t(ready ? where : unavailable ? 'agentWriteDestinationOfflineUnavailable' : 'agentWriteNeedsDestination')}`; return; @@ -1263,7 +1014,9 @@ export function createAgentPanel( if (!route || route.kind === 'blocked') { // "not configured" would be false when a cloud endpoint is configured but // cannot be used right now; say what is actually configured. - const configured = endpointConnection.current?.isReady() ? configuredEndpoint(endpointSettings) : null; + const configured = endpointConnection.current?.isReady() + ? configuredEndpoint(configuration.endpointSettings) + : null; writeDestination.hidden = !configured; if (configured && configured.kind !== 'loopback') writeDestination.textContent = `${title}: ${t('agentWriteDestinationOfflineUnavailable')} · ${configured.model}`; @@ -1273,14 +1026,6 @@ export function createAgentPanel( const where = route.dataPath === 'device' ? t('agentWriteDestinationDevice') : t('agentWriteDestinationRemote'); writeDestination.textContent = `${title}: ${where}${offlineNote}`; }; - // Filling the key field is a deliberate act (mount, or a different endpoint - // kind). Rendering status must not do it: it would overwrite a key the user is - // in the middle of typing, before it has been stored. - const loadStoredKeyIntoForm = (): void => { - const kind = endpointKindValue(); - const slot = isCloudEndpointKind(kind) ? endpointKeySlot(kind) : ''; - endpointKey.value = slot ? (getEndpointKey(slot) ?? '') : ''; - }; const syncEndpointForm = (status = ''): void => { const kind = endpointKindValue(); const cloud = isCloudEndpointKind(kind); @@ -1334,7 +1079,7 @@ export function createAgentPanel( let failure = ''; try { rememberEndpoint(); - const endpoint = configuredEndpoint(endpointSettings); + const endpoint = configuredEndpoint(configuration.endpointSettings); if (!endpoint) throw new Error('Incomplete writing endpoint'); const stored = isCloudEndpointKind(kind) ? getEndpointKey(endpointKeySlot(kind)) : undefined; const connected = await endpointConnection.connect(() => @@ -1438,7 +1183,7 @@ export function createAgentPanel( // The runtime row already announces loading and offers cancellation. readiness.hidden = !reason || modelLoading; const waiting = modelLoading || !!endpointConnection.pending || !!runtimeCleanup || cacheBusy; - const endpoint = configuredEndpoint(endpointSettings); + const endpoint = configuredEndpoint(configuration.endpointSettings); const canReconnect = !!( endpoint && (!offline || endpoint.kind === 'loopback') && @@ -1573,7 +1318,7 @@ export function createAgentPanel( note.textContent = t('agentModelCleanupFailed'); return; } - if (currentProvider() !== 'webllm' || modelLoading) return; + if (selectedSource !== 'local' || currentProvider() !== 'webllm' || modelLoading) return; if (webllmProvider?.isReady()) { note.textContent = t('agentModelLoaded'); return; @@ -1701,6 +1446,7 @@ export function createAgentPanel( controller?.dispose(); localProviderKey = 'wllama'; webllmProvider ??= new WllamaProvider({ + prepareRuntimeAssets: prepareAssistantOffline, generation: generationOptions, modelUrl: ggufUrl.value.trim(), modelFiles: Array.from(ggufFiles.files ?? []), @@ -1725,6 +1471,7 @@ export function createAgentPanel( if (!webllmProvider || localProviderKey !== kind) { localProviderKey = kind; webllmProvider = new LocalInferenceProvider({ + prepareRuntimeAssets: prepareAssistantOffline, generation: generationOptions, webllm: { model: selectedLocalModel(), @@ -1826,7 +1573,7 @@ export function createAgentPanel( ...localSource(), }); syncSidebar(); - void refreshCache(); + if (currentProvider() === 'webllm' || cacheList.open) void refreshCache(); } } catch (error) { if (generation === controllerGeneration && loading === webllmProvider) { @@ -2139,85 +1886,16 @@ export function createAgentPanel( directAbort = new AbortController(); planning = directAbort; - if (!requestTarget?.isCurrent(false)) throw new Error(t('agentPlanExpired')); - const readOnly = - (intent.kind === 'sum_range' && !intent.target) || - (intent.kind === 'slide' && intent.action === 'navigate'); - const directTarget = readOnly ? requestTarget : (requestTarget.prepareWrite?.() ?? requestTarget); - if (!directTarget.isCurrent(!readOnly)) throw new Error(t('agentPlanExpired')); - if (intent.kind === 'write_reply') { - if (!answer.trim()) throw new Error('agentNoCompletedAnswer'); - if (directTarget.context.kind !== 'word') throw new Error(t('agentWordOnly')); - if (intent.requireSelection && !directTarget.selectedText.trim()) throw new Error(t('agentNoSelection')); - } - if ( - intent.kind === 'align' && - /当前段落|current paragraph/i.test(trimmed) && - getEditorApi()?.pluginMethod_GetSelectedText().trim() - ) - throw new Error('agentParagraphSelectionConflict'); + const directRequest = prepareDirectDocumentRequest(intent, trimmed, requestTarget, answer); if (!accept()) return owned('stopped'); directAccepted = true; appendTurn({ role: 'user', text: trimmed }); - let name: string; - let input: Record; - let result: unknown; - if (intent.kind === 'write_reply') { - const target = directTarget; - name = 'insert_text'; - input = { text: answer, format: 'markdown' }; - const action = new DocumentToolAction(target, { tool: 'insert_text', input, readOnly: false }); - try { - result = { outcome: await action.apply(directAbort.signal) }; - } finally { - action.dispose(); - } - } else if (intent.kind === 'sum_range' || intent.kind === 'sort_range' || intent.kind === 'slide') { - name = intent.kind === 'slide' ? 'slide_action' : intent.kind; - const { kind: _kind, ...parameters } = intent; - input = parameters; - result = await agentTools[name].execute(input, directAbort.signal); - } else { - name = intent.kind === 'bold' ? 'set_bold' : 'set_paragraph_alignment'; - input = intent.kind === 'bold' ? { enabled: intent.enabled } : { alignment: intent.alignment }; - result = await agentTools[name].execute(input, directAbort.signal); - } - directAbort.signal.throwIfAborted(); - if (intent.kind !== 'write_reply' && (result as { verified?: unknown } | undefined)?.verified !== true) - throw new Error(t('agentPlanUnverified')); + const result = await directRequest.execute(directAbort.signal); if (revision !== conversationRevision || directGeneration !== controllerGeneration) return owned('superseded'); - directStatus = - intent.kind === 'write_reply' - ? (result as { outcome?: string }).outcome === 'verified' - ? 'success' - : 'failed' - : 'success'; - const id = `direct-${crypto.randomUUID()}`; - messages.push( - { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] }, - { role: 'user', content: [{ type: 'tool_result', toolUseId: id, content: JSON.stringify(result) }] }, - ); - if (intent.kind === 'sum_range' && !intent.target) - messages.push({ - role: 'assistant', - content: `${intent.range}: ${(result as { sum: number }).sum}`, - hostGuidance: 'tool', - }); - if (revision === conversationRevision) - appendTurn({ - role: 'tool', - text: - intent.kind === 'sum_range' && !intent.target - ? `${intent.range}: ${(result as { sum: number }).sum}` - : intent.kind === 'slide' && intent.action === 'navigate' - ? chinese - ? `已切换到第 ${intent.page} 页幻灯片` - : `Opened slide ${intent.page}` - : intent.kind === 'write_reply' && (result as { outcome?: string }).outcome !== 'verified' - ? t('agentPlanApplied') - : t('agentPlanVerified'), - }); + directStatus = result.status; + messages.push(...result.messages); + if (revision === conversationRevision) appendTurn(result.turn); } } catch (error) { const stopped = error instanceof Error && error.name === 'AbortError'; @@ -2251,19 +1929,10 @@ export function createAgentPanel( // serves the writing tasks, the fixed tool phrases need no model at all, and // chat or an open-ended tool request still does. Asking this is what keeps a // model download from standing in front of work that would never use it. - const localModelNeeded = ((): boolean => { - if (writingSelect.value === 'tools') - return !useEndpointForChat() && !isModelFreeToolRequest(text, captureDocumentContext()); - if (!proposalMode) - return ( - !endpointConnection.current?.isReady() || (endpointSettings.kind !== 'loopback' && navigator.onLine === false) - ); - try { - return resolveRoute().kind === 'local'; - } catch { - return false; - } - })(); + const localModelNeeded = routing.needsLocalModel( + writingSelect.value as ModelTask, + isModelFreeToolRequest(text, captureDocumentContext()), + ); if ( localModelNeeded && (currentProvider() === 'wllama' || currentProvider() === 'webllm') && @@ -3098,24 +2767,10 @@ export function createAgentPanel( syncRuntimeStatus(); settingsBtn.title = t('agentSettings'); settingsBtn.setAttribute('aria-label', t('agentSettings')); - providerSelect.setAttribute('aria-label', t('agentProviderLabel')); - modelSelect.setAttribute('aria-label', t('agentModelLabel')); + configuration.syncLabels(); closeBtn.title = t('agentClose'); closeBtn.setAttribute('aria-label', t('agentClose')); - const selected = providerSelect.value; - for (const opt of providerSelect.querySelectorAll('r-option')) { - opt.textContent = t(PROVIDER_LABEL_KEY[(opt.getAttribute('value') as ProviderId) ?? 'anthropic']); - } - providerSelect.setAttribute('value', selected); // nudge the closed label to retranslate - syncFileChoice(); - loadStop.textContent = t('agentStop'); - ggufStop.textContent = t('agentStop'); loadProgress.setAttribute('aria-label', t('agentPreparing')); - for (const opt of modelSelect.querySelectorAll('r-option')) { - const model = WEBLLM_MODELS.find((m) => m.id === opt.getAttribute('value')); - if (model) opt.textContent = model.label; - } - syncModelMemory(); reviewText.textContent = t('agentReviewMode'); reviewCheck.setAttribute('aria-label', t('agentReviewMode')); clearBtn.title = t('agentNewConversation'); @@ -3183,7 +2838,7 @@ export function createAgentPanel( runtimeCleanup || cacheBusy || modelLoading || - (selectedSource === 'service' && endpointSettings.kind !== 'loopback' && navigator.onLine === false) + (selectedSource === 'service' && configuration.endpointSettings.kind !== 'loopback' && navigator.onLine === false) ? undefined : selectedSource === 'service' ? endpointConnection.current @@ -3204,7 +2859,6 @@ export function createAgentPanel( error: displayError, }); invalidateArtifactWorkflow = () => artifactsView.cancelPending(); - artifactsBtn.addEventListener('click', () => showView(panel.dataset.view === 'artifacts' ? 'chat' : 'artifacts')); window.addEventListener('pagehide', (event) => { if (!event.persisted) artifactsView.dispose(); }); @@ -3229,7 +2883,6 @@ export function createAgentPanel( sourcesBtn.textContent = `${t('agentSourcesSources')}${referenceSources.list().length ? ` · ${referenceSources.list().length}` : ''}`; }, }); - sourcesBtn.addEventListener('click', () => showView(panel.dataset.view === 'sources' ? 'chat' : 'sources')); chat.actionsEl.prepend(sourcesBtn); chat.contextEl.append(sourceSummary); window.addEventListener('pagehide', (event) => { @@ -3278,6 +2931,10 @@ export function createAgentPanel( settingsView.setSource(selectedSource); cacheList.hidden = sourcePicker.value !== 'local'; note.hidden = sourcePicker.value !== 'local'; + if (selectedSource === 'local') { + void updateLocalHint(); + if (currentProvider() === 'webllm' || cacheList.open) void refreshCache(); + } syncRuntimeStatus(); }; sourcePicker.addEventListener('change', syncSource); @@ -3308,7 +2965,6 @@ export function createAgentPanel( const refreshCache = modelCacheSettings.refresh; modelActions.append(modelCacheSettings.release); syncSource(); - void refreshCache(); settings.replaceChildren(settingsHero, sourcePicker, offlineHint, localCard, serviceCard, cacheList, note, advanced); const disableRow = document.createElement('div'); disableRow.className = 'agent-enable-row'; @@ -3353,56 +3009,35 @@ export function createAgentPanel( header.insertBefore(clearBtn, settingsBtn); chat.el.append(writeDestination); - function showView(view: 'chat' | 'settings' | 'history' | 'sources' | 'artifacts'): void { - speech?.close(); - if (view !== 'chat') resultActions.cancel(); - if (view !== 'artifacts') artifactsView.cancelPending(); - const leavingChat = panel.dataset.view === 'chat' && view !== 'chat'; - if (leavingChat) navigation.capture(displayedConversationId); - if (view === 'history') rememberDraft(); - viewRevision++; - panel.dataset.view = view; - if (view === 'settings') modelRow.insertBefore(runtimeRow, modelActions); - else panel.insertBefore(runtimeRow, settings); - settings.classList.toggle('agent-panel-settings-hidden', view !== 'settings'); - chat.el.hidden = view !== 'chat'; - historyView.hidden = view !== 'history'; - sourceView.el.hidden = view !== 'sources'; - artifactsView.el.hidden = view !== 'artifacts'; - artifactsBtn.setAttribute('aria-expanded', String(view === 'artifacts')); - if (view === 'artifacts') void artifactsView.refresh(); - sourcesBtn.setAttribute('aria-expanded', String(view === 'sources')); - syncHistoryActivity(); - settingsBtn.setAttribute('aria-expanded', String(view === 'settings')); - syncRuntimeStatus(); - historyBtn.setAttribute('aria-expanded', String(view === 'history')); - if (!open) return; - if (view === 'settings') settingsHeading.focus(); - else if (view === 'history') conversationList?.focus(); - else if (view === 'sources') sourceView.focus(); - else if (view === 'artifacts') artifactsBtn.focus(); - else if (!navigation.restore(displayedConversationId, true)) chat.focus(); - } - for (const [element, trigger] of [ - [sourceView.el, sourcesBtn], - [artifactsView.el, artifactsBtn], - ] as const) { - element.addEventListener('keydown', (event) => { - if (event.key === 'Escape' && !event.isComposing) { - event.stopPropagation(); - showView('chat'); - trigger.focus(); - } - }); - } - historyBtn.addEventListener('click', () => showView(panel.dataset.view === 'history' ? 'chat' : 'history')); - historyView.addEventListener('keydown', (event) => { - if (event.key === 'Escape' && !event.isComposing) { - event.stopPropagation(); - showView('chat'); - historyBtn.focus(); - } + const workspace = createPanelWorkspace({ + panel, + chat, + navigation, + conversation: () => displayedConversationId, + isOpen: () => open, + runtime: { row: runtimeRow, settings, modelRow, modelActions }, + views: { + settings: { el: settings, trigger: settingsBtn, focus: () => settingsHeading.focus() }, + history: { el: historyView, trigger: historyBtn, focus: () => conversationList?.focus() }, + sources: { el: sourceView.el, trigger: sourcesBtn, focus: () => sourceView.focus() }, + artifacts: { el: artifactsView.el, trigger: artifactsBtn, focus: () => artifactsBtn.focus() }, + }, + beforeChange: (view) => { + speech?.close(); + if (view !== 'chat') resultActions.cancel(); + if (view !== 'artifacts') artifactsView.cancelPending(); + if (view === 'history') rememberDraft(); + viewRevision++; + }, + changed: (view) => { + if (view === 'artifacts') void artifactsView.refresh(); + syncHistoryActivity(); + syncRuntimeStatus(); + }, }); + function showView(view: PanelWorkspaceView): void { + workspace.show(view); + } const syncProductLabels = () => { settingsView.syncLabels(); labelViewBack(settingsBack, t('agentBackToChat')); @@ -3430,7 +3065,7 @@ export function createAgentPanel( clearBtn.setAttribute('aria-label', t('agentNewConversation')); panel.append(header, runtimeRow, settings, historyView, sourceView.el, artifactsView.el, chat.el); document.body.append(panel); - const endpoint = configuredEndpoint(endpointSettings); + const endpoint = configuredEndpoint(configuration.endpointSettings); hasConfiguration = !!(endpoint && (!isCloudEndpointKind(endpoint.kind) || getEndpointKey(endpointKeySlot(endpoint.kind)))) || (currentProvider() === 'webllm' ? WEBLLM_MODELS.some((model) => model.id === savedPreset) : !!ggufUrl.value.trim()); @@ -3457,7 +3092,8 @@ export function createAgentPanel( // Older default-model loads may have cached files without a saved preset. // Check those without warming the engine, and never replace a view the user // has deliberately opened while the storage check was pending. - if (options.externalEntry && !hasConfiguration) { + if (options.externalEntry && !hasConfiguration && selectedSource === 'service') showView('settings'); + if (options.externalEntry && !hasConfiguration && selectedSource === 'local') { const revision = viewRevision; const attempt = loadAttempt; const route = routeKey(); diff --git a/lib/agent-plugin/ui/request-error.ts b/lib/agent-plugin/ui/request-error.ts index 111460d3..88705072 100644 --- a/lib/agent-plugin/ui/request-error.ts +++ b/lib/agent-plugin/ui/request-error.ts @@ -11,6 +11,7 @@ export function requestErrorKey( if (error instanceof DOMException && error.name === 'TimeoutError') return 'agentServiceTimedOut'; if (!(error instanceof Error) && typeof error !== 'string') return; const message = error instanceof Error ? error.message : error; + if (message === 'AI request timed out') return 'agentServiceTimedOut'; if (error instanceof Error && error.name === 'TimeoutError') return 'agentServiceTimedOut'; if ( ['Failed to fetch', 'NetworkError when attempting to fetch resource.', 'Load failed', 'Connection error.'].includes( @@ -21,7 +22,7 @@ export function requestErrorKey( const status = error instanceof Error && 'status' in error && typeof error.status === 'number' ? error.status - : Number(/^(?:OpenAI|Ollama) request failed: (\d{3})(?:\s|$)/.exec(message)?.[1]); + : Number(/^(?:OpenAI|Ollama|Gemini) request failed: (\d{3})(?:\s|$)/.exec(message)?.[1]); if (status === 401 || status === 403) return 'agentServiceAuthFailed'; if (status === 429) return 'agentServiceRateLimited'; if (status === 408 || status === 504) return 'agentServiceTimedOut'; diff --git a/lib/agent-plugin/ui/request-routing.ts b/lib/agent-plugin/ui/request-routing.ts new file mode 100644 index 00000000..c6ae4d11 --- /dev/null +++ b/lib/agent-plugin/ui/request-routing.ts @@ -0,0 +1,49 @@ +import { resolveWritingRoute, type BrowserLocalBinding, type WritingRoute } from '@ranuts/agent-core/llm/writing-route'; +import type { ModelTask } from '@ranuts/agent-core/llm/task-model'; +import { configuredEndpoint, type EndpointSettings } from './endpoint-settings'; + +export interface RequestRoutingState { + settings: EndpointSettings; + endpointReady: boolean; + localReady: boolean; + offline: boolean; + localWritingConsent: boolean; + local: BrowserLocalBinding; +} + +/** Destination policy reads live state; it neither connects providers nor warms a local model. */ +export function createRequestRouting(state: () => RequestRoutingState) { + const useEndpointForChat = (): boolean => { + const { settings, endpointReady, localReady, offline } = state(); + if (!endpointReady || (settings.kind !== 'loopback' && offline)) return false; + return settings.kind === 'loopback' || settings.preference === 'remote-first' || !localReady; + }; + const writingRoute = (): WritingRoute => { + const current = state(); + const endpoint = current.endpointReady ? configuredEndpoint(current.settings) : null; + return resolveWritingRoute({ + loopback: endpoint?.kind === 'loopback' ? endpoint : null, + remote: endpoint && endpoint.kind !== 'loopback' ? endpoint : null, + preference: current.settings.preference, + offline: current.offline, + localWritingConsent: current.localWritingConsent, + local: current.local, + }); + }; + return { + useEndpointForChat, + writingRoute, + needsLocalModel(task: ModelTask, modelFreeTools: boolean): boolean { + if (task === 'tools') return !useEndpointForChat() && !modelFreeTools; + if (task === 'chat') { + const { endpointReady, settings, offline } = state(); + return !endpointReady || (settings.kind !== 'loopback' && offline); + } + try { + return writingRoute().kind === 'local'; + } catch { + return false; + } + }, + }; +} diff --git a/lib/assistant-offline.ts b/lib/assistant-offline.ts index 742ecab2..fa9e7367 100644 --- a/lib/assistant-offline.ts +++ b/lib/assistant-offline.ts @@ -1,3 +1,5 @@ +export type AssistantOfflineCapability = 'webgpu' | 'wllama' | 'wllama-compat' | 'cpu' | 'cpu-compat' | 'pdf'; + interface OfflineWorker { postMessage(message: unknown, transfer: Transferable[]): void; } @@ -6,8 +8,9 @@ interface OfflineWorkerContainer { ready: PromiseLike<{ active: OfflineWorker | null }>; } -/** Prepare build-owned AI runtime assets after opt-in; never download model weights. */ +/** Prepare only the activated build-owned capability; never download model weights. */ export function prepareAssistantOffline( + capability: AssistantOfflineCapability, container: OfflineWorkerContainer | undefined = typeof navigator === 'undefined' ? undefined : navigator.serviceWorker, @@ -34,7 +37,7 @@ export function prepareAssistantOffline( channel.port1.onmessage = (event) => { if (event.data?.type === 'ASSISTANT_PREPARED') done(event.data.ok === true); }; - worker.postMessage({ type: 'PREPARE_ASSISTANT' }, [channel.port2]); + worker.postMessage({ type: 'PREPARE_ASSISTANT', capability }, [channel.port2]); } catch { done(false); } diff --git a/lib/history/autosave.ts b/lib/history/autosave.ts index 18f43d18..74ef21d4 100644 --- a/lib/history/autosave.ts +++ b/lib/history/autosave.ts @@ -213,16 +213,14 @@ export async function takeSnapshot(): Promise { try { const file = await requestSaveDocument(active.ext.toUpperCase()); if (session !== active || active.stopped) return null; - const bytes = await file.arrayBuffer(); - // A document switch or disabled recovery retires this export. Its late - // bytes/failure must not write history or stop the replacement session. - if (session !== active || active.stopped) return null; + // Let storage check the exported file's size before copying its bytes. + // The owner signal also guards budget checks and asynchronous file reads. const doc = await putSnapshot( { id: active.docId, title: active.title, origin: active.origin, - bytes, + bytes: file, }, active.storageAbort.signal, ); diff --git a/lib/history/store.ts b/lib/history/store.ts index 4a212046..8e9e883e 100644 --- a/lib/history/store.ts +++ b/lib/history/store.ts @@ -243,6 +243,7 @@ async function writeSnapshot( input: SnapshotInput, budget: number, signal?: AbortSignal, + quotaRetry = false, ): Promise { const byteLength = payload.byteLength; const now = stamp(); @@ -250,6 +251,9 @@ async function writeSnapshot( return withStores([DOCS_STORE, BLOBS_STORE, HANDLES_STORE], 'readwrite', async (tx) => { if (!watchSnapshotCancellation(tx, signal)) return null; + // Reclaim and replace in one transaction: a failed retry must not consume + // another document's only recovery point without storing this snapshot. + if (quotaRetry && !(await evictColdest(tx, input.id))) return null; const docs = tx.objectStore(DOCS_STORE); const existing = input.id ? ((await requestToPromise(docs.get(input.id))) as HistoryDoc | undefined) : undefined; @@ -312,6 +316,10 @@ export async function putSnapshot(input: SnapshotInput, signal?: AbortSignal): P if (signal?.aborted) return null; const budget = await storageBudget(); if (signal?.aborted) return null; + // File/Blob sizes are available without allocating another full byte buffer. + // Reject an impossible snapshot before reading it, retaining existing recovery. + const inputSize = 'byteLength' in input.bytes ? input.bytes.byteLength : input.bytes.size; + if (inputSize > budget) return null; const payload = await toBytes(input.bytes); if (signal?.aborted) return null; // No eviction can make an individually oversized snapshot fit. Preserve the @@ -321,34 +329,23 @@ export async function putSnapshot(input: SnapshotInput, signal?: AbortSignal): P return await writeSnapshot(payload, input, budget, signal); } catch (error) { if (signal?.aborted || !isQuotaError(error)) return null; - // Out of room: drop the coldest document and try once. Retrying forever - // would trade the user's whole library for one snapshot. - const evicted = await evictColdest(input.id, signal); - if (!evicted || signal?.aborted) return null; + // Retry once with transactional eviction. A second failure rolls back the + // eviction along with the new snapshot, retaining the previous recovery. try { - return await writeSnapshot(payload, input, budget, signal); + return await writeSnapshot(payload, input, budget, signal, true); } catch { return null; } } } -/** Delete the least-recently-opened document. Returns false when there is none. */ -async function evictColdest(protectedId?: string, signal?: AbortSignal): Promise { - try { - return ( - (await withStores([DOCS_STORE, BLOBS_STORE, HANDLES_STORE], 'readwrite', async (tx) => { - if (!watchSnapshotCancellation(tx, signal)) return false; - const docs = (await readAllDocs(tx)).filter((doc) => doc.id !== protectedId); - if (!docs.length) return false; - const coldest = docs.sort((a, b) => a.lastOpenedAt - b.lastOpenedAt)[0]; - await deleteDocumentData(tx, coldest.id); - return true; - })) ?? false - ); - } catch { - return false; - } +/** Delete the coldest document within the caller's replacement transaction. */ +async function evictColdest(tx: IDBTransaction, protectedId?: string): Promise { + const docs = (await readAllDocs(tx)).filter((doc) => doc.id !== protectedId); + if (!docs.length) return false; + const coldest = docs.sort((a, b) => a.lastOpenedAt - b.lastOpenedAt)[0]; + await deleteDocumentData(tx, coldest.id); + return true; } /** diff --git a/packages/agent-core/README.md b/packages/agent-core/README.md index d0e04c26..eee305be 100644 --- a/packages/agent-core/README.md +++ b/packages/agent-core/README.md @@ -39,6 +39,44 @@ only knows the `LLMProvider` interface and the `AgentTool` registry. The document product uses a default-off assistant switch. Opening the panel never downloads a model or connects a service. Runtime configuration is reached through the usage settings; generated selection edits require explicit review and confirmation. Browser-agent tools are documented separately in the developer reference. +## Fetch provider request bounds + +OpenAI-compatible, Gemini, Ollama and Loopback providers use a five-minute total deadline +per request, covering headers and complete JSON or incremental response reads. Set `timeoutMs` +in their constructor options or `createProvider` options to override it. The +duration must be positive, finite and at most 2,147,483,647 milliseconds. +Caller cancellation retains its original reason; deadline expiry rejects with +`TimeoutError`. Both paths abort the transport and settle the provider call even +when an injected transport ignores cancellation. A late response cannot become +a successful result. Timers and caller subscriptions are released on settlement. + +HTTP failures carry a numeric `status` and a safe provider/status message. +Upstream error bodies are cancelled without being read or included in errors. +Hosts can classify structured errors directly; serialized messages retain the +same provider/status category. No request is retried automatically. These bounds +do not alter the Anthropic SDK or browser-local inference lifetimes. + +`maxResponseBytes` bounds actual response bytes, including streaming frames, to +8 MiB by default. It must be a positive safe integer. Declared oversized bodies +are rejected before reading; actual bytes are checked even if Content-Length is +missing or incorrect. Native response readers are cancelled on overflow, Stop, +timeout or consumer failure. Host transports exposing only `json()` retain a +post-materialization byte check and the same deadline. + +Tool-free `chatStream` uses SSE for OpenAI-compatible/Gemini/Ollama endpoints and +NDJSON for Loopback. It emits text as it arrives and resolves to the same neutral +response shape. Compatible endpoints returning complete JSON remain supported. +A disconnected stream without a terminal finish marker fails; it is never a +complete answer. Tool-bearing requests use complete JSON, and schema-constrained +generation remains complete and validated by its caller before document writes. + +Browser-local provider hosts may supply `prepareRuntimeAssets(capability)` to +persist the runtime actually being activated. This advisory callback never +starts model inference or changes the destination; persistence failures cannot +block online use. The app stores build-owned capability lists and restores +previously prepared capabilities across updates. Cloud setup prepares no local +inference or PDF runtime. + ## Optional timing observations `runAgent` accepts `onFirstProviderText?: () => void`, called once for the first diff --git a/packages/agent-core/src/llm/gemini.ts b/packages/agent-core/src/llm/gemini.ts index 000c3ada..e4a4c137 100644 --- a/packages/agent-core/src/llm/gemini.ts +++ b/packages/agent-core/src/llm/gemini.ts @@ -8,14 +8,17 @@ * OpenAI/Anthropic ones — see toGeminiContents for how neutral tool_use ids are * mapped back to names. Pure converters are exported for unit testing. */ +import { assertStreamRecord, fetchProviderStream } from './provider-stream'; import { getApiKey } from './keys'; import { DEFAULT_SYSTEM_PROMPT } from './prompt'; +import { fetchProviderJson, type ProviderHttpOptions } from './provider-http'; import type { LLMContent, LLMMessage, LLMProvider, LLMResponse, LLMToolDef } from './types'; const DEFAULT_MODEL = 'gemini-2.0-flash'; const DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; interface GeminiPart { + thought?: boolean; text?: string; functionCall?: { name: string; args?: Record }; functionResponse?: { name: string; response: Record }; @@ -28,8 +31,6 @@ export interface GeminiResponse { candidates?: Array<{ content?: { parts?: GeminiPart[] }; finishReason?: string }>; } -type FetchLike = (input: string, init: RequestInit) => Promise; - /** Convert neutral tool defs to Gemini's `functionDeclarations` shape. */ export function toGeminiTools(tools: LLMToolDef[]): Record[] { if (tools.length === 0) return []; @@ -109,6 +110,7 @@ export function parseGeminiResponse(response: GeminiResponse): LLMResponse { const toolCalls: LLMResponse['toolCalls'] = []; const assistant: LLMContent[] = []; parts.forEach((part, index) => { + if (part.thought) return; if (part.text) { text += part.text; assistant.push({ type: 'text', text: part.text }); @@ -129,13 +131,11 @@ export function parseGeminiResponse(response: GeminiResponse): LLMResponse { }; } -export interface GeminiProviderOptions { +export interface GeminiProviderOptions extends ProviderHttpOptions { apiKey?: string; geminiModel?: string; systemPrompt?: string; geminiBaseURL?: string; - /** Inject a fetch implementation (tests). */ - fetchImpl?: FetchLike; } export class GeminiProvider implements LLMProvider { @@ -145,26 +145,47 @@ export class GeminiProvider implements LLMProvider { private readonly model: string; private readonly systemPrompt: string; private readonly baseURL: string; - private readonly fetchImpl?: FetchLike; + private readonly requestOptions: ProviderHttpOptions; constructor(options: GeminiProviderOptions = {}) { this.apiKey = options.apiKey ?? getApiKey('gemini'); this.model = options.geminiModel ?? DEFAULT_MODEL; this.systemPrompt = options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT; this.baseURL = options.geminiBaseURL ?? DEFAULT_BASE_URL; - this.fetchImpl = options.fetchImpl; + this.requestOptions = { + fetchImpl: options.fetchImpl, + timeoutMs: options.timeoutMs, + maxResponseBytes: options.maxResponseBytes, + }; } isReady(): boolean { return !!this.apiKey; } - async chat(messages: LLMMessage[], tools: LLMToolDef[], signal?: AbortSignal): Promise { + chat(messages: LLMMessage[], tools: LLMToolDef[], signal?: AbortSignal): Promise { + return this.request(messages, tools, signal); + } + + chatStream( + messages: LLMMessage[], + tools: LLMToolDef[], + onDelta: (text: string) => void, + signal?: AbortSignal, + ): Promise { + return tools.length ? this.chat(messages, tools, signal) : this.request(messages, tools, signal, onDelta); + } + + private async request( + messages: LLMMessage[], + tools: LLMToolDef[], + signal?: AbortSignal, + onDelta?: (text: string) => void, + ): Promise { signal?.throwIfAborted(); if (!this.apiKey) { throw new Error('Gemini API key is not configured'); } - const doFetch = this.fetchImpl ?? (globalThis.fetch.bind(globalThis) as FetchLike); const body: Record = { systemInstruction: { parts: [{ text: this.systemPrompt }] }, contents: toGeminiContents(messages), @@ -172,17 +193,60 @@ export class GeminiProvider implements LLMProvider { const geminiTools = toGeminiTools(tools); if (geminiTools.length > 0) body.tools = geminiTools; - const response = await doFetch(`${this.baseURL}/models/${this.model}:generateContent`, { - method: 'POST', - ...(signal ? { signal } : {}), - headers: { 'Content-Type': 'application/json', 'x-goog-api-key': this.apiKey }, - body: JSON.stringify(body), - }); - if (!response.ok) { - const detail = await response.text().catch(() => ''); - throw new Error(`Gemini request failed: ${response.status} ${detail}`.trim()); + if (onDelta) { + let text = ''; + let finish: string | undefined; + let fallback: LLMResponse | undefined; + await fetchProviderStream( + 'Gemini', + `${this.baseURL}/models/${this.model}:streamGenerateContent?alt=sse`, + { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/json', 'x-goog-api-key': this.apiKey }, + body: JSON.stringify(body), + }, + this.requestOptions, + 'sse', + (value) => { + assertStreamRecord('Gemini', value); + const candidate = (value as GeminiResponse).candidates?.[0]; + for (const part of candidate?.content?.parts ?? []) { + if (part.functionCall) throw new Error('Unexpected Gemini tool response'); + if (part.thought || !part.text) continue; + if (finish || typeof part.text !== 'string') throw new Error('Invalid Gemini stream'); + text += part.text; + onDelta(part.text); + signal?.throwIfAborted(); + } + if (candidate?.finishReason) { + finish = candidate.finishReason; + return false; + } + }, + (value) => { + assertStreamRecord('Gemini', value); + fallback = parseGeminiResponse(value as GeminiResponse); + if (fallback.toolCalls.length) throw new Error('Unexpected Gemini tool response'); + if (fallback.text) onDelta(fallback.text); + }, + ); + if (fallback) return fallback; + if (!finish) throw new Error('Incomplete Gemini stream'); + return parseGeminiResponse({ candidates: [{ content: { parts: [{ text }] }, finishReason: finish }] }); } - const data = (await response.json()) as GeminiResponse; + + const data = await fetchProviderJson( + 'Gemini', + `${this.baseURL}/models/${this.model}:generateContent`, + { + method: 'POST', + ...(signal ? { signal } : {}), + headers: { 'Content-Type': 'application/json', 'x-goog-api-key': this.apiKey }, + body: JSON.stringify(body), + }, + this.requestOptions, + ); signal?.throwIfAborted(); return parseGeminiResponse(data); } diff --git a/packages/agent-core/src/llm/local.ts b/packages/agent-core/src/llm/local.ts index 334bd471..15c12e71 100644 --- a/packages/agent-core/src/llm/local.ts +++ b/packages/agent-core/src/llm/local.ts @@ -1,3 +1,4 @@ +import type { RuntimeAssetPreparation } from './runtime-assets'; import type { LLMMessage, LLMResponse, LLMToolDef, LocalLLMProvider } from './types'; import { WebLLMProvider, type WebLLMProviderOptions } from './webllm'; import { WllamaProvider, type WllamaProviderOptions } from './wllama'; @@ -25,6 +26,7 @@ export async function detectGPUAdapter(): Promise { } export interface LocalInferenceOptions { + prepareRuntimeAssets?: RuntimeAssetPreparation; generation?: GenerationOptions; webllm?: WebLLMProviderOptions; wllama?: WllamaProviderOptions; @@ -101,10 +103,16 @@ export class LocalInferenceProvider implements LocalLLMProvider { provider = backend === 'webllm' ? (this.options.gpu?.() ?? - new WebLLMProvider({ ...this.options.webllm, generation: this.generation, chatOnly: true })) + new WebLLMProvider({ + ...this.options.webllm, + prepareRuntimeAssets: this.options.prepareRuntimeAssets ?? this.options.webllm?.prepareRuntimeAssets, + generation: this.generation, + chatOnly: true, + })) : (this.options.cpu?.() ?? new WllamaProvider({ ...this.options.wllama, + prepareRuntimeAssets: this.options.prepareRuntimeAssets ?? this.options.wllama?.prepareRuntimeAssets, modelUrl: this.options.wllama?.modelUrl || DEFAULT_CPU_MODEL_URL, cpuOnly: true, generation: this.generation, diff --git a/packages/agent-core/src/llm/loopback.ts b/packages/agent-core/src/llm/loopback.ts index 8dd1da44..e4aea65e 100644 --- a/packages/agent-core/src/llm/loopback.ts +++ b/packages/agent-core/src/llm/loopback.ts @@ -1,3 +1,5 @@ +import { fetchProviderJson, type ProviderHttpOptions } from './provider-http'; +import { assertStreamRecord, fetchProviderStream } from './provider-stream'; import type { LLMMessage, LLMResponse, LLMToolDef, LocalLLMProvider } from './types'; export function validateLoopbackUrl(value: string): string { @@ -14,11 +16,9 @@ export function validateLoopbackUrl(value: string): string { throw new Error('Invalid loopback service origin'); return url.origin; } -export interface LoopbackProviderOptions { +export interface LoopbackProviderOptions extends ProviderHttpOptions { model: string; baseUrl?: string; - timeoutMs?: number; - fetchImpl?: (url: string, init: RequestInit) => Promise; } /** Explicit opt-in native service. Never downloads models or follows redirects. */ export class LoopbackProvider implements LocalLLMProvider { @@ -42,26 +42,24 @@ export class LoopbackProvider implements LocalLLMProvider { })); } private async request(path: string, body?: object, signal?: AbortSignal): Promise { - const timeout = AbortSignal.timeout(this.options.timeoutMs ?? 300000); - const combined = AbortSignal.any([this.lifetime.signal, timeout, ...(signal ? [signal] : [])]); - combined.throwIfAborted(); - const response = await (this.options.fetchImpl ?? fetch)(this.origin + '/api/' + path, { - method: body ? 'POST' : 'GET', - credentials: 'omit', - redirect: 'error', - signal: combined, - headers: body ? { 'Content-Type': 'application/json' } : undefined, - body: body ? JSON.stringify(body) : undefined, + return fetchProviderJson( + 'Local service', + this.origin + '/api/' + path, + { + method: body ? 'POST' : 'GET', + credentials: 'omit', + redirect: 'error', + signal: AbortSignal.any([this.lifetime.signal, ...(signal ? [signal] : [])]), + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }, + this.options, + ).catch((error: unknown) => { + if (error instanceof Error && 'status' in error) error.message = 'Local service request failed'; + throw error; }); - combined.throwIfAborted(); - if (!response.ok) { - await response.body?.cancel(); - throw Object.assign(new Error('Local service request failed'), { status: response.status }); - } - const result: unknown = await response.json(); - combined.throwIfAborted(); - return result; } + private async check(): Promise { const result = (await this.request('tags')) as { models?: Array<{ name?: string }> } | null; if (!Array.isArray(result?.models) || !result.models.some((model) => model?.name === this.options.model)) @@ -77,6 +75,15 @@ export class LoopbackProvider implements LocalLLMProvider { if (tools.length) return Promise.reject(new Error('Local service cannot execute editor tools')); return this.generate(messages, undefined, signal); } + chatStream( + messages: LLMMessage[], + tools: LLMToolDef[], + onDelta: (text: string) => void, + signal?: AbortSignal, + ): Promise { + if (tools.length) return Promise.reject(new Error('Local service cannot execute editor tools')); + return this.generate(messages, undefined, signal, onDelta); + } generateJSON(messages: LLMMessage[], schema: Record, signal?: AbortSignal): Promise { return this.generate(messages, schema, signal); } @@ -84,6 +91,7 @@ export class LoopbackProvider implements LocalLLMProvider { messages: LLMMessage[], schema?: Record, signal?: AbortSignal, + onDelta?: (text: string) => void, ): Promise { if (!this.isReady()) throw new Error('Connect the local model first'); // Translate completed host tool exchanges to Ollama's documented chat history. @@ -117,6 +125,54 @@ export class LoopbackProvider implements LocalLLMProvider { nativeMessages.push({ role: 'tool', tool_name: name, content: block.content }); } } + if (onDelta) { + let text = ''; + let finish: string | undefined; + const record = (value: unknown) => { + assertStreamRecord('Local service', value); + const data = value as { + done?: boolean; + done_reason?: string; + message?: { content?: string; tool_calls?: unknown[] }; + }; + if (data.message?.tool_calls?.length) throw new Error('Unexpected local service tool response'); + const piece = data.message?.content; + if (piece !== undefined && typeof piece !== 'string') throw new Error('Invalid local service stream'); + if (piece) { + text += piece; + onDelta(piece); + } + if (data.done === true) { + finish = data.done_reason ?? 'stop'; + return false; + } + }; + await fetchProviderStream( + 'Local service', + this.origin + '/api/chat', + { + method: 'POST', + credentials: 'omit', + redirect: 'error', + signal: AbortSignal.any([this.lifetime.signal, ...(signal ? [signal] : [])]), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: this.options.model, messages: nativeMessages, stream: true }), + }, + this.options, + 'ndjson', + record, + (value) => { + record(value); + }, + ); + if (!finish) throw new Error('Incomplete local service stream'); + return { + text, + toolCalls: [], + stopReason: finish === 'length' ? 'max_tokens' : finish, + assistant: { role: 'assistant', content: text }, + }; + } const result = (await this.request( 'chat', { diff --git a/packages/agent-core/src/llm/ollama.ts b/packages/agent-core/src/llm/ollama.ts index 39b909ee..807a5839 100644 --- a/packages/agent-core/src/llm/ollama.ts +++ b/packages/agent-core/src/llm/ollama.ts @@ -9,23 +9,21 @@ * remote/proxied Ollama deployments that require auth. */ import { type OpenAICompletion, parseOpenAIResponse, toOpenAIMessages, toOpenAITools } from './openai-format'; +import { fetchOpenAIStream } from './openai-stream'; import { getApiKey } from './keys'; import { DEFAULT_SYSTEM_PROMPT } from './prompt'; +import { fetchProviderJson, type ProviderHttpOptions } from './provider-http'; import type { LLMMessage, LLMProvider, LLMResponse, LLMToolDef } from './types'; const DEFAULT_MODEL = 'llama3.2'; const DEFAULT_BASE_URL = 'http://localhost:11434/v1'; -type FetchLike = (input: string, init: RequestInit) => Promise; - -export interface OllamaProviderOptions { +export interface OllamaProviderOptions extends ProviderHttpOptions { /** Optional — only needed for a remote/proxied Ollama that requires auth. */ ollamaApiKey?: string; ollamaModel?: string; systemPrompt?: string; ollamaBaseURL?: string; - /** Inject a fetch implementation (tests). */ - fetchImpl?: FetchLike; } export class OllamaProvider implements LLMProvider { @@ -35,14 +33,18 @@ export class OllamaProvider implements LLMProvider { private readonly model: string; private readonly systemPrompt: string; private readonly baseURL: string; - private readonly fetchImpl?: FetchLike; + private readonly requestOptions: ProviderHttpOptions; constructor(options: OllamaProviderOptions = {}) { this.apiKey = options.ollamaApiKey ?? getApiKey('ollama'); this.model = options.ollamaModel ?? DEFAULT_MODEL; this.systemPrompt = options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT; this.baseURL = options.ollamaBaseURL ?? DEFAULT_BASE_URL; - this.fetchImpl = options.fetchImpl; + this.requestOptions = { + fetchImpl: options.fetchImpl, + timeoutMs: options.timeoutMs, + maxResponseBytes: options.maxResponseBytes, + }; } /** Local server: ready without a key (the optional key is only for remote auth). */ @@ -50,27 +52,56 @@ export class OllamaProvider implements LLMProvider { return true; } - async chat(messages: LLMMessage[], tools: LLMToolDef[]): Promise { - const doFetch = this.fetchImpl ?? (globalThis.fetch.bind(globalThis) as FetchLike); + async chatStream( + messages: LLMMessage[], + tools: LLMToolDef[], + onDelta: (text: string) => void, + signal?: AbortSignal, + ): Promise { + if (tools.length) return this.chat(messages, tools, signal); + signal?.throwIfAborted(); + return fetchOpenAIStream( + 'Ollama', + `${this.baseURL}/chat/completions`, + { + method: 'POST', + signal, + headers: { + 'Content-Type': 'application/json', + ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}), + }, + body: JSON.stringify({ + model: this.model, + messages: toOpenAIMessages(messages, this.systemPrompt), + stream: true, + stream_options: { include_usage: true }, + }), + }, + this.requestOptions, + onDelta, + ); + } + + async chat(messages: LLMMessage[], tools: LLMToolDef[], signal?: AbortSignal): Promise { const headers: Record = { 'Content-Type': 'application/json' }; if (this.apiKey) { headers.Authorization = `Bearer ${this.apiKey}`; } - const response = await doFetch(`${this.baseURL}/chat/completions`, { - method: 'POST', - headers, - body: JSON.stringify({ - model: this.model, - messages: toOpenAIMessages(messages, this.systemPrompt), - tools: toOpenAITools(tools), - tool_choice: 'auto', - }), - }); - if (!response.ok) { - const detail = await response.text().catch(() => ''); - throw new Error(`Ollama request failed: ${response.status} ${detail}`.trim()); - } - const completion = (await response.json()) as OpenAICompletion; + const completion = await fetchProviderJson( + 'Ollama', + `${this.baseURL}/chat/completions`, + { + method: 'POST', + ...(signal ? { signal } : {}), + headers, + body: JSON.stringify({ + model: this.model, + messages: toOpenAIMessages(messages, this.systemPrompt), + ...(tools.length ? { tools: toOpenAITools(tools), tool_choice: 'auto' } : {}), + }), + }, + this.requestOptions, + ); return parseOpenAIResponse(completion); } } diff --git a/packages/agent-core/src/llm/openai-stream.ts b/packages/agent-core/src/llm/openai-stream.ts new file mode 100644 index 00000000..4ad746fb --- /dev/null +++ b/packages/agent-core/src/llm/openai-stream.ts @@ -0,0 +1,55 @@ +import { type OpenAICompletion, type OpenAIStreamChunk, parseOpenAIResponse } from './openai-format'; +import { assertStreamRecord, fetchProviderStream } from './provider-stream'; +import type { ProviderHttpOptions } from './provider-http'; +import type { LLMResponse } from './types'; + +/** Tool-free OpenAI-compatible answers; tool-bearing calls use complete JSON. */ +export async function fetchOpenAIStream( + provider: string, + url: string, + init: RequestInit, + options: ProviderHttpOptions, + onDelta: (text: string) => void, +): Promise { + let text = ''; + let finish: string | undefined; + let usage: OpenAICompletion['usage']; + let fallback: LLMResponse | undefined; + await fetchProviderStream( + provider, + url, + init, + options, + 'sse', + (value) => { + if (value === '[DONE]') { + if (!finish) throw new Error(`Incomplete ${provider} stream`); + return false; + } + assertStreamRecord(provider, value); + const chunk = value as OpenAIStreamChunk; + const choice = chunk.choices?.[0]; + if (choice?.delta?.tool_calls?.length || choice?.finish_reason === 'tool_calls') + throw new Error(`Unexpected ${provider} tool response`); + if (chunk.usage) usage = chunk.usage; + if (choice?.delta?.content) { + if (finish || typeof choice.delta.content !== 'string') throw new Error(`Invalid ${provider} stream`); + text += choice.delta.content; + onDelta(choice.delta.content); + } + if (choice?.finish_reason) finish = choice.finish_reason; + }, + (value) => { + assertStreamRecord(provider, value); + fallback = parseOpenAIResponse(value as unknown as OpenAICompletion); + if (fallback.toolCalls.length) throw new Error(`Unexpected ${provider} tool response`); + if (fallback.text) onDelta(fallback.text); + }, + ); + if (fallback) return fallback; + if (!finish) throw new Error(`Incomplete ${provider} stream`); + return parseOpenAIResponse({ + choices: [{ message: { content: text }, finish_reason: finish }], + ...(usage ? { usage } : {}), + }); +} diff --git a/packages/agent-core/src/llm/openai.ts b/packages/agent-core/src/llm/openai.ts index 98db2313..bfa3934f 100644 --- a/packages/agent-core/src/llm/openai.ts +++ b/packages/agent-core/src/llm/openai.ts @@ -7,22 +7,20 @@ * avoid another dependency; the request shape is small and stable. */ import { type OpenAICompletion, parseOpenAIResponse, toOpenAIMessages, toOpenAITools } from './openai-format'; +import { fetchOpenAIStream } from './openai-stream'; import { getApiKey } from './keys'; import { DEFAULT_SYSTEM_PROMPT } from './prompt'; +import { fetchProviderJson, type ProviderHttpOptions } from './provider-http'; import type { LLMMessage, LLMProvider, LLMResponse, LLMToolDef } from './types'; const DEFAULT_MODEL = 'gpt-4o-mini'; const DEFAULT_BASE_URL = 'https://api.openai.com/v1'; -type FetchLike = (input: string, init: RequestInit) => Promise; - -export interface OpenAIProviderOptions { +export interface OpenAIProviderOptions extends ProviderHttpOptions { apiKey?: string; model?: string; systemPrompt?: string; baseURL?: string; - /** Inject a fetch implementation (tests). */ - fetchImpl?: FetchLike; } export class OpenAIProvider implements LLMProvider { @@ -32,42 +30,72 @@ export class OpenAIProvider implements LLMProvider { private readonly model: string; private readonly systemPrompt: string; private readonly baseURL: string; - private readonly fetchImpl?: FetchLike; + private readonly requestOptions: ProviderHttpOptions; constructor(options: OpenAIProviderOptions = {}) { this.apiKey = options.apiKey ?? getApiKey('openai'); this.model = options.model ?? DEFAULT_MODEL; this.systemPrompt = options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT; this.baseURL = (options.baseURL ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); - this.fetchImpl = options.fetchImpl; + this.requestOptions = { + fetchImpl: options.fetchImpl, + timeoutMs: options.timeoutMs, + maxResponseBytes: options.maxResponseBytes, + }; } isReady(): boolean { return !!this.apiKey; } + async chatStream( + messages: LLMMessage[], + tools: LLMToolDef[], + onDelta: (text: string) => void, + signal?: AbortSignal, + ): Promise { + if (tools.length) return this.chat(messages, tools, signal); + signal?.throwIfAborted(); + if (!this.apiKey) throw new Error('OpenAI API key is not configured'); + return fetchOpenAIStream( + 'OpenAI', + `${this.baseURL}/chat/completions`, + { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` }, + body: JSON.stringify({ + model: this.model, + messages: toOpenAIMessages(messages, this.systemPrompt), + stream: true, + stream_options: { include_usage: true }, + }), + }, + this.requestOptions, + onDelta, + ); + } + async chat(messages: LLMMessage[], tools: LLMToolDef[], signal?: AbortSignal): Promise { signal?.throwIfAborted(); if (!this.apiKey) { throw new Error('OpenAI API key is not configured'); } - const doFetch = this.fetchImpl ?? (globalThis.fetch.bind(globalThis) as FetchLike); - const response = await doFetch(`${this.baseURL}/chat/completions`, { - method: 'POST', - ...(signal ? { signal } : {}), - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` }, - body: JSON.stringify({ - model: this.model, - messages: toOpenAIMessages(messages, this.systemPrompt), - tools: toOpenAITools(tools), - tool_choice: 'auto', - }), - }); - if (!response.ok) { - const detail = await response.text().catch(() => ''); - throw new Error(`OpenAI request failed: ${response.status} ${detail}`.trim()); - } - const completion = (await response.json()) as OpenAICompletion; + const completion = await fetchProviderJson( + 'OpenAI', + `${this.baseURL}/chat/completions`, + { + method: 'POST', + ...(signal ? { signal } : {}), + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` }, + body: JSON.stringify({ + model: this.model, + messages: toOpenAIMessages(messages, this.systemPrompt), + ...(tools.length ? { tools: toOpenAITools(tools), tool_choice: 'auto' } : {}), + }), + }, + this.requestOptions, + ); signal?.throwIfAborted(); return parseOpenAIResponse(completion); } diff --git a/packages/agent-core/src/llm/provider-http.ts b/packages/agent-core/src/llm/provider-http.ts new file mode 100644 index 00000000..bd53a437 --- /dev/null +++ b/packages/agent-core/src/llm/provider-http.ts @@ -0,0 +1,124 @@ +export interface ProviderHttpOptions { + /** Total request deadline, including response reads. Defaults to five minutes. */ + timeoutMs?: number; + /** Maximum decoded response bytes, including streaming protocol frames. Defaults to 8 MiB. */ + maxResponseBytes?: number; + /** Inject a fetch implementation (tests or host transport). */ + fetchImpl?: (url: string, init: RequestInit) => Promise; +} + +/** One lifetime and byte budget for headers, JSON and incremental protocols. */ +export async function fetchProviderResponse( + provider: string, + url: string, + init: RequestInit, + options: ProviderHttpOptions, + consume: (response: Response, chunks: AsyncIterable, signal: AbortSignal) => Promise, +): Promise { + const owner = init.signal; + owner?.throwIfAborted(); + const timeoutMs = options.timeoutMs ?? 300_000; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) + throw new RangeError('Provider timeout must be a positive timer duration'); + const maxResponseBytes = options.maxResponseBytes ?? 8 * 1024 * 1024; + if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0) + throw new RangeError('Provider response budget must be a positive safe integer'); + const controller = new AbortController(); + const forwardAbort = () => controller.abort(owner?.reason); + owner?.addEventListener('abort', forwardAbort, { once: true }); + let response: Response | undefined; + let reader: ReadableStreamDefaultReader | undefined; + const cancelBody = () => { + if (reader) void reader.cancel().catch(() => {}); + else if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {}); + }; + const rejectOversized = () => { + const error = new RangeError('AI response exceeds size limit'); + controller.abort(error); + throw error; + }; + let onAbort: () => void = () => {}; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => { + cancelBody(); + reject(controller.signal.reason); + }; + controller.signal.addEventListener('abort', onAbort, { once: true }); + }); + const timer = setTimeout(() => controller.abort(new DOMException('AI request timed out', 'TimeoutError')), timeoutMs); + async function* readChunks(): AsyncGenerator { + let bytes = 0; + // A host-only json() response remains supported; native Responses use the + // bounded reader, without allocating the full body before checking its size. + if (!response!.body) { + const text = JSON.stringify(await response!.json()); + controller.signal.throwIfAborted(); + if (new TextEncoder().encode(text).byteLength > maxResponseBytes) rejectOversized(); + yield text; + return; + } + reader = response!.body.getReader(); + const activeReader = reader; + const decoder = new TextDecoder(); + let complete = false; + try { + while (true) { + const chunk = await activeReader.read(); + controller.signal.throwIfAborted(); + if (chunk.done) { + complete = true; + break; + } + bytes += chunk.value.byteLength; + if (bytes > maxResponseBytes) rejectOversized(); + yield decoder.decode(chunk.value, { stream: true }); + } + yield decoder.decode(); + } finally { + // Protocol completion and parser/callback failure can stop before EOF. + if (!complete) void activeReader.cancel().catch(() => {}); + activeReader.releaseLock(); + reader = undefined; + } + } + try { + const request = (async () => { + const fetcher = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + response = await fetcher(url, { ...init, signal: controller.signal }); + if (controller.signal.aborted) cancelBody(); + controller.signal.throwIfAborted(); + if (!response.ok) { + cancelBody(); + throw Object.assign(new Error(`${provider} request failed: ${response.status}`), { status: response.status }); + } + if (Number(response.headers?.get('Content-Length')) > maxResponseBytes) rejectOversized(); + const value = await consume(response, readChunks(), controller.signal); + controller.signal.throwIfAborted(); + return value; + })(); + return await Promise.race([request, aborted]); + } catch (error) { + cancelBody(); + throw error; + } finally { + clearTimeout(timer); + owner?.removeEventListener('abort', forwardAbort); + controller.signal.removeEventListener('abort', onAbort); + } +} + +export async function readProviderJson(chunks: AsyncIterable): Promise { + let text = ''; + for await (const chunk of chunks) text += chunk; + return JSON.parse(text) as T; +} + +/** Fetch-based providers share cancellation, deadlines and safe HTTP failures. */ +export function fetchProviderJson( + provider: string, + url: string, + init: RequestInit, + options: ProviderHttpOptions, +): Promise { + return fetchProviderResponse(provider, url, init, options, (_response, chunks) => readProviderJson(chunks)); +} diff --git a/packages/agent-core/src/llm/provider-stream.ts b/packages/agent-core/src/llm/provider-stream.ts new file mode 100644 index 00000000..8f3dadf0 --- /dev/null +++ b/packages/agent-core/src/llm/provider-stream.ts @@ -0,0 +1,74 @@ +import { fetchProviderResponse, readProviderJson, type ProviderHttpOptions } from './provider-http'; + +/** Parse native SSE/NDJSON incrementally within the shared bounded lifetime. */ +export function fetchProviderStream( + provider: string, + url: string, + init: RequestInit, + options: ProviderHttpOptions, + protocol: 'sse' | 'ndjson', + onRecord: (value: unknown) => boolean | void, + onJson: (value: unknown) => void, +): Promise { + return fetchProviderResponse(provider, url, init, options, async (response, chunks, signal) => { + if (!response.body || (protocol === 'sse' && response.headers?.get('Content-Type')?.includes('application/json'))) { + const value = await readProviderJson(chunks); + signal.throwIfAborted(); + onJson(value); + return; + } + let buffer = ''; + let data: string[] = []; + const parse = (text: string): boolean | void => { + signal.throwIfAborted(); + if (text === '[DONE]' && protocol === 'sse') return onRecord('[DONE]'); + let value: unknown; + try { + value = JSON.parse(text); + } catch { + throw new Error(`Invalid ${provider} stream`); + } + return onRecord(value); + }; + const line = (value: string): boolean | void => { + if (protocol === 'ndjson') return value.trim() ? parse(value) : undefined; + if (!value) { + const text = data.join('\n'); + data = []; + return text ? parse(text) : undefined; + } + if (value.startsWith('data:')) data.push(value.slice(5).replace(/^ /, '')); + }; + for await (const chunk of chunks) { + buffer += chunk; + while (true) { + const index = buffer.search(/[\r\n]/); + if (index < 0 || (buffer[index] === '\r' && index === buffer.length - 1)) break; + const value = buffer.slice(0, index); + const width = buffer[index] === '\r' && buffer[index + 1] === '\n' ? 2 : 1; + buffer = buffer.slice(index + width); + if (line(value) === false) return; + } + } + if (buffer.endsWith('\r')) { + if (line(buffer.slice(0, -1)) === false) return; + buffer = ''; + } + // SSE requires a complete event delimiter; NDJSON permits the final line + // without a newline. Adapters still require their native terminal marker. + if (protocol === 'ndjson' && buffer.trim()) line(buffer); + }); +} + +/** In-band service errors retain only their safe numeric status. */ +export function assertStreamRecord(provider: string, value: unknown): asserts value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid ${provider} stream`); + if ('error' in value) { + const raw = (value as { error?: { code?: unknown } }).error?.code; + const status = typeof raw === 'number' && Number.isInteger(raw) && raw >= 400 && raw < 600 ? raw : undefined; + throw Object.assign( + new Error(status ? `${provider} request failed: ${status}` : `${provider} stream failed`), + status ? { status } : {}, + ); + } +} diff --git a/packages/agent-core/src/llm/runtime-assets.ts b/packages/agent-core/src/llm/runtime-assets.ts new file mode 100644 index 00000000..70b9ca2c --- /dev/null +++ b/packages/agent-core/src/llm/runtime-assets.ts @@ -0,0 +1,15 @@ +/** Build hosts may persist the runtime that is actually being activated. */ +export type RuntimeCapability = 'webgpu' | 'wllama' | 'wllama-compat' | 'cpu' | 'cpu-compat'; +export type RuntimeAssetPreparation = (capability: RuntimeCapability) => unknown; + +/** Persistence is advisory and must never stall initialization or surface quota failures. */ +export function prepareRuntimeAssets( + prepare: RuntimeAssetPreparation | undefined, + capability: RuntimeCapability, +): void { + try { + void Promise.resolve(prepare?.(capability)).catch(() => undefined); + } catch { + // Restricted host storage does not prevent using the runtime online. + } +} diff --git a/packages/agent-core/src/llm/webllm.ts b/packages/agent-core/src/llm/webllm.ts index 86b4b337..c3c09236 100644 --- a/packages/agent-core/src/llm/webllm.ts +++ b/packages/agent-core/src/llm/webllm.ts @@ -1,3 +1,4 @@ +import { prepareRuntimeAssets, type RuntimeAssetPreparation } from './runtime-assets'; /** * WebLLM offline LLM provider. * @@ -92,6 +93,7 @@ export async function isModelCached(modelId: string, source: ModelSource = {}): } export interface WebLLMProviderOptions extends ModelSource { + prepareRuntimeAssets?: RuntimeAssetPreparation; /** Notify hosts when an initialized engine becomes unavailable, including while idle. */ onUnavailable?: () => void; generation?: GenerationOptions; @@ -453,10 +455,11 @@ async function createWorkerEngine( model: string, onProgress: ((progress: InitProgress) => void) | undefined, signal: AbortSignal, - source: ModelSource = {}, + source: WebLLMProviderOptions = {}, ): Promise { await checkWebGPUSupport(model); signal.throwIfAborted(); + prepareRuntimeAssets(source.prepareRuntimeAssets, 'webgpu'); const { WebWorkerMLCEngine, prebuiltAppConfig } = await import('@mlc-ai/web-llm'); signal.throwIfAborted(); const appConfig = resolveModelConfig(prebuiltAppConfig, model, source); diff --git a/packages/agent-core/src/llm/wllama-compat-assets.ts b/packages/agent-core/src/llm/wllama-compat-assets.ts index 5e9909bb..4ff3e292 100644 --- a/packages/agent-core/src/llm/wllama-compat-assets.ts +++ b/packages/agent-core/src/llm/wllama-compat-assets.ts @@ -1,3 +1,4 @@ +import { prepareRuntimeAssets, type RuntimeAssetPreparation } from './runtime-assets'; export interface CompatibilityAssets { wasm: string; worker: { code: string }; @@ -11,6 +12,8 @@ export async function getWllamaCompatibility( ]); return { wasm: wasm.default, worker: { code: worker.default } }; }, + prepare?: RuntimeAssetPreparation, + capability: 'cpu-compat' | 'wllama-compat' = 'wllama-compat', ): Promise { try { if (capabilities.Suspending && capabilities.validate(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 5, 3, 1, 4, 1]))) @@ -18,5 +21,6 @@ export async function getWllamaCompatibility( } catch { // Accepted constructor options do not prove that native modules compile. } + prepareRuntimeAssets(prepare, capability); return load(); } diff --git a/packages/agent-core/src/llm/wllama-cpu-runtime.ts b/packages/agent-core/src/llm/wllama-cpu-runtime.ts index 8284a397..7cb73e3e 100644 --- a/packages/agent-core/src/llm/wllama-cpu-runtime.ts +++ b/packages/agent-core/src/llm/wllama-cpu-runtime.ts @@ -1,14 +1,20 @@ +import type { RuntimeAssetPreparation } from './runtime-assets'; import { getWllamaCompatibility } from './wllama-compat-assets'; -export async function loadWllamaCPURuntime() { +export async function loadWllamaCPURuntime(prepare?: RuntimeAssetPreparation) { const sdk = await import('../../vendor/wllama-count/client/index.js'); const wasm = await import('../../vendor/wllama-count/native/default/wllama.wasm?url'); - const compatibility = await getWllamaCompatibility(WebAssembly, async () => { - const [wasm, worker] = await Promise.all([ - import('../../vendor/wllama-count/native/compat/wllama.wasm?url'), - import('../../vendor/wllama-count/native/compat/wllama.js?raw'), - ]); - return { wasm: wasm.default, worker: { code: worker.default } }; - }); + const compatibility = await getWllamaCompatibility( + WebAssembly, + async () => { + const [wasm, worker] = await Promise.all([ + import('../../vendor/wllama-count/native/compat/wllama.wasm?url'), + import('../../vendor/wllama-count/native/compat/wllama.js?raw'), + ]); + return { wasm: wasm.default, worker: { code: worker.default } }; + }, + prepare, + 'cpu-compat', + ); return { ...sdk, wasmUrl: wasm.default, compatibility }; } diff --git a/packages/agent-core/src/llm/wllama.ts b/packages/agent-core/src/llm/wllama.ts index 6a4f08ab..2f830e4c 100644 --- a/packages/agent-core/src/llm/wllama.ts +++ b/packages/agent-core/src/llm/wllama.ts @@ -1,3 +1,4 @@ +import { prepareRuntimeAssets, type RuntimeAssetPreparation } from './runtime-assets'; import { accumulateOpenAIStream, parseOpenAIResponse, @@ -25,6 +26,7 @@ export interface WllamaEngine { exit(): Promise; } export interface WllamaProviderOptions { + prepareRuntimeAssets?: RuntimeAssetPreparation; generation?: GenerationOptions; systemPrompt?: string; modelUrl?: string; @@ -78,9 +80,13 @@ export class WllamaProvider implements LocalLLMProvider { !this.options.modelFiles?.length && this.options.modelUrl ? resolveModelArtifactUrl(this.options.modelUrl) : undefined; + prepareRuntimeAssets( + this.options.prepareRuntimeAssets, + this.options.cpuOnly && !this.options.wasmUrl ? 'cpu' : 'wllama', + ); const cpuRuntime = this.options.cpuOnly && !this.options.wasmUrl - ? await (await import('./wllama-cpu-runtime')).loadWllamaCPURuntime() + ? await (await import('./wllama-cpu-runtime')).loadWllamaCPURuntime(this.options.prepareRuntimeAssets) : undefined; const wasmUrl = this.options.wasmUrl ?? @@ -104,7 +110,12 @@ export class WllamaProvider implements LocalLLMProvider { ); engine = runtime as unknown as WllamaEngine; this.loadingEngine = engine; - runtime.setCompat(cpuRuntime ? cpuRuntime.compatibility : await getWllamaCompatibility(), 'firefox_safari'); + runtime.setCompat( + cpuRuntime + ? cpuRuntime.compatibility + : await getWllamaCompatibility(undefined, undefined, this.options.prepareRuntimeAssets), + 'firefox_safari', + ); this.lifetime.signal.throwIfAborted(); const params = { signal: this.lifetime.signal, diff --git a/public/landing-prefetch.js b/public/landing-prefetch.js index e049c3ff..cf9e54e9 100644 --- a/public/landing-prefetch.js +++ b/public/landing-prefetch.js @@ -201,10 +201,41 @@ * serves the vendored tree cache-first -- a miss there re-requests the file * even when the browser still holds a copy. */ + // ready can resolve before clients.claim() controls this first-visit page. + // Fetching in that gap warms only the HTTP cache, then requested[url] would + // prevent the controlled retry that actually populates the worker cache. + function waitForControl(registration) { + var workers = navigator.serviceWorker; + if (workers.controller) return Promise.resolve(!leaving && watchRegistration(registration)); + return new Promise(function (resolve) { + var signal = aborter && aborter.signal; + var finished = false; + function done(allowed) { + if (finished) return; + finished = true; + workers.removeEventListener('controllerchange', changed); + if (signal) signal.removeEventListener('abort', cancelled); + resolve(allowed); + } + function cancelled() { + done(false); + } + function changed() { + if (leaving || upgrading || (signal && signal.aborted)) done(false); + else if (workers.controller) done(watchRegistration(registration)); + } + workers.addEventListener('controllerchange', changed); + if (signal) signal.addEventListener('abort', cancelled, { once: true }); + changed(); + }); + } + function warm(url) { if (!navigator.serviceWorker) return warmRequest(url); return navigator.serviceWorker.ready - .then(watchRegistration) + .then(function (registration) { + return watchRegistration(registration) ? waitForControl(registration) : false; + }) .then(function (allowed) { if (allowed) return warmRequest(url); }) diff --git a/public/sw-register.js b/public/sw-register.js index 7bb65989..15623678 100644 --- a/public/sw-register.js +++ b/public/sw-register.js @@ -128,37 +128,15 @@ }); } - // Bridge persisted opt-in from older releases, whose worker cannot read localStorage. - function prepareRememberedAssistant(registration) { - try { - if (global.localStorage?.getItem('document-assistant-enabled') !== 'true') return; - var worker = registration.installing || registration.waiting || registration.active; - if (!worker || typeof worker.postMessage !== 'function') return; - var channel = new MessageChannel(); - var done = function () { - global.clearTimeout(timer); - channel.port1.close(); - channel.port2.close(); - }; - var timer = global.setTimeout(done, 60000); - channel.port1.onmessage = done; - worker.postMessage({ type: 'PREPARE_ASSISTANT' }, [channel.port2]); - } catch { - // Preparation is optional; ordinary navigation remains available. - } - } - /** Promote a worker that is already waiting, installing, or yet to be found. */ function wire(registration) { // Browser automation or a restricted environment may return no handle. if (!registration) return; - prepareRememberedAssistant(registration); void maybePromote(registration); // `updatefound` for an update found during registration itself can fire // before this listener exists, so pick up an in-flight install too. if (registration.installing) watchInstalling(registration, registration.installing); registration.addEventListener('updatefound', function () { - prepareRememberedAssistant(registration); watchInstalling(registration, registration.installing); }); } diff --git a/public/sw.js b/public/sw.js index aaaee860..0707a9c1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -44,7 +44,7 @@ const ASSETS_TO_CACHE = [ './icons/document-maskable-512.png', ]; const APP_ASSETS_TO_CACHE = []; // BUILD_APP_ASSETS -const ASSISTANT_ASSETS_TO_CACHE = []; // BUILD_ASSISTANT_ASSETS +const ASSISTANT_CAPABILITY_ASSETS = {}; // BUILD_ASSISTANT_CAPABILITIES const ASSISTANT_PREPARED_KEY = '/__document-assistant-prepared__'; const EDITOR_BOOTSTRAP_HTML = [ '/web-apps/apps/documenteditor/main/index.html', @@ -294,34 +294,46 @@ self.addEventListener('install', (event) => { ); }); -function restorePreparedAssistant() { - if (assistantPreparation) return assistantPreparation; - return Promise.resolve(caches.match?.(ASSISTANT_PREPARED_KEY)).then((prepared) => { - if (prepared?.headers?.get('X-Document-Assistant-Prepared') === '1') return prepareAssistantAssets(); - }); +// Each capability gets an independent marker, so preparing PDF in a newer +// worker cannot hide CPU preparation in an older still-live worker's cache. +// This metadata cache is intentionally independent of build retirement. +const ASSISTANT_STATE_CACHE = 'document-assistant-capabilities'; +const assistantPreparations = new Map(); +const preparedKey = (capability) => `${ASSISTANT_PREPARED_KEY}/${capability}`; +const isPrepared = (response) => response?.headers?.get('X-Document-Assistant-Prepared') === '1'; +async function restorePreparedAssistant() { + const names = (await caches.keys()).filter( + (name) => name === ASSISTANT_STATE_CACHE || name.startsWith('document-editor-core-'), + ); + const capabilities = new Set(); + for (const name of names) { + const cache = await caches.open(name); + const legacy = isPrepared(await cache.match(ASSISTANT_PREPARED_KEY)); + for (const capability of Object.keys(ASSISTANT_CAPABILITY_ASSETS)) + if (legacy || isPrepared(await cache.match(preparedKey(capability)))) capabilities.add(capability); + } + await Promise.all([...capabilities].map(prepareAssistantAssets)); } -let assistantPreparation; -function prepareAssistantAssets() { - if (!assistantPreparation) { - assistantPreparation = caches +function prepareAssistantAssets(capability) { + if (!Object.prototype.hasOwnProperty.call(ASSISTANT_CAPABILITY_ASSETS, capability)) + return Promise.reject(new Error('Unknown assistant capability')); + if (!assistantPreparations.has(capability)) { + const preparation = caches .open(CORE_CACHE) .then(async (cache) => { const missing = []; - for (const asset of ASSISTANT_ASSETS_TO_CACHE) { + for (const asset of ASSISTANT_CAPABILITY_ASSETS[capability]) if (!(await cache.match(asset))) missing.push(asset); - } if (missing.length) await cache.addAll(missing); - await cache.put( - ASSISTANT_PREPARED_KEY, - new Response('1', { headers: { 'X-Document-Assistant-Prepared': '1' } }), - ); + const marker = () => new Response('1', { headers: { 'X-Document-Assistant-Prepared': '1' } }); + await cache.put(preparedKey(capability), marker()); + await (await caches.open(ASSISTANT_STATE_CACHE)).put(preparedKey(capability), marker()); }) - .finally(() => { - assistantPreparation = undefined; - }); + .finally(() => assistantPreparations.delete(capability)); + assistantPreparations.set(capability, preparation); } - return assistantPreparation; + return assistantPreparations.get(capability); } self.addEventListener('message', (event) => { @@ -330,7 +342,7 @@ self.addEventListener('message', (event) => { if (!port) return; // The page cannot supply URLs: only this build's generated allowlist is cached. event.waitUntil( - prepareAssistantAssets().then( + (event.data.capability === undefined ? Promise.resolve() : prepareAssistantAssets(event.data.capability)).then( () => port.postMessage({ type: 'ASSISTANT_PREPARED', ok: true }), () => port.postMessage({ type: 'ASSISTANT_PREPARED', ok: false }), ), @@ -411,8 +423,8 @@ const holdsCoreAssetsForOpenWindow = (cacheName) => // Activate event: Retire owned app caches from previous versions. self.addEventListener('activate', (event) => { event.waitUntil( - caches - .keys() + restorePreparedAssistant() + .then(() => caches.keys()) .then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => { diff --git a/test/e2e/agent-cloud-streaming.spec.ts b/test/e2e/agent-cloud-streaming.spec.ts new file mode 100644 index 00000000..8cab8dfc --- /dev/null +++ b/test/e2e/agent-cloud-streaming.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from './lib/l0'; + +for (const kind of ['openai-compatible', 'gemini', 'loopback'] as const) { + test(`${kind}: renders an incremental answer before its network response completes`, async ({ page }) => { + test.setTimeout(90_000); + await page.addInitScript((kind) => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem( + `agent_endpoint_key_${encodeURIComponent(kind === 'openai-compatible' ? 'https://stream.example/v1' : kind)}`, + 'fixture-key', + ); + localStorage.setItem( + 'agent-writing-endpoint', + JSON.stringify({ + version: 1, + kind, + baseUrl: + kind === 'loopback' ? 'http://localhost:11434' : kind === 'gemini' ? '' : 'https://stream.example/v1', + model: 'stream-test', + preference: 'remote-first', + localWritingConsent: false, + }), + ); + const nativeFetch = window.fetch.bind(window); + const encoder = new TextEncoder(); + window.fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if ( + !url.includes('localhost:11434/api/') && + !url.includes('stream.example/') && + !url.includes('generativelanguage.googleapis.com/') + ) + return nativeFetch(input, init); + if (url.endsWith('/tags')) return Response.json({ models: [{ name: 'stream-test' }] }); + const body = JSON.parse(String(init?.body ?? '{}')); + const streaming = body.stream === true || url.includes(':streamGenerateContent'); + if (!streaming) { + const content = JSON.stringify({ task: 'chat', language: 'en' }); + return Response.json( + kind === 'loopback' + ? { done: true, message: { content } } + : kind === 'gemini' + ? { candidates: [{ content: { parts: [{ text: content }] }, finishReason: 'STOP' }] } + : { choices: [{ message: { content }, finish_reason: 'stop' }] }, + ); + } + const state = window as unknown as { finishStream: () => void; streamFinished: boolean }; + state.streamFinished = false; + const event = (text: string, done: boolean) => + kind === 'loopback' + ? JSON.stringify({ done, ...(done ? { done_reason: 'stop' } : {}), message: { content: text } }) + '\n' + : `data: ${JSON.stringify( + kind === 'gemini' + ? { candidates: [{ content: { parts: [{ text }] }, ...(done ? { finishReason: 'STOP' } : {}) }] } + : { choices: [{ delta: { content: text }, finish_reason: done ? 'stop' : null }] }, + )}\n\n`; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(event('First sentence. ', false))); + state.finishStream = () => { + state.streamFinished = true; + controller.enqueue(encoder.encode(event('Finished.', true))); + if (kind === 'openai-compatible') controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }; + }, + }), + { headers: { 'Content-Type': kind === 'loopback' ? 'application/x-ndjson' : 'text/event-stream' } }, + ); + }; + }, kind); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + const input = page.locator('.cui-input'); + await input.fill('Hello'); + await page.locator('.cui-send').click(); + await expect(page.locator('.agent-panel-endpoint-status')).toContainText( + kind === 'loopback' ? 'Connected' : 'Configured', + ); + await expect(page.locator('.cui-send')).toHaveAccessibleName('Send'); + await page.locator('.cui-send').click(); + await expect(page.locator('.cui-streaming .cui-bubble')).toContainText('First sentence.'); + expect(await page.evaluate(() => (window as unknown as { streamFinished: boolean }).streamFinished)).toBe(false); + await expect(page.locator('.cui-send-stop')).toBeVisible(); + await page.evaluate(() => (window as unknown as { finishStream: () => void }).finishStream()); + await expect(page.locator('.cui-streaming')).toHaveCount(0); + await expect(page.locator('.cui-msg-agent')).toHaveCount(1); + await expect(page.locator('.cui-msg-agent')).toContainText('First sentence. Finished.'); + await expect(page.locator('.cui-send-stop')).toHaveCount(0); + }); +} diff --git a/test/e2e/agent-document-references.spec.ts b/test/e2e/agent-document-references.spec.ts index db8e5a28..afd06da1 100644 --- a/test/e2e/agent-document-references.spec.ts +++ b/test/e2e/agent-document-references.spec.ts @@ -79,25 +79,16 @@ test('Office and PDF references attach without replacing the current document, i } expect(await page.evaluate(() => window.__ooFrames.readyEditor() === (window as any).__referenceEditor)).toBe(true); expect(await documentState()).toEqual(before); + // Parsing the PDF activates its offline capability; no generic assistant + // enable or explicit test-only preparation should be needed for reuse. + await expect + .poll(() => page.evaluate(async () => Boolean(await caches.match('/__document-assistant-prepared__/pdf'))), { + timeout: 60_000, + }) + .toBe(true); await page.evaluate(async () => { - await navigator.serviceWorker.ready; - if (!navigator.serviceWorker.controller) - await new Promise((resolve) => - navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true }), - ); - await new Promise((resolve, reject) => { - const channel = new MessageChannel(); - const timer = setTimeout(() => reject(new Error('Preparation timed out')), 60000); - channel.port1.onmessage = (event) => { - clearTimeout(timer); - channel.port1.close(); - if (event.data.ok) resolve(); - else reject(new Error('Preparation failed')); - }; - navigator.serviceWorker.controller!.postMessage({ type: 'PREPARE_ASSISTANT' }, [channel.port2]); - }); const source = await (await fetch('/sw.js')).text(); - const optional: string[] = JSON.parse(/const ASSISTANT_ASSETS_TO_CACHE = (\[.*?\]);/.exec(source)![1]); + const optional: string[] = JSON.parse(/const ASSISTANT_CAPABILITY_ASSETS = (\{.*?\});/.exec(source)![1]).pdf; const worker = optional.find((path) => /pdf\.worker.*\.mjs$/.test(path)); if (!worker) throw new Error('PDF worker not prepared'); const response = await fetch(worker); diff --git a/test/e2e/agent-error-recovery.spec.ts b/test/e2e/agent-error-recovery.spec.ts index ffba4e43..a20fa665 100644 --- a/test/e2e/agent-error-recovery.spec.ts +++ b/test/e2e/agent-error-recovery.spec.ts @@ -1,5 +1,55 @@ import { expect, test } from './lib/l0'; +test('explains Gemini rate limiting and restores the request without exposing its error body', async ({ page }) => { + test.setTimeout(90_000); + await page.addInitScript(() => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem('agent_endpoint_key_gemini', 'test-key'); + localStorage.setItem( + 'agent-writing-endpoint', + JSON.stringify({ + version: 1, + kind: 'gemini', + baseUrl: '', + model: 'recovery-test', + preference: 'remote-first', + localWritingConsent: false, + }), + ); + }); + let requests = 0; + await page.route('https://generativelanguage.googleapis.com/**', async (route) => { + const headers = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,x-goog-api-key', + }; + if (route.request().method() === 'OPTIONS') await route.fulfill({ status: 204, headers }); + else { + requests++; + await route.fulfill({ status: 429, headers, body: 'private-provider-response secret-token' }); + } + }); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + const input = page.locator('.cui-input'); + const send = page.locator('.cui-send'); + await input.fill('Hello'); + await send.click(); + await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Configured'); + await expect(send).toHaveAccessibleName('Send'); + await send.click(); + const error = page.locator('.cui-msg-error'); + await expect(error).toContainText('The AI service is receiving too many requests'); + await expect(error).not.toContainText('private-provider-response'); + await expect(error).not.toContainText('secret-token'); + await expect(page.locator('.cui-send-stop')).toHaveCount(0); + await error.getByRole('button', { name: 'Restore request' }).click(); + await expect(input).toHaveValue('Hello'); + await expect(input).toBeFocused(); + expect(requests).toBe(1); +}); + test('explains a service failure without private details and restores the original request without resending', async ({ page, l0, diff --git a/test/e2e/agent-request-queue.spec.ts b/test/e2e/agent-request-queue.spec.ts index 497c3b71..e4c3643a 100644 --- a/test/e2e/agent-request-queue.spec.ts +++ b/test/e2e/agent-request-queue.spec.ts @@ -49,7 +49,7 @@ for (const ext of ['docx', 'pptx'] as const) { const text = String(body.messages.at(-1).content).split('User request:\n').at(-1)!; calls.push(text); await new Promise((resolve) => releases.set(text, resolve)); - expect(body.stream).toBe(false); + expect(body.stream).toBe(true); await route.fulfill({ json: { done: true, message: { role: 'assistant', content: `Answer to ${text}` } }, }); diff --git a/test/e2e/assistant-offline-assets.spec.ts b/test/e2e/assistant-offline-assets.spec.ts index 00812bb1..06711bbd 100644 --- a/test/e2e/assistant-offline-assets.spec.ts +++ b/test/e2e/assistant-offline-assets.spec.ts @@ -1,25 +1,30 @@ import { expect, test } from './lib/l0'; -test('the first visit defers inference assets and explicit preparation keeps them available offline', async ({ - page, - context, -}) => { - test.setTimeout(120_000); - await page.goto('/'); - const lists = await page.evaluate(async () => { +async function assetLists(page: import('@playwright/test').Page) { + return page.evaluate(async () => { await navigator.serviceWorker.ready; const source = await (await fetch('/sw.js', { cache: 'no-store' })).text(); - const parse = (name: string): string[] => JSON.parse(new RegExp(`const ${name} = (\\[.*?\\]);`).exec(source)![1]); - return { core: parse('APP_ASSETS_TO_CACHE'), assistant: parse('ASSISTANT_ASSETS_TO_CACHE') }; + return { + core: JSON.parse(/const APP_ASSETS_TO_CACHE = (\[.*?\]);/.exec(source)![1]) as string[], + capabilities: JSON.parse(/const ASSISTANT_CAPABILITY_ASSETS = (\{.*?\});/.exec(source)![1]) as Record< + string, + string[] + >, + }; }); +} + +test('prepares only the requested runtime and keeps it available offline', async ({ page, context }) => { + test.setTimeout(120_000); + await page.goto('/'); + const lists = await assetLists(page); expect(lists.core.length).toBeGreaterThan(0); - expect(lists.assistant.some((path) => path.endsWith('.wasm'))).toBe(true); + expect(lists.capabilities.cpu.some((path) => path.endsWith('.wasm'))).toBe(true); expect(lists.core.some((path) => path.endsWith('.wasm') || /webllm/.test(path))).toBe(false); - const cached = await page.evaluate(async (assets) => { - const names = (await caches.keys()).filter((name) => name.startsWith('document-editor-core-')); - const cache = await caches.open(names[0]); - return Promise.all(assets.map(async (path) => Boolean(await cache.match(path)))); - }, lists.assistant); + const cached = await page.evaluate( + async (assets) => Promise.all(assets.map(async (path) => Boolean(await caches.match(path)))), + Object.values(lists.capabilities).flat(), + ); expect(cached.every((present) => !present)).toBe(true); await page.reload(); await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true); @@ -28,23 +33,32 @@ test('the first visit defers inference assets and explicit preparation keeps the () => new Promise((resolve, reject) => { const channel = new MessageChannel(); - const timer = setTimeout(() => reject(new Error('Assistant cache preparation timed out')), 60_000); + const timer = setTimeout(() => reject(new Error('CPU preparation timed out')), 60_000); channel.port1.onmessage = (event) => { clearTimeout(timer); channel.port1.close(); resolve(event.data.type === 'ASSISTANT_PREPARED' && event.data.ok === true); }; - navigator.serviceWorker.controller!.postMessage({ type: 'PREPARE_ASSISTANT' }, [channel.port2]); + navigator.serviceWorker.controller!.postMessage({ type: 'PREPARE_ASSISTANT', capability: 'cpu' }, [ + channel.port2, + ]); }), ); + // Prepare the editor once online before testing its offline reuse. Home + // precaches the shell; editor-specific SDK chunks are cached on first use. await page.goto('/editor?new=docx'); await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor()), undefined, { timeout: 60_000 }); expect(await prepare()).toBe(true); + const unrelated = await page.evaluate( + async (assets) => Promise.all(assets.map(async (path) => Boolean(await caches.match(path)))), + [...lists.capabilities.webgpu, ...lists.capabilities.pdf, ...lists.capabilities['cpu-compat']], + ); + expect(unrelated.every((present) => !present)).toBe(true); await context.setOffline(true); expect(await prepare()).toBe(true); const available = await page.evaluate( async (assets) => Promise.all(assets.map(async (path) => (await fetch(path)).ok)), - lists.assistant, + lists.capabilities.cpu, ); expect(available.every(Boolean)).toBe(true); await page.goto('/editor?new=docx'); @@ -54,19 +68,78 @@ test('the first visit defers inference assets and explicit preparation keeps the await expect(page.locator('.agent-runtime-panel')).toBeVisible(); }); -test('a remembered opt-in on Home migrates prepared runtime before the worker is ready', async ({ page }) => { +test('a remembered opt-in on Home and a cloud-only panel download no local inference or PDF assets', async ({ + page, +}) => { test.setTimeout(90_000); - await page.addInitScript(() => localStorage.setItem('document-assistant-enabled', 'true')); + await page.addInitScript(() => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem( + 'agent-writing-endpoint', + JSON.stringify({ + version: 1, + kind: 'openai-compatible', + baseUrl: 'https://stream.example/v1', + model: 'gpt-4.1-mini', + preference: 'remote-first', + localWritingConsent: false, + }), + ); + }); await page.goto('/'); - const present = await page.evaluate(async () => { - await navigator.serviceWorker.ready; - const source = await (await fetch('/sw.js', { cache: 'no-store' })).text(); - const optional: string[] = JSON.parse(/const ASSISTANT_ASSETS_TO_CACHE = (\[.*?\]);/.exec(source)![1]); - return { - count: optional.length, - cached: await Promise.all(optional.map(async (path) => Boolean(await caches.match(path)))), - }; + const lists = await assetLists(page); + const optional = Object.values(lists.capabilities).flat(); + expect(optional.length).toBeGreaterThan(0); + const requests: string[] = []; + page.on('request', (request) => requests.push(new URL(request.url()).pathname)); + await page.goto('/editor?new=docx'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor()), undefined, { timeout: 60_000 }); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await expect(page.locator('.agent-runtime-panel')).toBeVisible(); + expect(requests.filter((path) => optional.includes(path))).toEqual([]); + const cached = await page.evaluate( + async (assets) => Promise.all(assets.map(async (path) => Boolean(await caches.match(path)))), + optional, + ); + expect(cached.every((present) => !present)).toBe(true); +}); + +test('a new deployment restores the union of prepared capabilities from older worker caches', async ({ + page, + context, +}) => { + test.setTimeout(120_000); + // Seed persisted outgoing-worker metadata before this browser registers the new build. + await page.goto('/manifest.json'); + await page.evaluate(async () => { + for (const [build, capability] of [ + ['old-a', 'cpu'], + ['old-b', 'pdf'], + ]) { + const cache = await caches.open(`document-editor-core-${build}`); + await cache.put( + `/__document-assistant-prepared__/${capability}`, + new Response('1', { headers: { 'X-Document-Assistant-Prepared': '1' } }), + ); + } }); - expect(present.count).toBeGreaterThan(0); - expect(present.cached.every(Boolean)).toBe(true); + await page.goto('/'); + const lists = await assetLists(page); + const checked = await page.evaluate( + async ({ cpu, pdf, webgpu }) => ({ + restored: await Promise.all([...cpu, ...pdf].map(async (path) => Boolean(await caches.match(path)))), + unrelated: await Promise.all(webgpu.map(async (path) => Boolean(await caches.match(path)))), + }), + lists.capabilities as { cpu: string[]; pdf: string[]; webgpu: string[] }, + ); + expect(checked.restored.every(Boolean)).toBe(true); + expect(checked.unrelated.every((present) => !present)).toBe(true); + await page.reload(); + await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true); + await context.setOffline(true); + const available = await page.evaluate( + async (assets) => Promise.all(assets.map(async (path) => (await fetch(path)).ok)), + [...lists.capabilities.cpu, ...lists.capabilities.pdf], + ); + expect(available.every(Boolean)).toBe(true); }); diff --git a/test/e2e/history-quota.spec.ts b/test/e2e/history-quota.spec.ts new file mode 100644 index 00000000..ad690f43 --- /dev/null +++ b/test/e2e/history-quota.spec.ts @@ -0,0 +1,103 @@ +import { readHistoryDocs } from './lib/history-db'; +import { buildDocx } from './lib/ooxml'; +import { expect, test } from './lib/l0'; + +test('a failed autosave quota retry preserves the previous recovery document', async ({ page }) => { + test.setTimeout(90_000); + const recovery = Array.from(buildDocx('The only recovery copy')); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + + await page.evaluate(async (bytes) => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open('document-history', 3); + request.onupgradeneeded = () => { + request.transaction!.abort(); + reject(new Error('The editor must initialize its history database')); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(['docs', 'blobs'], 'readwrite'); + const now = Date.now() - 60_000; + tx.objectStore('docs').put({ + id: 'only-recovery', + title: 'Recovery.docx', + titleLower: 'recovery.docx', + ext: 'docx', + origin: 'local', + size: bytes.length, + totalBytes: bytes.length, + createdAt: now, + updatedAt: now, + lastOpenedAt: now, + revCount: 1, + nextRev: 1, + }); + tx.objectStore('blobs').put({ + docId: 'only-recovery', + rev: 0, + savedAt: now, + bytes: new Uint8Array(bytes), + byteLength: bytes.length, + }); + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error); + }); + } finally { + db.close(); + } + + const target = window as unknown as { quotaWriteAttempts: number }; + target.quotaWriteAttempts = 0; + const put = IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put = function (...args) { + if (this.transaction.db.name === 'document-history' && this.name === 'blobs') { + target.quotaWriteAttempts++; + throw new DOMException('Injected full storage', 'QuotaExceededError'); + } + return put.apply(this, args as Parameters); + }; + }, recovery); + + const sdk = page.frameLocator('iframe[name="frameEditor"]').locator('#editor_sdk'); + await sdk.click({ position: { x: 300, y: 200 } }); + await page.keyboard.type('A newer document that cannot be snapshotted'); + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + // Stay below the 30-second periodic interval: the visibility path must cause + // this export, including its one quota retry. + await expect + .poll(() => page.evaluate(() => (window as unknown as { quotaWriteAttempts: number }).quotaWriteAttempts), { + timeout: 25_000, + }) + .toBe(2); + + expect(await readHistoryDocs(page)).toMatchObject([{ id: 'only-recovery', title: 'Recovery.docx' }]); + await page.goto('/history?locale=en'); + await expect(page.locator('.history-row-title')).toHaveText(['Recovery.docx']); + const stored = await page.evaluate( + () => + new Promise((resolve, reject) => { + const opening = indexedDB.open('document-history', 3); + opening.onerror = () => reject(opening.error); + opening.onsuccess = () => { + const db = opening.result; + const read = db.transaction('blobs', 'readonly').objectStore('blobs').get(['only-recovery', 0]); + read.onsuccess = () => { + db.close(); + resolve(Array.from(read.result.bytes)); + }; + read.onerror = () => { + db.close(); + reject(read.error); + }; + }; + }), + ); + expect(stored).toEqual(recovery); +}); diff --git a/test/e2e/landing-prefetch.spec.ts b/test/e2e/landing-prefetch.spec.ts index 1e74bbff..b0fc4791 100644 --- a/test/e2e/landing-prefetch.spec.ts +++ b/test/e2e/landing-prefetch.spec.ts @@ -68,7 +68,7 @@ test.describe('landing page warm-up', () => { return wanted.filter((u) => !held.has(u)); }); - await expect.poll(async () => (await missing()).length, { timeout: 120_000 }).toBe(0); + await expect.poll(missing, { timeout: 120_000, intervals: [1000] }).toEqual([]); const shape = await page.evaluate(() => { const lp = (window as any).__landingPrefetch; diff --git a/test/unit/agent-direct-request.test.ts b/test/unit/agent-direct-request.test.ts new file mode 100644 index 00000000..ce6c9c09 --- /dev/null +++ b/test/unit/agent-direct-request.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +import { prepareDirectDocumentRequest } from '../../lib/agent-plugin/ui/direct-document-request'; +import type { DocumentToolTarget } from '../../lib/agent-plugin/document-tool-action'; +import { t } from '@ranuts/shared/i18n'; + +const execute = vi.hoisted(() => vi.fn()); +vi.mock('../../lib/agent-plugin/tools', () => ({ agentTools: { set_bold: { execute }, sum_range: { execute } } })); +vi.mock('../../lib/agent-plugin/editor-bridge', () => ({ getEditorApi: () => null })); +beforeEach(() => execute.mockReset()); +const target = (): DocumentToolTarget => ({ + context: { kind: 'word' }, + label: 'selection', + selectedText: 'selected', + isCurrent: vi.fn(() => true), +}); + +it('validates and promotes a document write target before executing a deterministic command', async () => { + const captured = target(); + const promoted = target(); + captured.prepareWrite = vi.fn(() => promoted); + execute.mockResolvedValue({ verified: true }); + const request = prepareDirectDocumentRequest({ kind: 'bold', enabled: true }, 'bold selection', captured, ''); + expect(captured.prepareWrite).toHaveBeenCalledOnce(); + expect(promoted.isCurrent).toHaveBeenCalledWith(true); + expect(execute).not.toHaveBeenCalled(); + const signal = new AbortController().signal; + const result = await request.execute(signal); + expect(execute).toHaveBeenCalledWith({ enabled: true }, signal); + expect(result.status).toBe('success'); + expect(result.turn).toEqual({ role: 'tool', text: t('agentPlanVerified') }); + expect(result.messages[0]).toMatchObject({ + role: 'assistant', + content: [{ type: 'tool_use', name: 'set_bold', input: { enabled: true } }], + }); +}); + +it('does not promote read-only scope and returns the sum as history evidence', async () => { + const captured = target(); + captured.prepareWrite = vi.fn(); + execute.mockResolvedValue({ verified: true, sum: 42 }); + const request = prepareDirectDocumentRequest({ kind: 'sum_range', range: 'A1:A2' }, 'sum A1:A2', captured, ''); + expect(captured.prepareWrite).not.toHaveBeenCalled(); + const result = await request.execute(new AbortController().signal); + expect(result.turn.text).toBe('A1:A2: 42'); + expect(result.messages.at(-1)).toEqual({ role: 'assistant', content: 'A1:A2: 42', hostGuidance: 'tool' }); +}); + +it('rejects unverified results and cancellation instead of reporting success', async () => { + const request = prepareDirectDocumentRequest({ kind: 'bold', enabled: true }, 'bold selection', target(), ''); + execute.mockResolvedValue({ verified: false }); + await expect(request.execute(new AbortController().signal)).rejects.toThrow(t('agentPlanUnverified')); + const abort = new AbortController(); + execute.mockImplementation(async () => { + abort.abort(); + return { verified: true }; + }); + await expect(request.execute(abort.signal)).rejects.toMatchObject({ name: 'AbortError' }); +}); + +it('refuses stale scope and missing answer before any editor tool starts', () => { + const stale = target(); + stale.isCurrent = () => false; + expect(() => prepareDirectDocumentRequest({ kind: 'bold', enabled: true }, 'bold', stale, '')).toThrow( + t('agentPlanExpired'), + ); + expect(() => + prepareDirectDocumentRequest({ kind: 'write_reply', requireSelection: false }, 'write', target(), ''), + ).toThrow('agentNoCompletedAnswer'); + expect(execute).not.toHaveBeenCalled(); +}); diff --git a/test/unit/agent-entry.test.ts b/test/unit/agent-entry.test.ts index 8b3b060c..d0a36951 100644 --- a/test/unit/agent-entry.test.ts +++ b/test/unit/agent-entry.test.ts @@ -31,13 +31,13 @@ it('enables explicitly, remembers the choice and never prepares a model on its o await vi.waitFor(() => expect(runtime.create).toHaveBeenCalledOnce()); expect(localStorage.getItem('document-assistant-enabled')).toBe('true'); expect(runtime.enable).toHaveBeenCalledWith(true); - expect(prepare).toHaveBeenCalledOnce(); + expect(prepare).not.toHaveBeenCalled(); }); -it('a remembered choice prepares offline assets but keeps runtime execution lazy until opening', async () => { +it('a remembered choice keeps optional runtime resources lazy until capability activation', async () => { localStorage.setItem('document-assistant-enabled', 'true'); const entry = createAgentEntry(); expect(runtime.create).not.toHaveBeenCalled(); - expect(prepare).toHaveBeenCalledOnce(); + expect(prepare).not.toHaveBeenCalled(); entry.toggle(); await vi.waitFor(() => expect(runtime.create).toHaveBeenCalledOnce()); }); diff --git a/test/unit/agent-llm-gemini.test.ts b/test/unit/agent-llm-gemini.test.ts index d4a411ac..bca09bd4 100644 --- a/test/unit/agent-llm-gemini.test.ts +++ b/test/unit/agent-llm-gemini.test.ts @@ -126,10 +126,11 @@ describe('GeminiProvider', () => { it('cancels a pending cloud request when Stop aborts its signal', async () => { const abort = new AbortController(); + let transport: AbortSignal | null | undefined; const provider = new GeminiProvider({ apiKey: 'test', fetchImpl: async (_url, init) => { - expect(init.signal).toBe(abort.signal); + transport = init.signal; return new Promise((_resolve, reject) => { init.signal!.addEventListener('abort', () => reject(init.signal!.reason), { once: true }); }); @@ -138,6 +139,8 @@ it('cancels a pending cloud request when Stop aborts its signal', async () => { const pending = (provider as import('@ranuts/agent-core/llm/types').LLMProvider).chat([], [], abort.signal); abort.abort(); await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + expect(transport?.aborted).toBe(true); + expect(transport?.reason).toBe(abort.signal.reason); }); it('does not send an already cancelled cloud request', async () => { diff --git a/test/unit/agent-llm-ollama.test.ts b/test/unit/agent-llm-ollama.test.ts index 502ec180..8bead644 100644 --- a/test/unit/agent-llm-ollama.test.ts +++ b/test/unit/agent-llm-ollama.test.ts @@ -42,6 +42,18 @@ describe('OllamaProvider', () => { expect((init.headers as Record).Authorization).toBe('Bearer proxy-token'); }); + it('sends plain chat without tool-only fields to a strict compatible endpoint', async () => { + const provider = new OllamaProvider({ + fetchImpl: async (_url, init) => { + const body = JSON.parse(init.body as string); + if ('tools' in body || 'tool_choice' in body) return new Response('Unexpected tool fields', { status: 400 }); + return okResponse({ choices: [{ message: { content: 'Plain reply' }, finish_reason: 'stop' }] }); + }, + }); + + expect((await provider.chat([{ role: 'user', content: 'Hello' }], [])).text).toBe('Plain reply'); + }); + it('respects a custom base URL', async () => { const fetchImpl = vi.fn(async (_url: string, _init: RequestInit) => okResponse({ choices: [{ message: { content: 'x' }, finish_reason: 'stop' }] }), diff --git a/test/unit/agent-llm-openai.test.ts b/test/unit/agent-llm-openai.test.ts index 2b5ca13d..ea77f82a 100644 --- a/test/unit/agent-llm-openai.test.ts +++ b/test/unit/agent-llm-openai.test.ts @@ -54,6 +54,19 @@ describe('OpenAIProvider', () => { expect(result.text).toBe('hi there'); }); + it('sends plain chat without tool-only fields to a strict compatible endpoint', async () => { + const provider = new OpenAIProvider({ + apiKey: 'test', + fetchImpl: async (_url, init) => { + const body = JSON.parse(init.body as string); + if ('tools' in body || 'tool_choice' in body) return new Response('Unexpected tool fields', { status: 400 }); + return okResponse({ choices: [{ message: { content: 'Plain reply' }, finish_reason: 'stop' }] }); + }, + }); + + expect((await provider.chat([{ role: 'user', content: 'Hello' }], [])).text).toBe('Plain reply'); + }); + it('throws with status detail on a non-ok response', async () => { const fetchImpl = vi.fn( async () => ({ ok: false, status: 401, text: async () => 'Unauthorized' }) as unknown as Response, @@ -70,10 +83,11 @@ describe('OpenAIProvider', () => { it('cancels a pending cloud request when Stop aborts its signal', async () => { const abort = new AbortController(); + let transport: AbortSignal | null | undefined; const provider = new OpenAIProvider({ apiKey: 'test', fetchImpl: async (_url, init) => { - expect(init.signal).toBe(abort.signal); + transport = init.signal; return new Promise((_resolve, reject) => { init.signal!.addEventListener('abort', () => reject(init.signal!.reason), { once: true }); }); @@ -82,6 +96,8 @@ it('cancels a pending cloud request when Stop aborts its signal', async () => { const pending = (provider as import('@ranuts/agent-core/llm/types').LLMProvider).chat([], [], abort.signal); abort.abort(); await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + expect(transport?.aborted).toBe(true); + expect(transport?.reason).toBe(abort.signal.reason); }); it('does not send an already cancelled cloud request', async () => { diff --git a/test/unit/agent-panel-loading.test.ts b/test/unit/agent-panel-loading.test.ts index 0c1135de..13f37b82 100644 --- a/test/unit/agent-panel-loading.test.ts +++ b/test/unit/agent-panel-loading.test.ts @@ -3,6 +3,7 @@ import { afterEach, expect, it, vi } from 'vitest'; import { createAgentPanel, setAgentPanelOpen } from '../../lib/agent-plugin/ui/panel'; import { t } from '@ranuts/shared/i18n'; import { getEndpointKey } from '@ranuts/agent-core/llm/keys'; +import { WllamaProvider } from '@ranuts/agent-core/llm/wllama'; import { AgentChatController } from '../../lib/agent-plugin/ui/controller'; import { createHistoryStorage } from '../../lib/agent-plugin/ui/storage'; import { ChatView } from '@ranuts/chat-ui'; @@ -30,6 +31,7 @@ const state = vi.hoisted(() => ({ ready: false, nativeEndpoint: false, cached: false, + cacheChecks: vi.fn(), cachedModel: 'Qwen3-4B-q4f16_1-MLC', cpuCached: false, deleteCache: vi.fn().mockResolvedValue(undefined), @@ -99,7 +101,10 @@ vi.mock('@ranuts/agent-core/llm/openai', async (original) => { vi.mock('@ranuts/agent-core/llm/webllm', async (original) => ({ ...(await original()), isWebGPUAvailable: () => true, - isModelCached: async (id: string) => state.cached && id === state.cachedModel, + isModelCached: async (id: string) => { + state.cacheChecks(id); + return state.cached && id === state.cachedModel; + }, WebLLMProvider: class { constructor(options: { onProgress: (value: { text: string }) => void }) { state.progress.push(options.onProgress); @@ -223,6 +228,7 @@ vi.mock('@ranuts/agent-core/llm/local', async (original) => ({ () => { state.ready = false; state.cached = false; + state.cacheChecks.mockClear(); state.editorKind = 'word'; state.deleteCache.mockReset().mockResolvedValue(undefined); reject(signal.reason); @@ -396,6 +402,7 @@ afterEach(() => { state.conversationRepository = undefined; state.nativeEndpoint = false; state.cached = false; + state.cacheChecks.mockClear(); state.cachedModel = 'Qwen3-4B-q4f16_1-MLC'; state.cpuCached = false; state.editorKind = 'word'; @@ -503,6 +510,70 @@ it('opens chat for a configured service without connecting on its own', () => { expect(panel.dataset.view).toBe('chat'); expect(panel.querySelector('.agent-panel-endpoint-connect')?.textContent).toBe(t('agentEndpointConnect')); }); +it('keeps CPU-only setup free of WebGPU cache inspection until inventory is opened', async () => { + localStorage.setItem('agent-panel-provider', 'wllama'); + const panel = createAgentPanel({ externalEntry: true }); + await vi.waitFor(() => expect(panel.dataset.view).toBe('settings')); + expect(state.cacheChecks).not.toHaveBeenCalled(); + const inventory = panel.querySelector('.agent-model-cache')!; + inventory.open = true; + await vi.waitFor(() => expect(state.cacheChecks).toHaveBeenCalled()); + expect(state.preload).not.toHaveBeenCalled(); +}); +it('keeps a successfully prepared CPU model usable without inspecting WebGPU caches', async () => { + localStorage.setItem('agent-panel-provider', 'wllama'); + localStorage.setItem('agent-panel-gguf-url', 'https://models.example/cpu-model.gguf'); + let ready = false; + const preload = vi.spyOn(WllamaProvider.prototype, 'preload').mockImplementation(async () => { + ready = true; + }); + const isReady = vi.spyOn(WllamaProvider.prototype, 'isReady').mockImplementation(() => ready); + try { + const panel = createAgentPanel(); + const inventory = panel.querySelector('.agent-model-cache')!; + expect(inventory.open).toBe(false); + expect(state.cacheChecks).not.toHaveBeenCalled(); + panel.querySelector('.agent-panel-gguf-load')!.click(); + await vi.waitFor(() => expect(panel.querySelector('.agent-panel-note')?.textContent).toBe(t('agentModelLoaded'))); + expect(preload).toHaveBeenCalledOnce(); + const input = panel.querySelector('.cui-input')!; + input.value = 'Hello'; + input.dispatchEvent(new Event('input', { bubbles: true })); + expect(panel.querySelector('.cui-send')?.hasAttribute('disabled')).toBe(false); + expect(state.cacheChecks).not.toHaveBeenCalled(); + inventory.open = true; + await vi.waitFor(() => expect(state.cacheChecks).toHaveBeenCalled()); + } finally { + preload.mockRestore(); + isReady.mockRestore(); + } +}); +it('ignores a native inventory toggle that settles after its panel is removed', async () => { + localStorage.setItem('agent-panel-provider', 'wllama'); + const panel = createAgentPanel(); + const inventory = panel.querySelector('.agent-model-cache')!; + const toggled = new Promise((resolve) => inventory.addEventListener('toggle', () => resolve(), { once: true })); + inventory.open = true; + panel.remove(); + await toggled; + expect(state.cacheChecks).not.toHaveBeenCalled(); +}); +it('does not inspect browser runtime caches for service setup until the local source is selected', async () => { + localStorage.setItem( + 'agent-writing-endpoint', + JSON.stringify({ version: 1, kind: 'openai-compatible', baseUrl: 'https://missing-key.example', model: 'model' }), + ); + const panel = createAgentPanel({ externalEntry: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(panel.dataset.view).toBe('settings'); + expect(state.cacheChecks).not.toHaveBeenCalled(); + expect(state.preload).not.toHaveBeenCalled(); + const source = panel.querySelector('.agent-source-picker')!; + source.value = 'local'; + source.dispatchEvent(new Event('change')); + await vi.waitFor(() => expect(state.cacheChecks).toHaveBeenCalled()); + expect(state.preload).not.toHaveBeenCalled(); +}); it('keeps setup reachable for an incomplete cloud service', async () => { localStorage.setItem( 'agent-writing-endpoint', @@ -790,6 +861,7 @@ it('explains a blocked writing route without requiring an unrelated model downlo // routed to a browser-local engine that never passed quality acceptance. state.ready = false; state.cached = false; + state.cacheChecks.mockClear(); state.editorKind = 'word'; state.deleteCache.mockReset().mockResolvedValue(undefined); localStorage.setItem('agent-panel-provider', 'wllama'); @@ -1494,6 +1566,7 @@ it('clears loaded runtime feedback when a failed operation retires its provider' state.toolPlan.mockImplementation(async () => { state.ready = false; state.cached = false; + state.cacheChecks.mockClear(); state.editorKind = 'word'; state.deleteCache.mockReset().mockResolvedValue(undefined); throw new WebAssembly.RuntimeError('Length out of range of buffer'); @@ -1926,6 +1999,7 @@ it('clears loaded feedback when the engine becomes unavailable after chat has fi expect(panel.querySelector('.agent-model-status')?.textContent).toContain(t('agentUseDevice')); state.ready = false; state.cached = false; + state.cacheChecks.mockClear(); state.editorKind = 'word'; state.deleteCache.mockReset().mockResolvedValue(undefined); state.readonly = false; diff --git a/test/unit/agent-panel-owners.test.ts b/test/unit/agent-panel-owners.test.ts new file mode 100644 index 00000000..40b0e517 --- /dev/null +++ b/test/unit/agent-panel-owners.test.ts @@ -0,0 +1,191 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { t } from '@ranuts/shared/i18n'; +import { createModelConfigurationForm } from '../../lib/agent-plugin/ui/model-configuration-form'; +import { createRequestRouting } from '../../lib/agent-plugin/ui/request-routing'; +import { createPanelWorkspace } from '../../lib/agent-plugin/ui/panel-workspace'; +import { getEndpointKey } from '@ranuts/agent-core/llm/keys'; +import { + ENDPOINT_SETTINGS_KEY, + readEndpointSettings, + type EndpointSettings, +} from '../../lib/agent-plugin/ui/endpoint-settings'; + +// These owner tests exercise their observable contract independently of panel assembly. +afterEach(() => { + document.body.replaceChildren(); + localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe('model configuration form', () => { + it('persists public source edits, resets their model and never starts a load while mounting', () => { + localStorage.setItem('agent-local-model-url', '/models/old/'); + const load = vi.fn(); + const sourceChanged = vi.fn(); + const form = createModelConfigurationForm({ + endpointSettings: readEndpointSettings(null), + load, + sourceChanged, + stop: vi.fn(), + }); + expect(load).not.toHaveBeenCalled(); + expect(form.localModelUrl.value).toBe('/models/old/'); + form.localModelUrl.value = ' /models/new/ '; + form.localModelUrl.dispatchEvent(new Event('change')); + expect(localStorage.getItem('agent-local-model-url')).toBe('/models/new/'); + expect(sourceChanged).toHaveBeenCalledOnce(); + form.loadBtn.click(); + expect(load).toHaveBeenCalledOnce(); + }); + + it('owns endpoint preferences and keeps secrets in their destination slot', () => { + const form = createModelConfigurationForm({ + endpointSettings: readEndpointSettings(null), + load: vi.fn(), + sourceChanged: vi.fn(), + stop: vi.fn(), + }); + form.endpointKind.value = 'openai-compatible'; + form.endpointBaseUrl.value = ' https://service.example/v1 '; + form.endpointModel.value = ' model '; + form.endpointKey.value = ' secret '; + form.rememberEndpoint(); + expect(form.endpointSettings).toMatchObject({ + kind: 'openai-compatible', + baseUrl: 'https://service.example/v1', + model: 'model', + }); + expect(localStorage.getItem(ENDPOINT_SETTINGS_KEY)).not.toContain('secret'); + expect(getEndpointKey('https://service.example/v1')).toBe('secret'); + form.endpointKey.value = ''; + form.loadStoredKeyIntoForm(); + expect(form.endpointKey.value).toBe('secret'); + form.endpointBaseUrl.value = 'https://other.example/v1'; + form.loadStoredKeyIntoForm(); + expect(form.endpointKey.value).toBe(''); + form.endpointBaseUrl.value = 'https://service.example/v1'; + form.rememberEndpoint(); + expect(getEndpointKey('https://service.example/v1')).toBeUndefined(); + }); + + it('reports chosen files and routes each keyboard stop action back to its matching load control', () => { + const stop = vi.fn(); + const form = createModelConfigurationForm({ + endpointSettings: readEndpointSettings(null), + load: vi.fn(), + sourceChanged: vi.fn(), + stop, + }); + Object.defineProperty(form.ggufFiles, 'files', { + value: [new File(['a'], 'first.gguf'), new File(['b'], 'second.gguf')], + }); + form.syncLabels(); + expect(form.ggufNames.textContent).toBe('first.gguf, second.gguf'); + expect(form.ggufFiles.getAttribute('aria-label')).toBe(t('agentChooseModelFiles')); + form.ggufStop.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', cancelable: true })); + expect(stop).toHaveBeenCalledWith(form.ggufLoad); + form.loadStop.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', cancelable: true })); + expect(stop).toHaveBeenCalledWith(form.loadBtn); + }); +}); + +describe('request routing', () => { + it('keeps loopback available offline but drops cloud connections before executing a request', () => { + const state = { + settings: { + ...readEndpointSettings(null), + kind: 'openai-compatible' as const, + baseUrl: 'https://api.example.com/v1', + model: 'test', + preference: 'remote-first', + } as EndpointSettings, + endpointReady: true, + localReady: true, + offline: true, + local: { backend: 'webllm' as const, model: 'local' }, + localWritingConsent: false, + }; + const routing = createRequestRouting(() => state); + expect(routing.useEndpointForChat()).toBe(false); + expect(routing.writingRoute().kind).toBe('blocked'); + state.settings = { ...state.settings, kind: 'loopback' }; + state.settings.baseUrl = 'http://localhost:11434'; + expect(routing.useEndpointForChat()).toBe(true); + expect(routing.writingRoute()).toMatchObject({ kind: 'endpoint', dataPath: 'device' }); + }); + + it('requires a browser model only for routes that execute locally', () => { + const state = { + settings: { + ...readEndpointSettings(null), + kind: 'openai-compatible' as const, + baseUrl: 'https://api.example.com/v1', + model: 'test', + }, + endpointReady: true, + localReady: false, + offline: false, + local: { backend: 'webllm' as const, model: 'local' }, + localWritingConsent: false, + }; + const routing = createRequestRouting(() => state); + expect(routing.needsLocalModel('chat', false)).toBe(false); + expect(routing.needsLocalModel('tools', true)).toBe(false); + state.endpointReady = false; + expect(routing.needsLocalModel('tools', true)).toBe(false); + expect(routing.needsLocalModel('tools', false)).toBe(true); + expect(routing.needsLocalModel('chat', false)).toBe(true); + expect(routing.needsLocalModel('rewrite', false)).toBe(false); + state.localWritingConsent = true; + expect(routing.needsLocalModel('rewrite', false)).toBe(true); + }); +}); + +it('coordinates workspace visibility, capture, focus, runtime placement and Escape return', () => { + const panel = document.createElement('div'); + const chat = { el: document.createElement('div'), focus: vi.fn() }; + const row = document.createElement('div'); + const settings = document.createElement('div'); + const modelRow = document.createElement('div'); + const modelActions = document.createElement('div'); + modelRow.append(modelActions); + settings.append(modelRow); + panel.append(row, settings, chat.el); + document.body.append(panel); + const focus = vi.fn(); + const trigger = document.createElement('button'); + panel.append(trigger); + const navigation = { capture: vi.fn(), restore: vi.fn(() => false) }; + const changed = vi.fn(); + const beforeChange = vi.fn(); + const workspace = createPanelWorkspace({ + panel, + chat, + navigation, + conversation: () => 'session', + isOpen: () => true, + runtime: { row, settings, modelRow, modelActions }, + views: { + settings: { el: settings, trigger, focus }, + history: { el: document.createElement('div'), trigger: document.createElement('button'), focus: vi.fn() }, + sources: { el: document.createElement('div'), trigger: document.createElement('button'), focus: vi.fn() }, + artifacts: { el: document.createElement('div'), trigger: document.createElement('button'), focus: vi.fn() }, + }, + beforeChange, + changed, + }); + workspace.show('chat'); + workspace.show('settings'); + expect(navigation.capture).toHaveBeenCalledWith('session'); + expect(chat.el.hidden).toBe(true); + expect(settings.classList.contains('agent-panel-settings-hidden')).toBe(false); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + expect(row.parentElement).toBe(modelRow); + expect(focus).toHaveBeenCalledOnce(); + settings.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(chat.el.hidden).toBe(false); + expect(row.parentElement).toBe(panel); + expect(document.activeElement).toBe(trigger); + expect(beforeChange).toHaveBeenCalledWith('chat'); + expect(changed).toHaveBeenCalledWith('chat'); +}); diff --git a/test/unit/agent-pdf-reference-reader.test.ts b/test/unit/agent-pdf-reference-reader.test.ts index e72726d2..76bc02b9 100644 --- a/test/unit/agent-pdf-reference-reader.test.ts +++ b/test/unit/agent-pdf-reference-reader.test.ts @@ -1,4 +1,6 @@ import { beforeEach, expect, it, vi } from 'vitest'; +const prepare = vi.hoisted(() => vi.fn().mockResolvedValue(true)); +vi.mock('../../lib/assistant-offline', () => ({ prepareAssistantOffline: prepare })); const state = vi.hoisted(() => ({ pages: 1, pending: undefined as Promise | undefined, @@ -49,6 +51,7 @@ beforeEach(() => { }); it('extracts streamed text and releases parser and worker', async () => { expect(await readPdfReference(new Uint8Array([1]))).toBe('PDF text'); + expect(prepare).toHaveBeenCalledWith('pdf'); expect(state.destroy).toHaveBeenCalled(); expect(state.terminate).toHaveBeenCalled(); expect(state.cleanup).toHaveBeenCalled(); diff --git a/test/unit/agent-provider-bounds.test.ts b/test/unit/agent-provider-bounds.test.ts new file mode 100644 index 00000000..30a826fb --- /dev/null +++ b/test/unit/agent-provider-bounds.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fetchProviderJson, type ProviderHttpOptions } from '../../packages/agent-core/src/llm/provider-http'; +import { OpenAIProvider } from '../../packages/agent-core/src/llm/openai'; +import { GeminiProvider } from '../../packages/agent-core/src/llm/gemini'; +import { OllamaProvider } from '../../packages/agent-core/src/llm/ollama'; + +const encode = (text: string) => new TextEncoder().encode(text); + +describe('provider response byte budget', () => { + it.each([undefined, '1'])('cancels oversized bodies even when Content-Length is %s', async (length) => { + let cancelled = false; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encode('{"text":"这是超过预算的内容"}')); + }, + cancel() { + cancelled = true; + }, + }), + { headers: length ? { 'Content-Length': length } : {} }, + ); + await expect( + fetchProviderJson( + 'test', + 'https://example.test', + {}, + { + maxResponseBytes: 16, + fetchImpl: async () => response, + }, + ), + ).rejects.toThrow('AI response exceeds size limit'); + expect(cancelled).toBe(true); + expect(response.body!.locked).toBe(false); + }); + + it('rejects an oversized declared body before consuming bytes', async () => { + const cancel = vi.fn(); + const response = new Response(new ReadableStream({ cancel }), { headers: { 'Content-Length': '8388609' } }); + await expect( + fetchProviderJson( + 'test', + 'https://example.test', + {}, + { + fetchImpl: async () => response, + }, + ), + ).rejects.toThrow('AI response exceeds size limit'); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('counts encoded bytes rather than decoded characters and accepts the exact boundary', async () => { + expect( + await fetchProviderJson( + 'test', + 'https://example.test', + {}, + { + maxResponseBytes: 5, + fetchImpl: async () => new Response('"中"'), + }, + ), + ).toBe('中'); + await expect( + fetchProviderJson( + 'test', + 'https://example.test', + {}, + { + maxResponseBytes: 4, + fetchImpl: async () => new Response('"中"'), + }, + ), + ).rejects.toThrow('AI response exceeds size limit'); + }); + + it.each([0, -1, Infinity, NaN, 1.5])('rejects an invalid byte budget (%s) before transport', async (limit) => { + const fetchImpl = vi.fn(); + await expect( + fetchProviderJson( + 'test', + 'https://example.test', + {}, + { + maxResponseBytes: limit, + fetchImpl, + }, + ), + ).rejects.toBeInstanceOf(RangeError); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it.each(['openai', 'gemini', 'ollama'] as const)('honors the %s constructor byte budget', async (kind) => { + const options: ProviderHttpOptions = { maxResponseBytes: 2, fetchImpl: async () => new Response('{} ') }; + const provider = + kind === 'openai' + ? new OpenAIProvider({ apiKey: 'test', ...options }) + : kind === 'gemini' + ? new GeminiProvider({ apiKey: 'test', ...options }) + : new OllamaProvider(options); + await expect(provider.chat([], [])).rejects.toThrow('AI response exceeds size limit'); + }); +}); diff --git a/test/unit/agent-provider-http.test.ts b/test/unit/agent-provider-http.test.ts new file mode 100644 index 00000000..13347398 --- /dev/null +++ b/test/unit/agent-provider-http.test.ts @@ -0,0 +1,254 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { OpenAIProvider } from '../../packages/agent-core/src/llm/openai'; +import { GeminiProvider } from '../../packages/agent-core/src/llm/gemini'; +import { OllamaProvider } from '../../packages/agent-core/src/llm/ollama'; +import { displayError } from '../../lib/agent-plugin/ui/presentation'; +import { t } from '@ranuts/shared/i18n'; +import { AgentChatController, type ChatTurn } from '../../lib/agent-plugin/ui/controller'; + +type Options = { + fetchImpl: (url: string, init: RequestInit) => Promise; + timeoutMs?: number; +}; +const providers = [ + { + name: 'OpenAI', + create: (options: Options) => new OpenAIProvider({ apiKey: 'test', ...options }), + body: { choices: [{ message: { content: 'Completed' }, finish_reason: 'stop' }] }, + }, + { + name: 'Gemini', + create: (options: Options) => new GeminiProvider({ apiKey: 'test', ...options }), + body: { candidates: [{ content: { parts: [{ text: 'Completed' }] }, finishReason: 'STOP' }] }, + }, + { + name: 'Ollama', + create: (options: Options) => new OllamaProvider(options), + body: { choices: [{ message: { content: 'Completed' }, finish_reason: 'stop' }] }, + }, +]; + +/** Observe pending requests without waiting for Vitest's own timeout. */ +function observe(promise: Promise) { + let outcome: { state: 'pending' } | { state: 'completed'; value: T } | { state: 'failed'; error: unknown } = { + state: 'pending', + }; + void promise.then( + (value) => { + outcome = { state: 'completed', value }; + }, + (error) => { + outcome = { state: 'failed', error }; + }, + ); + return () => outcome; +} + +describe.each(providers)('$name request lifetime', ({ create, body }) => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('times out an unresponsive fetch by default and aborts the transport', async () => { + let transport: AbortSignal | null | undefined; + const provider = create({ + fetchImpl: (_url, init) => { + transport = init.signal; + return new Promise(() => {}); + }, + }); + const outcome = observe(provider.chat([], [])); + + await vi.advanceTimersByTimeAsync(300_000); + + expect(outcome()).toMatchObject({ state: 'failed', error: { name: 'TimeoutError' } }); + expect(transport?.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it('keeps one configurable deadline across headers and a stalled response body', async () => { + let respond!: (response: Response) => void; + let transport: AbortSignal | null | undefined; + const provider = create({ + timeoutMs: 50, + fetchImpl: (_url, init) => { + transport = init.signal; + return new Promise((resolve) => { + respond = resolve; + }); + }, + }); + const outcome = observe(provider.chat([], [])); + await vi.advanceTimersByTimeAsync(40); + respond({ ok: true, json: () => new Promise(() => {}) } as unknown as Response); + await vi.advanceTimersByTimeAsync(9); + expect(outcome()).toEqual({ state: 'pending' }); + + await vi.advanceTimersByTimeAsync(1); + + expect(outcome()).toMatchObject({ state: 'failed', error: { name: 'TimeoutError' } }); + expect(transport?.aborted).toBe(true); + }); + + it('preserves the caller cancellation reason during a stalled body read', async () => { + const caller = new AbortController(); + const reason = new DOMException('Stopped by the reader', 'AbortError'); + const provider = create({ + fetchImpl: async () => ({ ok: true, json: () => new Promise(() => {}) }) as unknown as Response, + }); + const pending = provider.chat([], [], caller.signal); + const outcome = observe(pending); + await vi.advanceTimersByTimeAsync(0); + + caller.abort(reason); + await vi.advanceTimersByTimeAsync(0); + + expect(outcome()).toEqual({ state: 'failed', error: reason }); + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not start transport for an already cancelled request', async () => { + const caller = new AbortController(); + caller.abort(); + const fetchImpl = vi.fn(async () => Response.json(body)); + const provider = create({ fetchImpl }); + + await expect(provider.chat([], [], caller.signal)).rejects.toMatchObject({ name: 'AbortError' }); + + expect(fetchImpl).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('releases its timeout and caller subscription after a successful response', async () => { + const caller = new AbortController(); + let transport: AbortSignal | null | undefined; + const provider = create({ + fetchImpl: async (_url, init) => { + transport = init.signal; + return Response.json(body); + }, + }); + + expect((await provider.chat([], [], caller.signal)).text).toBe('Completed'); + caller.abort(); + await vi.advanceTimersByTimeAsync(300_000); + + expect(transport?.aborted).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it('discards and cancels a response arriving after the deadline', async () => { + let respond!: (response: Response) => void; + let cancelled = false; + const provider = create({ + timeoutMs: 50, + fetchImpl: () => + new Promise((resolve) => { + respond = resolve; + }), + }); + const outcome = observe(provider.chat([], [])); + await vi.advanceTimersByTimeAsync(50); + respond( + new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + ), + ); + await vi.advanceTimersByTimeAsync(0); + + expect(outcome()).toMatchObject({ state: 'failed', error: { name: 'TimeoutError' } }); + expect(cancelled).toBe(true); + }); + + it('reports HTTP failure without waiting for a stalled error body', async () => { + let cancelled = false; + const provider = create({ + fetchImpl: async () => + new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { status: 503 }, + ), + }); + const outcome = observe(provider.chat([], [])); + await vi.advanceTimersByTimeAsync(0); + + expect(outcome()).toMatchObject({ state: 'failed', error: { status: 503 } }); + expect(cancelled).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it('cancels an actual streaming response when its host transport ignores abort', async () => { + let cancelled = false; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"partial":')); + }, + cancel() { + cancelled = true; + }, + }), + ); + const provider = create({ timeoutMs: 50, fetchImpl: async () => response }); + const outcome = observe(provider.chat([], [])); + + await vi.advanceTimersByTimeAsync(50); + + expect(outcome()).toMatchObject({ state: 'failed', error: { name: 'TimeoutError' } }); + expect(cancelled).toBe(true); + expect(response.body!.locked).toBe(false); + }); + + it('clears the deadline when JSON decoding fails', async () => { + const provider = create({ fetchImpl: async () => new Response('Invalid JSON') }); + + await expect(provider.chat([], [])).rejects.toBeInstanceOf(SyntaxError); + expect(vi.getTimerCount()).toBe(0); + }); + + it('releases the chat controller after timeout and permits a new explicit request', async () => { + let requests = 0; + const turns: ChatTurn[] = []; + const provider = create({ + timeoutMs: 50, + fetchImpl: async () => (++requests === 1 ? new Promise(() => {}) : Response.json(body)), + }); + const controller = new AgentChatController(provider, (turn) => turns.push(turn)); + const pending = controller.send('Hello'); + + await vi.advanceTimersByTimeAsync(50); + + expect(await pending).toMatchObject({ status: 'failed' }); + expect(controller.isRunning()).toBe(false); + expect(turns.find((turn) => turn.role === 'error')?.text).toBe(t('agentServiceTimedOut')); + expect(await controller.send('Try again')).toMatchObject({ status: 'success' }); + expect(turns.at(-1)).toMatchObject({ role: 'agent', text: 'Completed' }); + expect(requests).toBe(2); + }); +}); + +it.each([ + [401, 'agentServiceAuthFailed'], + [403, 'agentServiceAuthFailed'], + [429, 'agentServiceRateLimited'], + [503, 'agentServiceUnavailable'], + [504, 'agentServiceTimedOut'], +] as const)('carries Gemini HTTP %i through direct and serialized product guidance', async (status, key) => { + const provider = new GeminiProvider({ + apiKey: 'test', + fetchImpl: async () => new Response('private upstream response', { status }), + }); + const error = await provider.chat([], []).catch((failure: unknown) => failure); + + expect(error).toMatchObject({ status }); + expect(displayError(error)).toBe(t(key)); + expect(displayError((error as Error).message)).toBe(t(key)); + expect((error as Error).message).not.toContain('private upstream response'); +}); diff --git a/test/unit/agent-provider-streaming.test.ts b/test/unit/agent-provider-streaming.test.ts new file mode 100644 index 00000000..e2d8e202 --- /dev/null +++ b/test/unit/agent-provider-streaming.test.ts @@ -0,0 +1,322 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { OpenAIProvider } from '../../packages/agent-core/src/llm/openai'; +import { OllamaProvider } from '../../packages/agent-core/src/llm/ollama'; +import { GeminiProvider } from '../../packages/agent-core/src/llm/gemini'; +import { LoopbackProvider } from '../../packages/agent-core/src/llm/loopback'; +import type { LLMProvider } from '../../packages/agent-core/src/llm/types'; +import type { ProviderHttpOptions } from '../../packages/agent-core/src/llm/provider-http'; + +const encoder = new TextEncoder(); +const sse = (value: unknown) => `data: ${JSON.stringify(value)}\r\n\r\n`; +const chunk = (text: string, finish: string | null = null) => ({ + choices: [{ delta: { content: text }, finish_reason: finish }], +}); +const gemini = (text: string, finish?: string) => ({ + candidates: [{ content: { parts: [{ text }] }, ...(finish ? { finishReason: finish } : {}) }], +}); +const formats = [ + { + name: 'OpenAI', + create: (options: ProviderHttpOptions) => new OpenAIProvider({ apiKey: 'test', ...options }), + type: 'text/event-stream', + head: sse(chunk('你好')), + tail: sse(chunk(' world', 'stop')) + 'data: [DONE]\n\n', + full: { choices: [{ message: { content: 'JSON fallback' }, finish_reason: 'stop' }] }, + }, + { + name: 'Ollama', + create: (options: ProviderHttpOptions) => new OllamaProvider(options), + type: 'text/event-stream', + head: sse(chunk('你好')), + tail: sse(chunk(' world', 'stop')) + 'data: [DONE]\n\n', + full: { choices: [{ message: { content: 'JSON fallback' }, finish_reason: 'stop' }] }, + }, + { + name: 'Gemini', + create: (options: ProviderHttpOptions) => new GeminiProvider({ apiKey: 'test', ...options }), + type: 'text/event-stream', + head: sse(gemini('你好')), + tail: sse(gemini(' world', 'STOP')), + full: gemini('JSON fallback', 'STOP'), + }, + { + name: 'Loopback', + create: (options: ProviderHttpOptions) => new LoopbackProvider({ model: 'local', ...options }), + type: 'application/x-ndjson', + head: JSON.stringify({ done: false, message: { content: '你好' } }) + '\n', + tail: JSON.stringify({ done: true, done_reason: 'stop', message: { content: ' world' } }) + '\n', + full: { done: true, done_reason: 'stop', message: { content: 'JSON fallback' } }, + }, +]; +async function prepare(provider: LLMProvider) { + if (provider instanceof LoopbackProvider) await provider.preload(); +} +const tags = Response.json({ models: [{ name: 'local' }] }); + +describe.each(formats)('$name incremental request', (format) => { + afterEach(() => vi.useRealTimers()); + it('publishes text before completion across split UTF-8/frame boundaries and returns one full answer', async () => { + let sink!: ReadableStreamDefaultController; + const requests: RequestInit[] = []; + const provider = format.create({ + fetchImpl: async (url, init) => { + if (url.endsWith('/tags')) return tags.clone(); + requests.push(init); + return new Response( + new ReadableStream({ + start(controller) { + sink = controller; + }, + }), + { headers: { 'Content-Type': format.type } }, + ); + }, + }); + await prepare(provider); + const deltas: string[] = []; + let completed = false; + const pending = provider.chatStream!([], [], (text) => deltas.push(text)).finally(() => { + completed = true; + }); + await vi.waitFor(() => expect(sink).toBeDefined()); + const bytes = encoder.encode(format.head); + // Split every byte: multi-byte text, CRLF and frame delimiters must survive. + for (const byte of bytes) sink.enqueue(new Uint8Array([byte])); + await vi.waitFor(() => expect(deltas).toEqual(['你好'])); + expect(completed).toBe(false); + sink.enqueue(encoder.encode(format.tail)); + sink.close(); + expect(await pending).toMatchObject({ text: '你好 world', toolCalls: [] }); + expect(deltas).toEqual(['你好', ' world']); + const body = JSON.parse(requests[0].body as string); + if (format.name === 'Gemini') expect(body.tools).toBeUndefined(); + else expect(body.stream).toBe(true); + expect(requests[0].signal?.aborted).toBe(false); + }); + + it('accepts a complete JSON answer from compatible endpoints that ignore streaming', async () => { + const provider = format.create({ + fetchImpl: async (url) => (url.endsWith('/tags') ? tags.clone() : Response.json(format.full)), + }); + await prepare(provider); + const deltas: string[] = []; + expect((await provider.chatStream!([], [], (text) => deltas.push(text))).text).toBe('JSON fallback'); + expect(deltas.join('')).toBe('JSON fallback'); + }); + + it('rejects a disconnected partial response instead of archiving it as complete', async () => { + const provider = format.create({ + fetchImpl: async (url) => + url.endsWith('/tags') ? tags.clone() : new Response(format.head, { headers: { 'Content-Type': format.type } }), + }); + await prepare(provider); + await expect(provider.chatStream!([], [], () => {})).rejects.toThrow(/Incomplete .* stream/); + }); + + it('cancels a stalled stream at the total deadline even when the host ignores abort', async () => { + let cancelled = false; + const response = new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { headers: { 'Content-Type': format.type } }, + ); + const provider = format.create({ + timeoutMs: 50, + fetchImpl: async (url) => (url.endsWith('/tags') ? tags.clone() : response), + }); + await prepare(provider); + vi.useFakeTimers(); + const pending = provider.chatStream!([], [], () => {}); + const checked = expect(pending).rejects.toMatchObject({ name: 'TimeoutError' }); + await vi.advanceTimersByTimeAsync(50); + await checked; + expect(cancelled).toBe(true); + expect(response.body!.locked).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it('cancels a stream that exceeds its byte ceiling and never exposes its payload', async () => { + let cancelled = false; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(format.head + ' '.repeat(256))); + }, + cancel() { + cancelled = true; + }, + }), + { headers: { 'Content-Type': format.type } }, + ); + const provider = format.create({ + maxResponseBytes: 128, + fetchImpl: async (url) => (url.endsWith('/tags') ? tags.clone() : response), + }); + await prepare(provider); + const onDelta = vi.fn(); + await expect(provider.chatStream!([], [], onDelta)).rejects.toThrow('AI response exceeds size limit'); + expect(onDelta).not.toHaveBeenCalled(); + expect(cancelled).toBe(true); + expect(response.body!.locked).toBe(false); + }); +}); + +it.each(formats.filter((f) => f.name !== 'Loopback'))( + '$name preserves complete tool requests rather than streaming unvalidated operations', + async (format) => { + let request: Record = {}; + const provider = format.create({ + fetchImpl: async (_url, init) => { + request = JSON.parse(init.body as string); + return Response.json(format.full); + }, + }); + const onDelta = vi.fn(); + expect((await provider.chatStream!([], [{ name: 'read', description: '', inputSchema: {} }], onDelta)).text).toBe( + 'JSON fallback', + ); + expect(request.stream).not.toBe(true); + expect(request.tools).toBeDefined(); + expect(onDelta).not.toHaveBeenCalled(); + }, +); + +it('Gemini does not stream thought text and rejects private in-band error details safely', async () => { + const provider = new GeminiProvider({ + apiKey: 'test', + fetchImpl: async () => + new Response( + sse({ + candidates: [{ content: { parts: [{ text: 'private thought', thought: true }, { text: 'Answer' }] } }], + }) + sse({ error: { code: 429, message: 'secret upstream content' } }), + { headers: { 'Content-Type': 'text/event-stream' } }, + ), + }); + const deltas: string[] = []; + await expect(provider.chatStream!([], [], (text) => deltas.push(text))).rejects.toMatchObject({ + status: 429, + message: 'Gemini request failed: 429', + }); + expect(deltas).toEqual(['Answer']); +}); + +it('stops Gemini deltas immediately when Stop occurs inside the first part callback', async () => { + const abort = new AbortController(); + const provider = new GeminiProvider({ + apiKey: 'test', + fetchImpl: async () => + new Response( + sse({ + candidates: [ + { + content: { parts: [{ text: 'first' }, { text: 'late' }] }, + finishReason: 'STOP', + }, + ], + }), + { headers: { 'Content-Type': 'text/event-stream' } }, + ), + }); + const deltas: string[] = []; + await expect( + provider.chatStream( + [], + [], + (text) => { + deltas.push(text); + abort.abort(); + }, + abort.signal, + ), + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(deltas).toEqual(['first']); +}); + +it('parses SSE comments and multiline data and cancels at DONE without awaiting a transport close', async () => { + let cancelled = false; + const provider = new OpenAIProvider({ + apiKey: 'test', + fetchImpl: async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + ': heartbeat\r\nevent: message\r\ndata: {"choices":\r\ndata: [{"delta":{"content":"Hi"},"finish_reason":"stop"}]}\r\n\r\ndata: [DONE]\r\n\r\n', + ), + ); + }, + cancel() { + cancelled = true; + }, + }), + { headers: { 'Content-Type': 'text/event-stream' } }, + ), + }); + const deltas: string[] = []; + expect((await provider.chatStream([], [], (text) => deltas.push(text))).text).toBe('Hi'); + expect(deltas).toEqual(['Hi']); + expect(cancelled).toBe(true); +}); + +it('cancels and unlocks the stream if the display callback throws', async () => { + let cancelled = false; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sse(chunk('Hi')))); + }, + cancel() { + cancelled = true; + }, + }), + { headers: { 'Content-Type': 'text/event-stream' } }, + ); + const provider = new OpenAIProvider({ apiKey: 'test', fetchImpl: async () => response }); + await expect( + provider.chatStream([], [], () => { + throw new Error('View disposed'); + }), + ).rejects.toThrow('View disposed'); + expect(cancelled).toBe(true); + expect(response.body!.locked).toBe(false); +}); + +it('settles Gemini at the native finish marker even if the host keeps the socket open', async () => { + let cancelled = false; + const provider = new GeminiProvider({ + apiKey: 'test', + timeoutMs: 50, + fetchImpl: async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sse(gemini('Complete', 'STOP')))); + }, + cancel() { + cancelled = true; + }, + }), + { headers: { 'Content-Type': 'text/event-stream' } }, + ), + }); + expect((await provider.chatStream([], [], () => {})).text).toBe('Complete'); + expect(cancelled).toBe(true); +}); + +it.each(formats)('$name accepts complete host JSON responses without a body stream', async (format) => { + const provider = format.create({ + fetchImpl: async (url) => + ({ + ok: true, + json: async () => (url.endsWith('/tags') ? { models: [{ name: 'local' }] } : format.full), + }) as Response, + }); + await prepare(provider); + const deltas: string[] = []; + expect((await provider.chatStream!([], [], (text) => deltas.push(text))).text).toBe('JSON fallback'); + expect(deltas).toEqual(['JSON fallback']); +}); diff --git a/test/unit/assistant-offline.test.ts b/test/unit/assistant-offline.test.ts index 70ff5555..d4ca6c18 100644 --- a/test/unit/assistant-offline.test.ts +++ b/test/unit/assistant-offline.test.ts @@ -7,17 +7,44 @@ afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); - -function worker(addAll = vi.fn(async (_assets: string[]) => {})) { +const capabilities = { + webgpu: ['/assets/gpu.js'], + cpu: ['/assets/cpu.wasm'], + 'cpu-compat': ['/assets/compat.wasm'], + pdf: ['/assets/pdf.mjs'], +}; +function worker(initial: Record> = {}, fail = false) { + const stores = new Map(Object.entries(initial)); const listeners = new Map void>(); - const cache = { - put: vi.fn(async () => {}), - addAll, - match: async (url: string) => (url.includes('/assets/') ? undefined : { text: async () => '' }), + const added: string[] = []; + const deleted: string[] = []; + const open = async (name: string) => { + let store = stores.get(name); + if (!store) { + store = new Map(); + stores.set(name, store); + } + return { + match: async (url: string) => + store!.get(url) ?? + (!url.includes('/assets/') && !url.includes('__document') ? { text: async () => '' } : undefined), + put: async (url: string, response: Response) => { + store!.set(url, response); + }, + addAll: async (urls: string[]) => { + if (fail && urls.some((u) => u.startsWith('/assets/'))) throw Error('quota'); + for (const url of urls) { + added.push(url); + store!.set(url, new Response('payload')); + } + }, + keys: async () => [...store!.keys()].map((url) => new Request(new URL(url, 'https://editor.example'))), + delete: async (request: Request) => store!.delete(new URL(request.url).pathname), + }; }; const source = readFileSync('public/sw.js', 'utf8').replace( - 'const ASSISTANT_ASSETS_TO_CACHE = [];', - 'const ASSISTANT_ASSETS_TO_CACHE = ["/assets/agent.js", "/assets/runtime.wasm"];', + 'const ASSISTANT_CAPABILITY_ASSETS = {};', + `const ASSISTANT_CAPABILITY_ASSETS = ${JSON.stringify(capabilities)};`, ); runInNewContext(source, { URL, @@ -25,61 +52,106 @@ function worker(addAll = vi.fn(async (_assets: string[]) => {})) { Date, Response, Set, + Map, self: { location: { origin: 'https://editor.example' }, addEventListener: (type: string, fn: any) => listeners.set(type, fn), skipWaiting: () => {}, + clients: { matchAll: async () => [], claim: async () => {} }, }, - caches: { open: async () => cache, keys: async () => [] }, - }); - return { listeners, addAll }; -} - -it('does not cache optional runtime during installation and prepares it only on request', async () => { - const { listeners, addAll } = worker(); - let job: Promise; - listeners.get('install')!({ - waitUntil: (value: Promise) => { - job = value; + caches: { + open, + keys: async () => [...stores.keys()], + has: async (name: string) => stores.has(name), + delete: async (name: string) => { + deleted.push(name); + return stores.delete(name); + }, + match: async (url: string) => { + for (const store of stores.values()) if (store.has(url)) return store.get(url); + }, }, }); - await job!; - expect(addAll.mock.calls.flat(2)).not.toContain('/assets/runtime.wasm'); - addAll.mockClear(); - const reply = vi.fn(); - listeners.get('message')!({ - data: { type: 'PREPARE_ASSISTANT' }, - ports: [{ postMessage: reply }], - waitUntil: (value: Promise) => { - job = value; + async function event(type: string, data?: unknown) { + let job: Promise | undefined; + const reply = vi.fn(); + listeners.get(type)!({ + data, + ports: [{ postMessage: reply }], + waitUntil: (p: Promise) => { + job = p; + }, + }); + await job; + return reply; + } + return { + added, + stores, + event, + deleted, + setFailure: (value: boolean) => { + fail = value; }, - }); - await job!; - expect(addAll).toHaveBeenCalledWith(['/assets/agent.js', '/assets/runtime.wasm']); + }; +} +const marker = (capability: string) => `/__document-assistant-prepared__/${capability}`; +const prepared = () => new Response('1', { headers: { 'X-Document-Assistant-Prepared': '1' } }); + +it('installs no local runtime and prepares only the activated capability', async () => { + const w = worker(); + await w.event('install'); + expect(w.added.filter((a) => a.startsWith('/assets/'))).toEqual([]); + const reply = await w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'cpu' }); + expect(w.added.filter((a) => a.startsWith('/assets/'))).toEqual(['/assets/cpu.wasm']); expect(reply).toHaveBeenCalledWith({ type: 'ASSISTANT_PREPARED', ok: true }); + await w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'pdf' }); + expect(w.added).toContain('/assets/pdf.mjs'); + expect(w.added).not.toContain('/assets/gpu.js'); + expect(w.added).not.toContain('/assets/compat.wasm'); }); - -it('reports an optional cache failure and permits a later retry', async () => { - const addAll = vi.fn(async (_assets: string[]) => {}).mockRejectedValueOnce(new Error('quota')); - const { listeners } = worker(addAll); - const reply = vi.fn(); - let job: Promise; - const event = { - data: { type: 'PREPARE_ASSISTANT' }, - ports: [{ postMessage: reply }], - waitUntil: (value: Promise) => { - job = value; - }, - }; - listeners.get('message')!(event); - await job!; - expect(reply).toHaveBeenLastCalledWith({ type: 'ASSISTANT_PREPARED', ok: false }); - listeners.get('message')!(event); - await job!; - expect(reply).toHaveBeenLastCalledWith({ type: 'ASSISTANT_PREPARED', ok: true }); +it('ignores legacy generic opt-in and rejects arbitrary capability URLs', async () => { + const w = worker(); + await w.event('message', { type: 'PREPARE_ASSISTANT' }); + const reply = await w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'https://external.example/model' }); + expect(reply).toHaveBeenCalledWith({ type: 'ASSISTANT_PREPARED', ok: false }); + expect(w.added).toEqual([]); }); - -it('bounds a missing worker response and closes message ports', async () => { +it('retries quota failure without marking a capability as prepared', async () => { + const w = worker({}, true); + const reply = await w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'cpu' }); + expect(reply).toHaveBeenCalledWith({ type: 'ASSISTANT_PREPARED', ok: false }); + expect([...w.stores.values()].some((s) => s.has(marker('cpu')))).toBe(false); + w.setFailure(false); + const retry = await w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'cpu' }); + expect(retry).toHaveBeenCalledWith({ type: 'ASSISTANT_PREPARED', ok: true }); +}); +it('restores the union of capabilities across earlier builds before retiring them', async () => { + const w = worker({ + 'document-editor-core-old-a': new Map([[marker('cpu'), prepared()]]), + 'document-editor-core-old-b': new Map([[marker('pdf'), prepared()]]), + }); + await w.event('install'); + expect(w.added).toContain('/assets/cpu.wasm'); + expect(w.added).toContain('/assets/pdf.mjs'); + expect(w.added).not.toContain('/assets/gpu.js'); + await w.event('activate'); + expect(w.deleted).toContain('document-editor-core-old-a'); + expect([...w.stores.values()].some((s) => s.has(marker('cpu')))).toBe(true); + expect([...w.stores.values()].some((s) => s.has(marker('pdf')))).toBe(true); +}); +it('migrates old all-runtime preparation and catches preparation committed after install', async () => { + const old = new Map(); + const w = worker({ 'document-editor-core-old': old }); + await w.event('install'); + old.set('/__document-assistant-prepared__', prepared()); + await w.event('activate'); + expect(w.added).toContain('/assets/gpu.js'); + expect(w.added).toContain('/assets/cpu.wasm'); + expect(w.added).toContain('/assets/compat.wasm'); + expect(w.added).toContain('/assets/pdf.mjs'); +}); +it('sends the selected capability and bounds missing worker replies', async () => { vi.useFakeTimers(); const close = vi.fn(); vi.stubGlobal( @@ -91,140 +163,39 @@ it('bounds a missing worker response and closes message ports', async () => { ); const postMessage = vi.fn(); const pending = prepareAssistantOffline( + 'cpu', { controller: { postMessage }, ready: Promise.resolve({ active: { postMessage } }) }, 50, ); + expect(postMessage.mock.calls[0][0]).toEqual({ type: 'PREPARE_ASSISTANT', capability: 'cpu' }); await vi.advanceTimersByTimeAsync(50); expect(await pending).toBe(false); expect(close).toHaveBeenCalledTimes(2); }); - -it('keeps unsupported or failed offline preparation from disabling online use', async () => { - expect(await prepareAssistantOffline(undefined)).toBe(false); - expect(await prepareAssistantOffline({ controller: null, ready: Promise.reject(new Error('denied')) }, 50)).toBe( +it('keeps failed worker readiness from disabling online use', async () => { + expect(await prepareAssistantOffline('cpu', { controller: null, ready: Promise.reject(Error('denied')) }, 50)).toBe( false, ); }); - -it('prepares new optional hashes during install when an older build was prepared', async () => { - const listeners = new Map void>(); - const marker = new Response('1', { headers: { 'X-Document-Assistant-Prepared': '1' } }); - const added: string[] = []; - const put = vi.fn(async () => {}); - const source = readFileSync('public/sw.js', 'utf8').replace( - 'const ASSISTANT_ASSETS_TO_CACHE = [];', - 'const ASSISTANT_ASSETS_TO_CACHE = ["/assets/agent-new.js", "/assets/engine-new.wasm"];', - ); - runInNewContext(source, { - URL, - Promise, - Date, - Set, - Response, - self: { - location: { origin: 'https://editor.example' }, - addEventListener: (type: string, fn: any) => listeners.set(type, fn), - skipWaiting: () => {}, - }, - caches: { - keys: async () => [], - match: async () => marker, - open: async () => ({ - addAll: async (assets: string[]) => { - added.push(...assets); - }, - put, - match: async (path: string) => (path.includes('/assets/') ? undefined : { text: async () => '' }), - }), - }, - }); - let install: Promise; - listeners.get('install')!({ - waitUntil: (value: Promise) => { - install = value; - }, - }); - await install!; - expect(added).toContain('/assets/agent-new.js'); - expect(added).toContain('/assets/engine-new.wasm'); - expect(put).toHaveBeenCalledWith('/__document-assistant-prepared__', expect.any(Response)); -}); - -it('rechecks preparation committed by the outgoing worker during installation', async () => { - const listeners = new Map void>(); - let marker: Response | undefined; - let release!: () => void; - const barrier = new Promise((resolve) => { - release = resolve; - }); - const added: string[] = []; - const source = readFileSync('public/sw.js', 'utf8').replace( - 'const ASSISTANT_ASSETS_TO_CACHE = [];', - 'const ASSISTANT_ASSETS_TO_CACHE = ["/assets/new-engine.wasm"];', - ); - runInNewContext(source, { - URL, - Promise, - Date, - Set, - Response, - self: { - location: { origin: 'https://editor.example' }, - addEventListener: (type: string, fn: any) => listeners.set(type, fn), - skipWaiting: () => {}, - }, - caches: { - keys: async () => [], - match: async () => marker, - open: async () => ({ - addAll: async (assets: string[]) => { - if (assets.includes('./editor')) await barrier; - added.push(...assets); - }, - put: async () => {}, - match: async (path: string) => (path.includes('/assets/') ? undefined : { text: async () => '' }), - }), - }, - }); - let install: Promise; - listeners.get('install')!({ - waitUntil: (value: Promise) => { - install = value; - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(added).not.toContain('/assets/new-engine.wasm'); - marker = new Response('1', { headers: { 'X-Document-Assistant-Prepared': '1' } }); - release(); - await install!; - expect(added).toContain('/assets/new-engine.wasm'); -}); - -it('migrates a remembered landing-page opt-in without starting a model', async () => { +it('remembered generic opt-in on Home never prepares local runtimes', async () => { const postMessage = vi.fn(); - const close = vi.fn(); - let factory: any; - const global = { - localStorage: { getItem: () => 'true' }, - setTimeout: vi.fn(() => 1), - clearTimeout: vi.fn(), - }; + const global = { localStorage: { getItem: () => 'true' }, setTimeout: vi.fn(() => 1), clearTimeout: vi.fn() }; runInNewContext(readFileSync('public/sw-register.js', 'utf8'), { window: global, Promise, MessageChannel: class { - port1 = { close, onmessage: null }; - port2 = { close }; + port1 = { close: vi.fn(), onmessage: null }; + port2 = { close: vi.fn() }; }, }); - factory = (global as any).__createSwUpdater; const registration = { installing: { postMessage, state: 'installing', addEventListener: vi.fn() }, active: null, waiting: null, addEventListener: vi.fn(), }; - await factory({ serviceWorker: { register: async () => registration, controller: null } }).start(); - expect(postMessage.mock.calls[0][0]).toEqual({ type: 'PREPARE_ASSISTANT' }); - expect(postMessage.mock.calls[0][1]).toHaveLength(1); + await (global as any) + .__createSwUpdater({ serviceWorker: { register: async () => registration, controller: null } }) + .start(); + expect(postMessage).not.toHaveBeenCalled(); }); diff --git a/test/unit/history-autosave.test.ts b/test/unit/history-autosave.test.ts index 655350cf..bb6da7da 100644 --- a/test/unit/history-autosave.test.ts +++ b/test/unit/history-autosave.test.ts @@ -138,6 +138,25 @@ describe('autosave session', () => { expect(getAutosaveDocId()).toBe(doc!.id); }); + it('rejects an over-budget export without reading its file and preserves earlier recovery', async () => { + requestSaveDocument.mockResolvedValue(fileOf([7, 8, 9])); + await beginAutosaveSession({ docId: 'keep', title: 'Keep.docx', origin: 'local' }); + await takeSnapshot(); + stopAutosaveSession(); + vi.stubGlobal('navigator', { ...navigator, storage: { estimate: async () => ({ quota: 200 }) } }); + const file = new File([new Uint8Array(120)], 'Large.docx'); + const read = vi.spyOn(file, 'arrayBuffer'); + requestSaveDocument.mockResolvedValue(file); + await beginAutosaveSession({ docId: 'large', title: 'Large.docx', origin: 'local' }); + + expect(await takeSnapshot()).toBeNull(); + + expect(read).not.toHaveBeenCalled(); + expect(isAutosaveSessionActive()).toBe(false); + expect(Array.from((await getLatestSnapshot('keep'))!.bytes)).toEqual([7, 8, 9]); + expect((await listDocs()).items.map((doc) => doc.id)).toEqual(['keep']); + }); + it('keeps appending to the same row across snapshots', async () => { requestSaveDocument.mockResolvedValue(fileOf([1])); await beginAutosaveSession({ docId: 'report', title: 'Report.docx', origin: 'local' }); diff --git a/test/unit/history-store.test.ts b/test/unit/history-store.test.ts index eff14d62..7bc74bb5 100644 --- a/test/unit/history-store.test.ts +++ b/test/unit/history-store.test.ts @@ -187,6 +187,40 @@ describe('history store', () => { expect((await listDocs()).items.map((row) => row.title)).toEqual(['New.docx']); }); + it.each(['QuotaExceededError', 'UnknownError'])( + 'preserves recovery when the quota retry fails with %s', + async (retryError) => { + const cold = await putSnapshot({ title: 'Recovery.docx', origin: 'local', bytes: bytes(16, 7) }); + const current = await putSnapshot({ title: 'Current.docx', origin: 'local', bytes: bytes(16, 9) }); + const put = IDBObjectStore.prototype.put; + let attempts = 0; + vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (this: IDBObjectStore, ...args) { + if (this.name === 'blobs') { + attempts++; + throw Object.assign(new Error('Snapshot write failed'), { + name: attempts === 1 ? 'QuotaExceededError' : retryError, + }); + } + return put.apply(this, args as Parameters); + }); + + const result = await putSnapshot({ + id: current!.id, + title: 'Changed.docx', + origin: 'local', + bytes: bytes(32, 1), + }); + + expect(result).toBeNull(); + expect(attempts).toBe(2); + expect(await getDoc(cold!.id)).toEqual(cold); + expect(Array.from((await getLatestSnapshot(cold!.id))!.bytes)).toEqual(Array(16).fill(7)); + expect(await getDoc(current!.id)).toEqual(current); + expect(Array.from((await getLatestSnapshot(current!.id))!.bytes)).toEqual(Array(16).fill(9)); + expect((await listDocs()).total).toBe(2); + }, + ); + it('rejects an impossible snapshot without evicting existing recovery', async () => { mockQuota(200); const keep = await putSnapshot({ title: 'Keep.docx', origin: 'local', bytes: bytes(40, 7) }); @@ -196,6 +230,19 @@ describe('history store', () => { expect(await historyUsage()).toBe(40); }); + it.each(['blob', 'file'])('rejects an oversized %s before materializing its bytes', async (kind) => { + mockQuota(200); + const keep = await putSnapshot({ title: 'Keep.docx', origin: 'local', bytes: bytes(40, 7) }); + const payload = kind === 'blob' ? new Blob([new Uint8Array(120)]) : new File([new Uint8Array(120)], 'Large.docx'); + const read = vi.spyOn(payload, 'arrayBuffer'); + + expect(await putSnapshot({ title: 'Large.docx', origin: 'local', bytes: payload })).toBeNull(); + + expect(read).not.toHaveBeenCalled(); + expect(Array.from((await getLatestSnapshot(keep!.id))!.bytes)).toEqual(Array(40).fill(7)); + expect((await listDocs()).total).toBe(1); + }); + it('trims its own revisions to the budget before evicting unrelated history', async () => { mockQuota(240); const keep = await putSnapshot({ title: 'Keep.docx', origin: 'local', bytes: bytes(10, 7) }); diff --git a/test/unit/landing-prefetch.test.ts b/test/unit/landing-prefetch.test.ts index c1c605fb..f3933197 100644 --- a/test/unit/landing-prefetch.test.ts +++ b/test/unit/landing-prefetch.test.ts @@ -116,7 +116,7 @@ describe('landing page warm-up', () => { }); describe('landing warm-up during a worker upgrade', () => { - function setup(effectiveType?: string) { + function setup(effectiveType?: string, controlled = true) { const src = readFileSync(resolve(__dirname, '../../public/landing-prefetch.js'), 'utf8'); const win = Object.assign(new EventTarget(), { requestIdleCallback: (callback: () => void) => callback(), @@ -128,7 +128,10 @@ describe('landing warm-up during a worker upgrade', () => { waiting: null as (EventTarget & { state: string; scriptURL?: string }) | null, installing: null as (EventTarget & { state: string }) | null, }); - const workers = Object.assign(new EventTarget(), { ready: Promise.resolve(registration) }); + const workers = Object.assign(new EventTarget(), { + ready: Promise.resolve(registration), + controller: controlled ? ({} as object | null) : null, + }); const fetchMock = vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(okResponse())); new Function('window', 'document', 'navigator', 'fetch', 'AbortController', src)( win, @@ -140,6 +143,39 @@ describe('landing warm-up during a worker upgrade', () => { return { win, doc, cta, registration, workers, fetchMock, hook: win.__landingPrefetch! }; } + it('waits for actual page control after ready before fetching a cacheable resource', async () => { + const { workers, hook, fetchMock } = setup(undefined, false); + const cached = new Set(); + fetchMock.mockImplementation((url) => { + if (workers.controller) cached.add(url); + return Promise.resolve(okResponse()); + }); + const pending = hook.warm('/sdkjs/common/libfont/engine/fonts.js'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetchMock).not.toHaveBeenCalled(); + expect(cached.size).toBe(0); + + workers.controller = {}; + workers.dispatchEvent(new Event('controllerchange')); + await pending; + expect(cached.has('/sdkjs/common/libfont/engine/fonts.js')).toBe(true); + }); + + it('settles a control wait on departure and leaves the URL retryable after returning', async () => { + const { workers, win, hook, fetchMock } = setup(undefined, false); + const pending = hook.warm('/awaiting-control.js'); + await new Promise((resolve) => setTimeout(resolve, 0)); + win.dispatchEvent(new Event('beforeunload')); + await pending; + workers.controller = {}; + workers.dispatchEvent(new Event('controllerchange')); + expect(fetchMock).not.toHaveBeenCalled(); + + win.dispatchEvent(new Event('pointerdown')); + await hook.warm('/awaiting-control.js'); + expect(fetchMock.mock.calls.filter(([url]) => url === '/awaiting-control.js')).toHaveLength(1); + }); + it('holds a waiting upgrade without poisoning URLs, then warms after controllerchange', async () => { const { registration, workers, win, hook, fetchMock } = setup(); registration.waiting = Object.assign(new EventTarget(), { state: 'installed' }); diff --git a/test/unit/local-runtime-assets.test.ts b/test/unit/local-runtime-assets.test.ts new file mode 100644 index 00000000..92380ba4 --- /dev/null +++ b/test/unit/local-runtime-assets.test.ts @@ -0,0 +1,79 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { LocalInferenceProvider } from '../../packages/agent-core/src/llm/local'; +import { WebLLMProvider } from '../../packages/agent-core/src/llm/webllm'; + +const state = vi.hoisted(() => ({ gpuReload: vi.fn(async () => {}), cpuLoaded: false })); +vi.mock('../../packages/agent-core/node_modules/@mlc-ai/web-llm', () => ({ + prebuiltAppConfig: { model_list: [] }, + WebWorkerMLCEngine: class { + chat = { completions: { create: vi.fn() } }; + reload = state.gpuReload; + }, +})); +vi.mock('../../packages/agent-core/src/llm/wllama-cache-storage', () => ({ + selectWllamaStorage: async () => undefined, +})); +vi.mock('../../packages/agent-core/src/llm/wllama-cpu-runtime', () => ({ + loadWllamaCPURuntime: async () => ({ + Wllama: class { + setCompat() {} + isModelLoaded() { + return state.cpuLoaded; + } + async loadModelFromUrl() { + state.cpuLoaded = true; + } + async exit() { + state.cpuLoaded = false; + } + }, + wasmUrl: '/cpu.wasm', + compatibility: null, + }), +})); +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + state.cpuLoaded = false; +}); +function browser() { + vi.stubGlobal('navigator', { gpu: { requestAdapter: async () => ({ features: new Set(['shader-f16']) }) } }); + vi.stubGlobal( + 'Worker', + class { + addEventListener() {} + terminate() {} + }, + ); +} +it('prepares WebGPU only when a supported default runtime starts and ignores a hung host cache', async () => { + browser(); + const prepare = vi.fn(() => new Promise(() => {})); + const provider = new WebLLMProvider({ prepareRuntimeAssets: prepare }); + expect(prepare).not.toHaveBeenCalled(); + await provider.preload(); + expect(prepare.mock.calls).toEqual([['webgpu']]); + expect(state.gpuReload).toHaveBeenCalled(); + await provider.dispose(); +}); +it('forwards host preparation to the selected CPU backend and tolerates rejected persistence', async () => { + browser(); + const prepare = vi.fn(async () => { + throw Error('quota'); + }); + const provider = new LocalInferenceProvider({ detectGPU: async () => false, prepareRuntimeAssets: prepare }); + await provider.preload(); + expect(provider.backend).toBe('wllama'); + expect(prepare.mock.calls).toEqual([['cpu']]); + expect(state.gpuReload).not.toHaveBeenCalled(); + await provider.dispose(); +}); +it('forwards host preparation to WebGPU without preparing the unused CPU backend', async () => { + browser(); + const prepare = vi.fn(); + const provider = new LocalInferenceProvider({ detectGPU: async () => true, prepareRuntimeAssets: prepare }); + await provider.preload(); + expect(provider.backend).toBe('webllm'); + expect(prepare.mock.calls).toEqual([['webgpu']]); + await provider.dispose(); +}); diff --git a/test/unit/offline-assets-build.test.ts b/test/unit/offline-assets-build.test.ts index cf1dc9b7..dc75408c 100644 --- a/test/unit/offline-assets-build.test.ts +++ b/test/unit/offline-assets-build.test.ts @@ -24,7 +24,7 @@ async function fixture() { await writeFile(join(dir, 'ran-fonts', 'Geist.woff2'), ''); await writeFile( join(dir, 'sw.js'), - 'const APP_ASSETS_TO_CACHE = []; // BUILD_APP_ASSETS\nconst ASSISTANT_ASSETS_TO_CACHE = []; // BUILD_ASSISTANT_ASSETS', + 'const APP_ASSETS_TO_CACHE = []; // BUILD_APP_ASSETS\nconst ASSISTANT_CAPABILITY_ASSETS = {}; // BUILD_ASSISTANT_CAPABILITIES', ); const manifest = { 'editor.html': { @@ -43,74 +43,73 @@ async function fixture() { return { dir, manifest }; } -it('keeps offline editor dependencies while deferring the assistant and its runtime', async () => { - const { dir } = await fixture(); - try { - expect(await stampOfflineAssets(dir)).toEqual([ - '/assets/agent.js', - '/assets/base.css', - '/assets/editor.js', - '/assets/recovery.js', - '/assets/shared.js', - '/ran-fonts/Geist.woff2', - ]); - const source = await readFile(join(dir, 'sw.js'), 'utf8'); - const optional = JSON.parse(source.match(/const ASSISTANT_ASSETS_TO_CACHE = (\[.*?\]);/)![1]); - expect(optional).toEqual(['/assets/engine.js', '/assets/runtime.wasm']); - expect(source).not.toContain('model.gguf'); - expect(source).not.toContain('private.docx'); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); +function lists(source: string) { + return { + core: JSON.parse(source.match(/const APP_ASSETS_TO_CACHE = (\[.*?\]);/)![1]), + capabilities: JSON.parse(source.match(/const ASSISTANT_CAPABILITY_ASSETS = (\{.*?\});/)![1]), + }; +} -it('keeps assistant UI assets mandatory while deferring attached executable runtime assets', async () => { +it('partitions actual capability graphs without downloading unrelated emitted payloads', async () => { const { dir, manifest } = await fixture(); try { - // Vite can attach Worker outputs directly to the importing UI chunk. - Object.assign(manifest['lib/agent-plugin/agent-plugin.ts'], { - css: ['assets/assistant.css'], - assets: ['assets/webllm.worker-build.js', 'assets/runtime.wasm', 'assets/ui.woff2'], - }); - for (const name of ['assistant.css', 'webllm.worker-build.js', 'ui.woff2']) - await writeFile(join(dir, 'assets', name), ''); - await writeFile(join(dir, '.vite', 'manifest.json'), JSON.stringify(manifest)); - - expect(await stampOfflineAssets(dir)).toEqual([ - '/assets/agent.js', - '/assets/assistant.css', - '/assets/base.css', - '/assets/editor.js', - '/assets/recovery.js', - '/assets/shared.js', - '/assets/ui.woff2', - '/ran-fonts/Geist.woff2', - ]); - const source = await readFile(join(dir, 'sw.js'), 'utf8'); - const optional = JSON.parse(source.match(/const ASSISTANT_ASSETS_TO_CACHE = (\[.*?\]);/)![1]); - expect(optional).toEqual(['/assets/engine.js', '/assets/runtime.wasm', '/assets/webllm.worker-build.js']); + const graph = manifest as Record; + graph['lib/agent-plugin/agent-plugin.ts'].dynamicImports = [ + 'node_modules/@mlc-ai/web-llm/lib/index.js', + 'packages/agent-core/dist/llm/wllama-cpu-runtime.js', + 'lib/agent-plugin/connectors/pdf-reference-reader.ts', + 'reference', + ]; + graph['lib/agent-plugin/agent-plugin.ts'].assets = [ + 'assets/webllm.worker-test.js', + 'assets/history-search.worker-test.js', + 'assets/cpu.wasm', + ]; + graph['node_modules/@mlc-ai/web-llm/lib/index.js'] = { file: 'assets/gpu.js' }; + graph['packages/agent-core/dist/llm/wllama-cpu-runtime.js'] = { + file: 'assets/cpu.js', + imports: ['lib/agent-plugin/agent-plugin.ts'], + dynamicImports: [ + 'packages/agent-core/vendor/wllama-count/native/default/wllama.wasm?url', + 'packages/agent-core/vendor/wllama-count/native/compat/wllama.wasm?url', + ], + }; + graph['packages/agent-core/vendor/wllama-count/native/default/wllama.wasm?url'] = { + file: 'assets/cpu-url.js', + assets: ['assets/cpu.wasm'], + }; + graph['packages/agent-core/vendor/wllama-count/native/compat/wllama.wasm?url'] = { + file: 'assets/compat-url.js', + assets: ['assets/compat.wasm'], + }; + graph['lib/agent-plugin/connectors/pdf-reference-reader.ts'] = { + file: 'assets/pdf.js', + assets: ['assets/pdf.worker-test.mjs'], + }; + graph.reference = { file: 'assets/reference.js' }; + await writeFile(join(dir, '.vite', 'manifest.json'), JSON.stringify(graph)); + await stampOfflineAssets(dir); + const { core, capabilities } = lists(await readFile(join(dir, 'sw.js'), 'utf8')); + expect(core).toContain('/assets/reference.js'); + expect(core).not.toContain('/assets/cpu.wasm'); + expect(core).toContain('/assets/history-search.worker-test.js'); + expect(capabilities.webgpu).toEqual(['/assets/gpu.js', '/assets/webllm.worker-test.js']); + expect(capabilities.cpu).toEqual(['/assets/cpu-url.js', '/assets/cpu.js', '/assets/cpu.wasm']); + expect(capabilities['cpu-compat']).toEqual(['/assets/compat-url.js', '/assets/compat.wasm']); + expect(capabilities.pdf).toEqual(['/assets/pdf.js', '/assets/pdf.worker-test.mjs']); + expect(JSON.stringify({ core, capabilities })).not.toContain('/assets/engine.js'); + expect(JSON.stringify({ core, capabilities })).not.toContain('private.docx'); } finally { await rm(dir, { recursive: true, force: true }); } }); -it('rejects graph dependencies that could cache an external URL or private file', async () => { +it('rejects unsafe or missing graph dependencies', async () => { const { dir, manifest } = await fixture(); try { manifest.engine.assets = ['https://outside.example/runtime.wasm']; await writeFile(join(dir, '.vite', 'manifest.json'), JSON.stringify(manifest)); await expect(stampOfflineAssets(dir)).rejects.toThrow(/asset/i); - manifest.engine.assets = ['assets/private.docx']; - await writeFile(join(dir, '.vite', 'manifest.json'), JSON.stringify(manifest)); - await expect(stampOfflineAssets(dir)).rejects.toThrow(/asset/i); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -it('fails the build when an import graph edge is missing', async () => { - const { dir, manifest } = await fixture(); - try { manifest.engine = undefined as never; await writeFile(join(dir, '.vite', 'manifest.json'), JSON.stringify(manifest)); await expect(stampOfflineAssets(dir)).rejects.toThrow(/manifest/i); @@ -119,29 +118,11 @@ it('fails the build when an import graph edge is missing', async () => { } }); -it('prepares bundled PDF module workers as optional runtime assets', async () => { - const { dir, manifest } = await fixture(); - try { - manifest.engine.assets = ['assets/pdf.worker.min-test.mjs']; - await writeFile(join(dir, 'assets', 'pdf.worker.min-test.mjs'), ''); - await writeFile(join(dir, '.vite', 'manifest.json'), JSON.stringify(manifest)); - await stampOfflineAssets(dir); - const source = await readFile(join(dir, 'sw.js'), 'utf8'); - expect(JSON.parse(source.match(/const ASSISTANT_ASSETS_TO_CACHE = (\[.*?\]);/)![1])).toContain( - '/assets/pdf.worker.min-test.mjs', - ); - expect(JSON.parse(source.match(/const APP_ASSETS_TO_CACHE = (\[.*?\]);/)![1])).not.toContain( - '/assets/pdf.worker.min-test.mjs', - ); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); it('keeps the local token and font stylesheets available after an offline reload', async () => { const { dir } = await fixture(); try { - await writeFile(join(dir, 'app-tokens.css'), ':root{--app-control-page:42px}'); - await writeFile(join(dir, 'ran-tokens.css'), ':root{--ran-font-family:system-ui}'); + await writeFile(join(dir, 'app-tokens.css'), ':root{}'); + await writeFile(join(dir, 'ran-tokens.css'), ':root{}'); await writeFile(join(dir, 'ran-fonts', 'fonts.css'), '@font-face{}'); const assets = await stampOfflineAssets(dir); expect(assets).toContain('/ran-tokens.css'); diff --git a/test/unit/sw-live-core.test.ts b/test/unit/sw-live-core.test.ts index 4d05cc4a..2a9d758a 100644 --- a/test/unit/sw-live-core.test.ts +++ b/test/unit/sw-live-core.test.ts @@ -34,6 +34,12 @@ describe('deploy preserves lazy chunks for live pages', () => { if (!stores.has(name)) stores.set(name, new Map()); const store = stores.get(name)!; return { + // Real Cache instances support match and resolve relative URLs against + // the worker origin, including optional capability metadata lookups. + match: async (request: string | { url: string }) => { + const url = new URL(typeof request === 'string' ? request : request.url, origin).href; + return store.get(url)?.clone(); + }, keys: async () => [...store.keys()].map((url) => ({ url })), delete: async ({ url }: { url: string }) => store.delete(url), put: async (url: string, response: Response) => store.set(url, response), diff --git a/test/unit/wllama-compat-assets.test.ts b/test/unit/wllama-compat-assets.test.ts index 893b88bc..56473b11 100644 --- a/test/unit/wllama-compat-assets.test.ts +++ b/test/unit/wllama-compat-assets.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from 'vitest'; +import { expect, it, vi } from 'vitest'; import { getWllamaCompatibility } from '../../packages/agent-core/src/llm/wllama-compat-assets'; const assets = { wasm: '/assets/compat.wasm', worker: { code: 'var Module = {};' } }; @@ -28,3 +28,17 @@ it('propagates local asset failure without silently choosing a CDN', async () => }), ).rejects.toThrow('Local asset missing'); }); + +it('prepares compatibility resources only for the selected fallback and tolerates host cache failure', async () => { + const prepare = vi.fn(() => { + throw Error('cache unavailable'); + }); + expect( + await getWllamaCompatibility({ Suspending: class {}, validate: () => true }, async () => assets, prepare), + ).toBeNull(); + expect(prepare).not.toHaveBeenCalled(); + expect(await getWllamaCompatibility({ validate: () => false }, async () => assets, prepare, 'cpu-compat')).toEqual( + assets, + ); + expect(prepare).toHaveBeenCalledWith('cpu-compat'); +}); diff --git a/test/unit/wllama-cpu-loader.test.ts b/test/unit/wllama-cpu-loader.test.ts index 770b41f9..123f98a9 100644 --- a/test/unit/wllama-cpu-loader.test.ts +++ b/test/unit/wllama-cpu-loader.test.ts @@ -41,8 +41,10 @@ it.each(['javascript:alert(1)', 'data:application/octet-stream;base64,AA==', 'fi vi.stubGlobal('navigator', { storage: { getDirectory: async () => ({ getDirectoryHandle: async () => ({}) }) }, }); - const provider = new WllamaProvider({ cpuOnly: true, modelUrl }); + const prepareRuntimeAssets = vi.fn(); + const provider = new WllamaProvider({ cpuOnly: true, modelUrl, prepareRuntimeAssets }); await expect(provider.preload()).rejects.toThrow('Model artifacts require HTTP or HTTPS'); + expect(prepareRuntimeAssets).not.toHaveBeenCalled(); expect(state.loader).not.toHaveBeenCalled(); expect(state.load).not.toHaveBeenCalled(); await provider.dispose(); diff --git a/test/unit/wllama-loading-disposal.test.ts b/test/unit/wllama-loading-disposal.test.ts index 37022b8e..cafe3b57 100644 --- a/test/unit/wllama-loading-disposal.test.ts +++ b/test/unit/wllama-loading-disposal.test.ts @@ -35,13 +35,21 @@ it('owns and releases the worker while SDK initialization is still pending', asy const entered = new Promise((resolve) => { state.entered = resolve; }); - const provider = new WllamaProvider({ modelUrl: 'https://models.example/model.gguf', wasmUrl: '/local.wasm' }); + const prepareRuntimeAssets = vi.fn(async () => { + throw Error('cache quota'); + }); + const provider = new WllamaProvider({ + modelUrl: 'https://models.example/model.gguf', + wasmUrl: '/local.wasm', + prepareRuntimeAssets, + }); const loading = provider.preload(); const outcome = loading.then( () => ({ name: 'unexpected success' }), (error) => error, ); await entered; + expect(prepareRuntimeAssets).toHaveBeenCalledWith('wllama'); await provider.dispose(); expect(state.exits).toBe(1); expect(await outcome).toMatchObject({ name: 'AbortError' });