diff --git a/CLAUDE.md b/CLAUDE.md index b2150e025..001a077d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ pnpm install --frozen-lockfile # 安装依赖 pnpm run dev # 启动开发服务器(含热更新) pnpm run build # 生产构建(执行 bin/build.sh) pnpm run build:single # 打包为单个 HTML 文件 -pnpm run lint:ts # oxlint + tsc --noEmit(CI 必跑) +pnpm run lint:ts # oxlint + workspace rebuild + tsc --noEmit(CI 必跑) pnpm run format:check # prettier 格式检查(CI 必跑) pnpm run test # 单元测试(Vitest) pnpm run test:coverage # 带覆盖率的单元测试 @@ -204,7 +204,7 @@ test/setup/vitest.ts # 全局 mock:matchMedia、URL.createObjectURL ``` 覆盖率统计 `lib/**/*.ts` 与 `packages/*/src/**/*.ts`,排除类型声明。 -全局最低门槛保留在 `vitest.config.ts`,历史、shared、converter、agent-core 与 chat-ui +全局门槛为 lines/functions/statements 80%、branches 75%;Agent 应用层、历史、shared、converter、agent-core 与 chat-ui 另有独立模块门槛。实际数字以 `pnpm run test:coverage` 为准。 开发与 Vitest 通过 `bin/workspace-aliases.mjs` 从 workspace 的 `exports` 派生源码别名, @@ -1140,3 +1140,10 @@ E2E 回归套件覆盖**(`pnpm run test:e2e` 的 embed-regression.spec:打 ### AI assistant product entry `lib/agent-entry.ts` owns the discoverable entry and browser-local default-off preference. URL `agent` flags do not enable it. Opening restores only UI/preferences; loading a model or connecting a service requires an explicit user action. Generated writing must be reviewed before applying. Developer transport and provider details belong in developer references, not product onboarding. + +## Runtime and storage maintenance (2026-10-13) + +- `bin/offline-assets.mjs` derives mandatory app dependencies from Vite's emitted manifest. Inference chunks/WASM are optional; the small assistant UI static graph stays in core for live-page compatibility. `PREPARE_ASSISTANT` follows explicit or persisted opt-in, and a prepared marker refreshes runtime assets before later worker activation. Model weights and external endpoints remain outside app caches. Preserve the vendor precache for first offline export. +- `pnpm run tsc` rebuilds workspace declarations before checking the app; `lint:ts` calls it. Do not rely on an earlier production build to refresh package contracts. +- History v3 uses key-only revision/size indexing for retention; latest snapshot reads only one value. Conversation storage keeps explicit opt-in and atomic revision conflicts while updating individual sessions. +- Panel context synchronization is lifecycle-owned: no periodic reads while closed, disabled or hidden, and BFCache restoration reinstalls observers. Keep extracted settings/context owners rather than adding them back to `panel.ts`. diff --git a/bin/offline-assets.mjs b/bin/offline-assets.mjs index 109b1ff81..2cdf0e858 100644 --- a/bin/offline-assets.mjs +++ b/bin/offline-assets.mjs @@ -1,18 +1,67 @@ import { readdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -/** Build outputs only: no model weights, documents, source maps or external URLs. */ +const ASSISTANT_ENTRY = 'lib/agent-plugin/agent-plugin.ts'; +const GENERATED_ASSET = /^assets\/[A-Za-z0-9_.-]+\.(?:js|css|wasm|woff2)$/; + +/** Follow both static and lazy app edges, stopping at the optional assistant. */ +function collectAssets(manifest, roots, excluded = new Set(), followDynamic = true) { + const visited = new Set(); + const assets = new Set(); + const visit = (key) => { + if (visited.has(key) || excluded.has(key)) return; + visited.add(key); + const chunk = manifest[key]; + if (!chunk) throw new Error(`Missing offline manifest dependency: ${key}`); + for (const asset of [chunk.file, ...(chunk.css ?? []), ...(chunk.assets ?? [])]) { + if (!GENERATED_ASSET.test(asset)) throw new Error(`Unsupported offline asset: ${asset}`); + assets.add(`/${asset}`); + } + for (const imported of [...(chunk.imports ?? []), ...(followDynamic ? (chunk.dynamicImports ?? []) : [])]) + visit(imported); + }; + for (const root of roots) visit(root); + return assets; +} + +/** Core app dependencies install first; optional inference assets need explicit opt-in. */ export async function stampOfflineAssets(directory) { - const chunks = (await readdir(join(directory, 'assets'))).filter((name) => /\.(?:js|css|wasm)$/.test(name)); + const manifest = JSON.parse(await readFile(join(directory, '.vite', 'manifest.json'), 'utf8')); + const entries = Object.entries(manifest) + .filter(([, chunk]) => chunk.isEntry) + .map(([key]) => key); + const assistant = Object.keys(manifest).filter( + (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 (/\.(?:js|css|wasm)$/.test(name)) optional.add(`/assets/${name}`); + } const fonts = (await readdir(join(directory, 'ran-fonts'))).filter((name) => name.endsWith('.woff2')); - const assets = [...chunks.map((name) => `/assets/${name}`), ...fonts.map((name) => `/ran-fonts/${name}`)].sort(); + for (const name of fonts) core.add(`/ran-fonts/${name}`); + 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'; - if (!source.includes(marker)) throw new Error('Missing offline app asset build marker'); + const assistantMarker = 'const ASSISTANT_ASSETS_TO_CACHE = []; // BUILD_ASSISTANT_ASSETS'; + if (!source.includes(marker) || !source.includes(assistantMarker)) + throw new Error('Missing offline asset build marker'); await writeFile( path, - source.replace(marker, `const APP_ASSETS_TO_CACHE = ${JSON.stringify(assets)}; // BUILD_APP_ASSETS`), + source + .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`, + ), ); return assets; } diff --git a/docs/explorations/2026-10-13-follow-up-performance.md b/docs/explorations/2026-10-13-follow-up-performance.md new file mode 100644 index 000000000..44befe0d1 --- /dev/null +++ b/docs/explorations/2026-10-13-follow-up-performance.md @@ -0,0 +1,52 @@ +# Follow-up performance verification + +This pass starts from 0131b526 and adds one commit per feature to PR #266. Operation counts establish reduced work; they do not imply a specific browser latency improvement. + +## Browser baseline + +Chrome DevTools inspected a production local editor preview on 127.0.0.1:4203. The reload trace was unthrottled and reported CLS 0.06; no LCP or field metrics were returned. The network snapshot confirmed native app/SDK/font resources, and the accessibility snapshot confirmed the real editor was interactive. This local reload is not a cold production bandwidth benchmark. No vendor-loading optimization is claimed from it. + +## Selected improvements + +- Chat rendering: repeated full-history button-state scans and per-row scroll measurements during restoration; repeated full streamed source attribute writes. +- History expiry: complete live metadata read/sort before expiry filtering despite an existing updatedAt index. +- GGUF deletion: per-shard SDK deletion repeats cache enumeration, including unrelated cached files. + +Verification results will be recorded alongside each independently committed feature. + +## Chat rendering + +The new appendMany API restores completed messages through the existing renderer, then performs one final scroll update. Ordinary append keeps its immediate behavior. New action controls initialize within their row; existing controls update only when running or draft-emptiness changes. Streamed original source is published once at completion/interruption before the row becomes a completed answer. + +| Deterministic fixture | Before | After | +| --------------------------------------------------- | -------------------------------: | ----: | +| Disabled-control writes for 100 agent messages | 5,050 | 100 | +| Disabled-control writes for 500 agent messages | 125,250 | 500 | +| Scroll calls for a 500-message restore | 500 | 1 | +| scrollHeight reads for a 500-message restore | 1,500 | 3 | +| data-source updates across 20 streaming frames | 20 | 0 | +| Final source publication on completion/interruption | Per-frame source already present | 1 | + +Validated with 120 focused chat/controller/storage tests, package type generation, and a mounted Chromium case restoring 400 persisted messages, switching to a short conversation and back, checking source/action state and final scroll position. Operation counts are measured in deterministic DOM fixtures; no end-user milliseconds or memory reduction is asserted. + +## Indexed history expiry + +Use the existing updatedAt index to fetch only snapshots at or before the inclusive expiry cutoff. The precise expiresAt predicate still renews retention after reopening, and a stable descending sort of expired candidates preserves returned ID ordering, including equal timestamps. Deletions and save-target cleanup retain their original transactional behavior. No database schema change. + +A fixture with 2,000 recent documents and five old metadata rows reads five candidates instead of all 2,005. Snapshot writes still perform the required full budget check: total metadata results fall from 4,008 to 2,008 (five expiry candidates and 2,003 budget rows); full metadata scans fall from two to one. Eleven focused history suites / 98 tests passed, including exact boundaries/ties, reopened retention, blob/handle cleanup and cancellation rollback. + +## Batched GGUF deletion + +Resolve the requested cache group, then delete its exact known filenames in one SDK deleteMany call. The installed SDK still owns body/metadata deletion and error propagation. Unrelated models, shared runtimes and the missing-group error remain unchanged. + +Real installed SDK/storage-boundary tests show three-shard deletion uses two inventory scans instead of four. An eight-shard, four-unrelated-file small-body adapter probe shows nine scans become two, with identical deleted and remaining filenames; Blob reads fall from 240 to 72. These are aggregate operation counts on small fixtures, not a browser heap or transfer measurement. Eleven focused model-cache/storage tests pass, including metadata cleanup and failure propagation. + +## Final assembled verification + +- Full repository-pinned unit coverage: 5,394 tests / 181 files pass; 85.75% lines, 80.12% branches, all coverage gates pass. +- Thirty-eight impacted Chromium cases pass: long saved conversation restore, context notifications, assistant entry/native writes, history pagination/search/retention/export and autosave recovery. +- Lint, workspace declaration rebuild, application/package type checking, Docker Compose configuration, complete format check and production Vite build pass. +- Independent read-only review approves all three new feature scopes with no unresolved findings. +- New commits stay separate by feature. Earlier optional-cache/storage/UI changes remain in the previously reviewed commit; no dependencies, schemas, model defaults or vendor code changed in this pass. + +The previous revision was separately checked with the user's pending upgraded dependencies. This follow-up was verified with repository-pinned dependencies; that distinction remains explicit in the PR. Real-user field metrics, production-network timing and deployment are outside the local evidence. diff --git a/docs/superpowers/plans/2026-10-13-runtime-storage-optimizations.md b/docs/superpowers/plans/2026-10-13-runtime-storage-optimizations.md new file mode 100644 index 000000000..ff7a85b65 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-runtime-storage-optimizations.md @@ -0,0 +1,91 @@ +# Runtime and storage optimization implementation plan + +> For agentic workers: execute the independent domains using dispatching-parallel-agents; the coordinator owns cache integration and final verification. + +**Goal:** Implement all audit findings and deliver one reviewed GitHub PR. +**Architecture:** Preserve existing browser APIs and offline editor behavior. Defer optional assistant assets, reduce storage byte copies, and extract bounded UI lifecycles. +**Tech Stack:** TypeScript, Vite, IndexedDB, Service Worker, Vitest, Playwright. +**Spec:** docs/superpowers/specs/2026-10-13-runtime-storage-design.md + +## Global constraints + +No new dependencies or vendor changes. Preserve first offline save, stored data, explicit cloud/history opt-in, stale-tab conflicts, cancellation, public panel exports and BFCache. Do not include the root checkout's unrelated dependency edits. + +## Review focus + +- Offline reopen/export after one online editor initialization, with a cold landing that defers inference assets. +- Enabling an already cached assistant offline after reload. +- Database upgrades with old data and another tab open. +- Erasure/cancellation while a save is queued or in flight. +- Hidden panel, replaced editor and BFCache restoration. + +### Task 1: Optional assistant precache + +Files: bin/offline-assets.mjs, declaration; vite.config.ts; public/sw.js and public/sw-register.js; lib/agent-entry.ts; new lib/assistant-offline.ts; manifest/worker/entry/offline tests. + +- [x] Assert the core manifest retains the static assistant UI graph and excludes inference runtime assets and includes static/dynamic editor dependencies. +- [x] Assert explicit worker preparation caches only the local allowlist, rejects failures, and preserves first native offline export. +- [x] Enable Vite manifest; derive graph closures and stamp two asset lists; request optional preparation after explicit enablement. +- [x] Run related unit tests and production build; measure core bytes before/after. + +### Task 2: Bounded document history reads + +Files: lib/history/{store,db,types}.ts and history unit tests. + +- [x] Add regressions rejecting bulk blob reads while returning newest bytes/accounting correctly. +- [x] Implement reverse latest cursor and lightweight revision metadata/key trimming; migrate legacy records as needed. +- [x] Verify retention, quota, cancellation and source/handle cleanup tests. + +### Task 3: Incremental conversation storage + +Files: lib/agent-plugin/ui/{sessions,conversation-db}.ts and their unit tests. + +- [x] Test burst coalescing, unchanged-session writes, v1 migration, stale-tab and erase races. +- [x] Implement a bounded pending save and per-session persistence under atomic index revision, keeping repository API. +- [x] Run the conversation/history controls/controller test suites. + +### Task 4: Panel lifecycle and decomposition + +Files: lib/agent-plugin/ui/panel.ts; extracted context/settings/history modules; related unit tests. + +- [x] Test no hidden/disabled/background polling, immediate resume, SDK callbacks and BFCache cleanup. +- [x] Extract context scheduler and at least two focused presentation responsibilities; preserve public panel functions and request semantics. +- [x] Run all panel/composer/context/sidebar tests. + +### Task 5: Quality gates and review + +Files: failing iframe/focus tests; package.json scripts; vitest.config.ts; shared/root CLAUDE guides; workflow contract tests. + +- [x] Fix synthetic iframe/focus fixtures without weakening production behavior assertions. +- [x] Make type checking rebuild declarations, verify from deleted stale package output. +- [x] Tighten useful application-owned coverage gates and update outdated guide statements. +- [x] Run full unit coverage, lint, format, production build and impacted E2E. +- [ ] Fresh review of the complete diff; resolve findings; commit, push and create PR. + +## Progress + +- Scope authorized by the user's instruction to implement all audit recommendations and submit a PR. +- Phase 1 branch prepared; PR phases 2-7 pending until implementation and validation complete. + +## Implementation evidence + +- History: 11 suites / 112 tests passed, including legacy v1/v2 upgrade, bounded reads, exact gapped-revision accounting, blocked upgrade retry and cancelled trim rollback. +- Conversation: 81 targeted tests passed; v2 migration, stale-tab conflicts/erasure and coalescing verified. Full-snapshot validation/serialization remains proportional to input size; unchanged records are neither cloned nor rewritten. +- Panel: 176 targeted tests passed; three modules extracted, panel reduced from 2905 to 2633 lines. SDK bursts coalesce and hidden/disabled/BFCache lifecycles release resources. +- Integrated upgraded-toolchain run: 5360 unit tests passed, coverage gates passed (85.39% lines, 79.84% branches), 34 impacted Chromium E2E passed. +- Default app precache: 1,453,207 bytes (~1.39 MiB), down from 57,481,875 bytes (~54.8 MiB); optional inference runtime: 56,063,311 bytes (~53.47 MiB). These are decoded/build file bytes, not network transfer measurements. +- Fresh converter declaration output was moved away before running the type-check script; the script rebuilt packages and passed. +- Independent review found and fixed prepared-runtime update migration/races. Final scoped review reports no unresolved Critical/Important findings. +- The first browser probe assumed a landing-only visit fully warms the editor. Missing native vendor assets disproved that assumption; the acceptance flow now initializes the native editor online before offline reopen/export, preserving the existing contract. + +Final pinned-toolchain verification: 179 unit files / 5360 tests passed with coverage gates; 34 impacted browser cases plus the isolated worker-update case passed (35 total). Lint/type/build/format and fresh-declaration checks passed. The same pinned update case also passed against unmodified main. Upgraded Playwright update timing remains inconsistent in local probes; dependency updates are outside this PR. + +## Follow-up: event-driven assistant UI + +- Replace panel context intervals with disposable SDK/native-input subscriptions and explicit readiness/open/visibility notifications. +- Replace sidebar rail scans with filtered DOM/iframe-load/media-query observers. +- Replace pending-preview validity intervals with the context event lifecycle; preserve pre-apply validation. +- Refresh model release controls and translated context explicitly from existing runtime/language updates. +- Verify focused no-polling regressions, full quality checks, real editor sidebar/selection interactions, review and update PR #266. + +Follow-up verification: 5378 tests across 179 files pass with coverage gates (85.51% lines, 79.90% branches); lint/type checks, format and production build pass. Twenty impacted assistant Chromium cases pass, including three new event-driven cases; the final rebuilt three-case suite additionally verifies a native sheet rename and same-selection history mutation. Independent read-only review found missing spreadsheet mutation/sheet notifications, now covered and rechecked. The tracking fixture models SDK multiple-listener ownership and checks complete pagehide cleanup. No recurring timers remain under lib/agent-plugin/ui. diff --git a/docs/superpowers/specs/2026-10-13-follow-up-performance-design.md b/docs/superpowers/specs/2026-10-13-follow-up-performance-design.md new file mode 100644 index 000000000..124f448b6 --- /dev/null +++ b/docs/superpowers/specs/2026-10-13-follow-up-performance-design.md @@ -0,0 +1,38 @@ +# Follow-up performance scope + +The user requested another performance pass with one commit per feature, collected in one PR. Continue PR #266 from 0131b526, keeping its previous reviewed commit and adding three independently reviewable feature commits. User-requested commit granularity overrides the PR helper's default one-commit requirement. + +## Evidence + +| Finding | Evidence | Status | +| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | +| Restoring messages repeatedly scans all historical action controls and reads scroll geometry after each inserted row | packages/chat-ui/src/chat-view.ts append/updateRestoreState/scrollToEnd; two restoration loops in lib/agent-plugin/ui/panel.ts | Source verified | +| Streaming rewrites the entire response into data-source every frame although live actions are hidden | chat-view.ts flushStream/endStream; styles.ts; answer getters exclude streaming rows | Source and consumer search verified | +| Expiry sweep reads and sorts all history metadata before filtering | lib/history/store.ts sweepExpired/readAllDocs; db.ts DOCS_BY_UPDATED already exists | Source verified | +| Updating or reopening a document restarts its retention period | lib/history/types.ts expiresAt | Source verified; compatibility constraint | +| GGUF shard deletion repeatedly enumerates the complete model cache | model-cache.ts deleteCachedGGUF; installed Wllama CacheManager.delete delegates to deleteMany/list | Source verified | +| Local editor reload trace reports CLS 0.06, without LCP or field data | Chrome DevTools trace NAVIGATION_0, unthrottled local preview | Local diagnostic only; no network-speed or real-user claim | + +## Changes and boundaries + +1. Chat rendering: packages/chat-ui/src/chat-view.ts, its package guide, the two conversation-restoration loops in lib/agent-plugin/ui/panel.ts, and targeted unit/browser tests. Initialize action state within a new row, update old rows only on relevant state transitions, add appendMany using the existing renderer with one final scroll update, and finalize streamed source metadata at completion. Preserve normal append's return value and behavior. +2. History expiry: lib/history/store.ts and targeted history tests. Read only potentially expired rows via the existing updatedAt index, then retain the precise expiry predicate and return ordering. +3. GGUF cache deletion: packages/agent-core/src/llm/model-cache.ts and targeted model-cache tests. Resolve the requested group, then use one SDK batch deletion over exactly its known file IDs. + +The corresponding validation record lives in docs/explorations/2026-10-13-follow-up-performance.md and evolves with each feature commit. + +Untouched: vendor binaries/scripts, dependencies and lockfile, storage schemas, retention settings, providers, inference defaults, autosave timing, external services, production deployment. The main checkout's pending dependency changes remain outside this worktree. + +Compatibility requirements: message order/tool groups, reader scroll intent, busy-state accessibility, copy/application payloads and partial interrupted answers; cancellation/disposal; reopened history and inclusive expiration boundary; stable expired-ID ordering, atomic byte/handle cleanup; exact cache-group membership and missing-group/failure behavior. + +## Acceptance and verification + +- Message restoration visits controls linearly and performs one final scroll update. Streaming source metadata is finalized once, with completed/interrupted payloads unchanged. Verify operation-count regressions, existing chat and panel tests, and mounted Chromium conversation interactions. +- A fixture of 2,000 recent and five old history records reads five expiry candidates rather than all 2,005 metadata rows. Verify retained/reaped records, exact ID ordering, cancellation and normal snapshot accounting. +- Deleting a model with multiple shards scans the cache a constant number of times through the actual SDK and leaves unrelated entries intact. Verify exact filename membership, metadata removal, missing group and partial failure propagation. +- Run full unit coverage, lint/type/format checks, production build and affected Chromium E2E; independently review the final diff. +- Commit each feature separately, validate clean branch/non-main target/current base and commit boundaries, push the branch normally and update PR #266. No merge or deployment. + +## Simpler alternatives and deferred work + +Use row-local initialization and a small batching method instead of virtualizing history. Use the existing index instead of a new expiry schema. Use the installed SDK's batch deletion instead of reimplementing its storage semantics. Keep complete conversation snapshot validation because callers may mutate loaded messages in place; identity-only caching would miss legitimate edits. Defer default history cursor pagination because reversing equal timestamps changes tie ordering and filtered views still need scans. Formatter caching has small per-page absolute cost and is outside this pass. diff --git a/docs/superpowers/specs/2026-10-13-runtime-storage-design.md b/docs/superpowers/specs/2026-10-13-runtime-storage-design.md new file mode 100644 index 000000000..e37824c5d --- /dev/null +++ b/docs/superpowers/specs/2026-10-13-runtime-storage-design.md @@ -0,0 +1,55 @@ +# Runtime and storage optimization design + +The user approved implementing every finding from the project audit and creating a PR. This is an architectural maintenance change across existing browser flows, with no new product dependencies. + +## Evidence and scope lock + +| Assumption | Evidence | Status | +| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------- | +| All emitted JS/CSS/WASM are precached even with AI disabled | bin/offline-assets.mjs:6; public/sw.js:270; production manifest: 108 files / 57,481,875 bytes | Verified source and build | +| History reads clone all retained snapshot bytes | lib/history/store.ts:129,368 | Verified source | +| Context polling continues after close/disable | lib/agent-plugin/ui/panel.ts:250,276,900 | Verified source | +| Every conversation update clones and writes all sessions | lib/agent-plugin/ui/sessions.ts:45,59; conversation-db.ts:156 | Verified source | +| Current jsdom rejects synthetic iframe mounting and clears remembered focus | audit minimum reproduction and two failing tests | Verified runtime | +| Type checking can read old workspace declarations | initial TS1345; successful lint after package build | Verified runtime | + +Allowed: bin/offline-assets.mjs and declaration, vite.config.ts, public/sw.js and public/sw-register.js; lib/agent-entry.ts and a new assistant-offline module; lib/history/{store,db,types}.ts; lib/agent-plugin/ui/{panel,sessions,conversation-db}.ts and extracted UI modules; package.json scripts; relevant unit/E2E tests and their build configuration; this design/plan and related CLAUDE guides. The root checkout's pending dependency upgrades remain outside the PR. + +Untouched: editor vendor binaries/scripts, model artifacts, provider defaults, cloud routing, embedding protocol, unrelated workflows. No new third-party dependency. + +Compatibility red lines: preserve offline document reopen and first offline save after online editor initialization, all three editor formats, model reuse offline after explicit setup, saved conversation opt-in, cancellation and stale-tab conflict/erasure protection, existing history revisions and metadata accounting, public panel exports and BFCache restoration. + +## Design + +1. Derive core and assistant offline manifests from the emitted Vite dependency graph. Core includes editor/history/static dependencies and the small assistant UI static graph, protecting old live-page lazy UI imports; assistant roots and their transitive runtime assets are prepared following explicit assistant enablement, including a persisted opt-in. A build-owned prepared marker makes later workers refresh the optional graph before activation for already prepared users. A worker message prepares optional assets without blocking core activation, ignores external URLs/model weights, acknowledges errors, and preserves response isolation. Existing vendor precache remains for first offline document export. +2. Read the latest document revision with a reverse bounded cursor. Trim using keys and small revision metadata; migrate old snapshots transactionally, without reading every document's bytes into memory. Keep seven-day/three-revision rules and atomic cancellation. +3. Coalesce queued conversation saves before creating a snapshot. Persist per-session records with an atomic revision/index record; migrate v1 saved state on database upgrade. Keep repository interfaces, explicit opt-in, revision tombstones and conflict behavior. +4. Extract context synchronization and focused panel settings/history presentation modules. Use SDK selection/content notifications and native editor input events; refresh only on changes while enabled, open and visible. Discover late SDKs on document readiness, unregister on editor replacement/pagehide, and restore after BFCache. Sidebar entries follow iframe load, rail DOM changes and media-query notifications; pending previews invalidate through the same event lifecycle. No periodic assistant UI polling. No broad rewrite of request semantics. +5. Repair test fixtures while preserving assertions. Rebuild workspace declarations before type checks. Tighten application-owned coverage gates where meaningful, retain vendor/E2E exceptions, and correct stale shared guide statements. + +## Simplest alternative + +Keep the current editor/vendor offline contract and only defer assistant resources, instead of adding an offline preparation product screen. Keep storage APIs unchanged rather than exposing new repository contracts. Coalesce writes and use revision metadata before considering OPFS document storage. Extract existing responsibilities without replacing the panel or adding a framework. + +## Acceptance + +- No inference WASM/large runtime in the mandatory precache; explicit preparation restores assistant offline assets. Verify build-manifest and actual worker handler tests plus browser offline flow. +- Latest snapshot returns only its newest bytes; trim has no bulk blob read and accounting remains exact. Verify existing history suite, legacy DB migration, cancellation and bounded-read regressions. +- Burst conversation updates persist the latest state with bounded writes and unchanged sessions are not rewritten. Verify repository/store migration, blocked upgrade, stale-tab and erase race tests. +- All assistant UI states, including visible/open panels and pending previews, make no periodic SDK reads and resume immediately when shown; callbacks clean up across editor replacement/BFCache. Verify scheduler and panel tests plus sidebar E2E. +- Full unit coverage, lint/type checks, formatting, production build and impacted real editor/offline/save/history/sidebar E2E pass. Verify both repository-pinned dependencies and the pending upgraded toolchain where feasible. +- Review branch diff, preserve unrelated root changes, push one coherent commit and create a GitHub PR against main. No merge or deployment. + +## Validation refinement + +The browser probe confirmed that a landing-only visit does not fully warm the native editor (missing SDK chart/style/image assets). This is an existing distinction from first offline export after editor initialization. Keep that native contract instead of adding a large cold offline vendor download. Optional AI assets must also survive a new build while the panel stays closed; a preparation marker is required, and an already opted-in page may prepare assets without running models. + +The assistant UI static graph remains in mandatory core (about 0.42 MiB additional) so a live unsaved old editor can still open its lazy UI after deployment removes the old hashed URL. Large inference graphs remain optional; an already prepared profile refreshes them transactionally during installation. + +Legacy remembered opt-in is communicated from landing registration to the installing/active worker. Install rechecks the prepared marker after required caching, closing preparation races with the outgoing worker; activation checks again before claiming clients. Enabled editor pages also prepare the current worker following controller changes and BFCache restoration. + +## Follow-up scope: remove assistant UI polling + +User request: remove remaining polling. Source verification found periodic context reads, sidebar rail discovery and preview validity checks. The bundled SDK exposes selection, active-sheet, content-modified and undo/redo notifications; the host emits document readiness, and native iframe load/DOM/media notifications cover sidebar readiness and layout. + +Allowed files for this follow-up: panel-context.ts, sidebar-entry.ts, action-preview.ts, panel.ts, their unit tests, relevant assistant E2E tests and this design/plan. Preserve exact pre-apply stale-target checks, focus ownership, disable/visibility/BFCache teardown and offline/storage contracts. Automatic save cadence and bounded vendor initialization/font waits remain outside this UI scope. Event bursts use one queued refresh rather than recurring timers. Verify zero idle reads, late editor/rail arrival, compact layout transitions, input invalidation and callback cleanup in unit tests and real editors. diff --git a/lib/agent-entry.ts b/lib/agent-entry.ts index fe19e2ad0..7e4af86d1 100644 --- a/lib/agent-entry.ts +++ b/lib/agent-entry.ts @@ -1,3 +1,4 @@ +import { prepareAssistantOffline } from './assistant-offline'; import { t } from '@ranuts/shared/i18n'; import { isEmbedMode } from './embed-mode'; import { readAssistantEnabled, saveAssistantEnabled } from './agent-preferences'; @@ -7,6 +8,8 @@ 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; @@ -84,6 +87,7 @@ export function createAgentEntry() { }, }); await runtime.setAgentPanelEnabled(true); + void prepareAssistantOffline(); } else { enabled = false; saveAssistantEnabled(false); @@ -139,6 +143,10 @@ 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) => { @@ -147,6 +155,7 @@ 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. @@ -155,6 +164,7 @@ export function createAgentEntry() { sidebar = mountSidebar(); viewportDispose = mountPanelViewport(shell); sync(); + onControllerChange(); } }; window.addEventListener('pagehide', onPageHide); diff --git a/lib/agent-plugin/ui/action-preview.ts b/lib/agent-plugin/ui/action-preview.ts index 2754d75d3..06bd45dc4 100644 --- a/lib/agent-plugin/ui/action-preview.ts +++ b/lib/agent-plugin/ui/action-preview.ts @@ -3,6 +3,7 @@ import { t } from '@ranuts/shared/i18n'; import { displayError } from './presentation'; import { buildSeries, type SeriesInput } from '../fill-series'; import { textDifference, type ChangeReview } from './change-review'; +import { mountPanelContext } from './panel-context'; export interface PreviewAction { target: { label: string; @@ -58,7 +59,7 @@ export class ActionPreview { private onApplied?: (outcome: 'sent' | 'verified', action: PreviewAction) => void; private onRefine?: (action: PreviewAction) => void; private onSettled?: (outcome: ReviewOutcome) => void; - private timer?: ReturnType; + private context?: ReturnType; private readonly updateLanguage = () => { this.labels(); if (this.action && !this.settled && this.editor.hidden) this.renderComparison(this.action.review); @@ -327,9 +328,21 @@ export class ActionPreview { this.renderComparison(action.review); this.applyButton.removeAttribute('disabled'); this.labels(); - this.timer = setInterval(() => { - if (!action.isCurrent()) this.invalidate(); - }, 500); + const context = mountPanelContext({ + isActive: () => this.action === action && !this.settled && !this.el.hidden, + refresh: () => { + if (this.action === action && !this.settled && !action.isCurrent()) this.invalidate(); + }, + }); + // Mount validates synchronously; it can settle or replace this review before + // returning its owner. Dispose that owner instead of retaining stale listeners. + if (this.action === action && !this.settled) this.context = context; + else context.dispose(); + } + private stopObserving(): void { + const context = this.context; + this.context = undefined; + context?.dispose(); } /** Only a live pending proposal can be used as unexecuted refinement context. */ pending(): PreviewAction | undefined { @@ -456,7 +469,7 @@ export class ActionPreview { if (this.settled) return; this.action.cancel(); this.settled = true; - clearInterval(this.timer); + this.stopObserving(); this.applyButton.setAttribute('disabled', ''); this.status.textContent = t('agentPlanExpired'); this.finish('expired'); @@ -480,7 +493,7 @@ export class ActionPreview { this.action?.cancel(); if (this.action) this.settle({ status: 'cancelled', action: this.action }); this.action = null; - clearInterval(this.timer); + this.stopObserving(); this.el.hidden = true; if (restore && this.returnFocus?.isConnected) this.returnFocus.focus(); } @@ -498,7 +511,7 @@ export class ActionPreview { if (!this.action || this.settled || this.refining) return; const action = this.action; this.settled = true; - clearInterval(this.timer); + this.stopObserving(); this.applyButton.setAttribute('disabled', ''); this.el.dataset.state = 'applying'; this.el.setAttribute('aria-busy', 'true'); diff --git a/lib/agent-plugin/ui/conversation-db.ts b/lib/agent-plugin/ui/conversation-db.ts index 18ffee052..4a60b70bd 100644 --- a/lib/agent-plugin/ui/conversation-db.ts +++ b/lib/agent-plugin/ui/conversation-db.ts @@ -42,6 +42,10 @@ export function isConversationRecord(value: unknown): value is ConversationRecor ); } +function normalizeRecord({ id, title, createdAt, updatedAt, messages }: ConversationRecord): ConversationRecord { + return { id, title, createdAt, updatedAt, messages }; +} + function validateStored(value: unknown): StoredConversations | null { if (value === undefined) return null; if (typeof value !== 'object' || value === null) throw new Error('Saved conversation data is invalid'); @@ -60,13 +64,7 @@ function validateStored(value: unknown): StoredConversations | null { version: 1, revision: stored.revision, activeId: stored.activeId, - sessions: stored.sessions.map(({ id, title, createdAt, updatedAt, messages }) => ({ - id, - title, - createdAt, - updatedAt, - messages, - })), + sessions: stored.sessions.map(normalizeRecord), }; } @@ -77,15 +75,67 @@ export class ConversationConflictError extends Error { } } +interface ConversationIndex { + version: 2; + revision: number; + activeId: string; + sessionIds: string[]; +} + +function indexFor(data: StoredConversations): ConversationIndex { + return { + version: 2, + revision: data.revision, + activeId: data.activeId, + sessionIds: data.sessions.map((item) => item.id), + }; +} + +function validateIndex(value: unknown): ConversationIndex | null { + if (value === undefined) return null; + const index = value as ConversationIndex; + if ( + !index || + index.version !== 2 || + !Number.isSafeInteger(index.revision) || + index.revision < 1 || + !Array.isArray(index.sessionIds) || + !index.sessionIds.every(validConversationId) || + new Set(index.sessionIds).size !== index.sessionIds.length || + (index.sessionIds.length ? !index.sessionIds.includes(index.activeId) : index.activeId !== '') + ) + throw new Error('Saved conversation data is invalid'); + return index; +} + function openDatabase(): Promise { return new Promise((resolve, reject) => { if (typeof indexedDB === 'undefined') { reject(new Error('Conversation storage is unavailable')); return; } - const request = indexedDB.open(CONVERSATION_DB_NAME, 1); + const request = indexedDB.open(CONVERSATION_DB_NAME, 2); let abandoned = false; - request.onupgradeneeded = () => request.result.createObjectStore('conversations'); + request.onupgradeneeded = (event) => { + const db = request.result; + const transaction = request.transaction!; + if (event.oldVersion === 0) db.createObjectStore('conversations'); + const sessions = db.createObjectStore('sessions'); + const conversations = transaction.objectStore('conversations'); + const old = conversations.get('state'); + old.onsuccess = () => { + let stored: StoredConversations | null; + try { + stored = validateStored(old.result); + } catch { + // Keep damaged v1 data available for explicit erasure; load/save still reject it. + return; + } + if (!stored) return; + for (const item of stored.sessions) sessions.put(item, item.id); + conversations.put(indexFor(stored), 'state'); + }; + }; request.onblocked = () => { abandoned = true; reject(new Error('Close other tabs to open conversation storage')); @@ -102,8 +152,12 @@ function openDatabase(): Promise { }); } -/** One transaction compares the revision and commits the entire conversation index. */ +/** Session changes, deletions and the global revision/index commit in one transaction. */ export function createConversationRepository(): ConversationRepository { + // Exact serialized records avoid rereading/structured-cloning unchanged persisted messages. + // They are usable only after comparing the cached global revision in the transaction. + let cachedRevision: number | undefined; + let cachedSessions = new Map(); async function transact( data?: ConversationData, expectedRevision?: number, @@ -113,16 +167,23 @@ export function createConversationRepository(): ConversationRepository { return new Promise((resolve, reject) => { let result: StoredConversations | null = null; let failure: unknown; + let serialized = new Map(); let transaction: IDBTransaction; try { - transaction = db.transaction('conversations', data || clear ? 'readwrite' : 'readonly'); + transaction = db.transaction(['conversations', 'sessions'], data || clear ? 'readwrite' : 'readonly'); } catch (error) { db.close(); reject(error); return; } + const fail = (error: unknown) => { + failure = error; + transaction.abort(); + }; transaction.oncomplete = () => { db.close(); + cachedRevision = result?.revision ?? 0; + cachedSessions = serialized; resolve(result); }; transaction.onabort = () => { @@ -130,12 +191,12 @@ export function createConversationRepository(): ConversationRepository { reject(failure ?? transaction.error ?? new Error('Conversation storage transaction aborted')); }; const store = transaction.objectStore('conversations'); + const sessions = transaction.objectStore('sessions'); const request = store.get('state'); request.onsuccess = () => { try { if (clear) { - // Confirmed erasure must not depend on parsing potentially damaged messages. - // Read and replace in one transaction so another tab cannot slip in a write. + // Erasure also works for damaged legacy/index/messages, without decoding their contents. const previous = request.result?.revision; const revision = Number.isSafeInteger(previous) && previous >= 0 && previous < Number.MAX_SAFE_INTEGER @@ -144,20 +205,61 @@ export function createConversationRepository(): ConversationRepository { ? 1 : Date.now(); result = { version: 1, revision, activeId: '', sessions: [] }; - store.put(result, 'state'); + sessions.clear(); + store.put(indexFor(result), 'state'); return; } - result = validateStored(request.result); + const index = validateIndex(request.result); if (data) { - if ((result?.revision ?? 0) !== expectedRevision) throw new ConversationConflictError(); - result = { ...data, revision: (result?.revision ?? 0) + 1 }; - // Validate the host snapshot too; malformed input must not replace existing history. - validateStored(result); - store.put(result, 'state'); + if ((index?.revision ?? 0) !== expectedRevision) throw new ConversationConflictError(); + // Normalize host records too, so unrelated properties never enter saved history. + result = validateStored({ ...data, revision: (index?.revision ?? 0) + 1 })!; + serialized = new Map(result.sessions.map((item) => [item.id, JSON.stringify(item)])); + const nextIds = new Set(serialized.keys()); + for (const id of index?.sessionIds ?? []) if (!nextIds.has(id)) sessions.delete(id); + for (const item of result.sessions) { + const text = serialized.get(item.id)!; + if (cachedRevision === (index?.revision ?? 0)) { + if (cachedSessions.get(item.id) !== text) sessions.put(item, item.id); + } else if (!index?.sessionIds.includes(item.id)) { + sessions.put(item, item.id); + } else { + // A fresh repository may be used to save without loading. Compare existing records once. + const previous = sessions.get(item.id); + previous.onsuccess = () => { + try { + if (!isConversationRecord(previous.result) || previous.result.id !== item.id) + throw new Error('Saved conversation data is invalid'); + if (JSON.stringify(normalizeRecord(previous.result)) !== text) sessions.put(item, item.id); + } catch (error) { + fail(error); + } + }; + } + } + store.put(indexFor(result), 'state'); + } else if (index) { + result = { version: 1, revision: index.revision, activeId: index.activeId, sessions: [] }; + const records = new Map(); + for (const id of index.sessionIds) { + const record = sessions.get(id); + record.onsuccess = () => { + try { + if (!isConversationRecord(record.result) || record.result.id !== id) + throw new Error('Saved conversation data is invalid'); + const item = normalizeRecord(record.result); + records.set(id, item); + serialized.set(id, JSON.stringify(item)); + if (records.size === index.sessionIds.length) + result!.sessions = index.sessionIds.map((sessionId) => records.get(sessionId)!); + } catch (error) { + fail(error); + } + }; + } } } catch (error) { - failure = error; - transaction.abort(); + fail(error); } }; }); diff --git a/lib/agent-plugin/ui/model-cache-settings.ts b/lib/agent-plugin/ui/model-cache-settings.ts new file mode 100644 index 000000000..9901ea950 --- /dev/null +++ b/lib/agent-plugin/ui/model-cache-settings.ts @@ -0,0 +1,255 @@ +import { Div, Span, View } from 'ranui/builder'; +import { t } from '@ranuts/shared/i18n'; +import { LocalInferenceProvider } from '@ranuts/agent-core/llm/local'; +import type { LocalLLMProvider } from '@ranuts/agent-core/llm/types'; +import { isModelCached, WEBLLM_MODELS, RETIRED_WEBLLM_MODELS } from '@ranuts/agent-core/llm/webllm'; +import { + deleteCachedModel, + listCachedGGUF, + deleteCachedGGUF, + rememberedModelSources, +} from '@ranuts/agent-core/llm/model-cache'; +import { displayError } from './presentation'; + +type ValueEl = HTMLElement & { value: string }; +const ranButton = (text: string, className: string): HTMLElement => + View('r-button').class(className).text(text).build(); + +/** Model inventory, use/delete confirmation and memory release presentation. */ +export function createModelCacheSettings(options: { + providerSelect: ValueEl; + modelSelect: ValueEl; + localModelId: ValueEl; + localModelUrl: ValueEl; + localModelLib: ValueEl; + ggufUrl: ValueEl; + note: HTMLElement; + selectedLocalModel: () => string; + currentProvider: () => string; + getProvider: () => LocalLLMProvider | null; + isBusy: () => boolean; + setBusy: (busy: boolean) => void; + resetController: () => void; + cleanup: () => Promise; + cleanupFailed: () => boolean; + loadModel: () => Promise; + chat: { refreshSendAvailability: () => void }; +}) { + const { + providerSelect, + modelSelect, + localModelId, + localModelUrl, + localModelLib, + ggufUrl, + note, + selectedLocalModel, + currentProvider, + getProvider, + isBusy, + setBusy, + resetController, + cleanup, + cleanupFailed, + loadModel, + chat, + } = options; + const cacheList = View('details').class('agent-model-cache agent-disclosure').build(); + const cacheSummary = View('summary') + .attr('tabindex', '0') + .text(t('agentDownloadedModels').replace('{count}', '…')) + .build(); + const cacheRows = Div().class('agent-model-cache-rows').build(); + const cacheStatus = Span().attr('role', 'status').build(); + cacheList.append(cacheSummary, cacheRows, cacheStatus); + let cacheRevision = 0; + const refreshCache = async () => { + const revision = ++cacheRevision; + const sources = [...WEBLLM_MODELS, ...RETIRED_WEBLLM_MODELS, ...rememberedModelSources()]; + const unique = sources.filter( + (model, index) => + sources.findIndex( + (item) => + item.id === model.id && + ('modelUrl' in item ? item.modelUrl : undefined) === ('modelUrl' in model ? model.modelUrl : undefined), + ) === index, + ); + const cached = await Promise.all( + unique.map(async (model) => ({ model, cached: await isModelCached(model.id, 'modelUrl' in model ? model : {}) })), + ); + + let ggufs: Awaited> = []; + try { + ggufs = await listCachedGGUF(); + } catch { + cacheStatus.textContent = t('agentCacheUnavailable'); + } + if (revision !== cacheRevision) return; + cacheSummary.textContent = t('agentDownloadedModels').replace( + '{count}', + String(cached.filter((item) => item.cached).length + ggufs.length), + ); + cacheRows.replaceChildren(); + for (const { model, cached: exists } of cached) { + if (!exists) continue; + const row = Div().class('agent-model-cache-row').attr('role', 'group').build(); + const provider = getProvider(); + const active = + !!provider?.isReady() && + selectedLocalModel() === model.id && + !(provider instanceof LocalInferenceProvider && provider.backend === 'wllama'); + const name = Span() + .text(model.label + (active ? ' · ' + t('agentModelInUse') : '')) + .build(); + const use = ranButton(t(active ? 'agentModelInUse' : 'agentUseModel'), 'agent-cache-use'); + use.toggleAttribute('disabled', active); + use.addEventListener('click', () => { + if (isBusy() || active) return; + providerSelect.value = 'webllm'; + providerSelect.dispatchEvent(new Event('change')); + if (WEBLLM_MODELS.some((item) => item.id === model.id)) { + localModelId.value = ''; + localModelUrl.value = ''; + localModelLib.value = ''; + localModelId.dispatchEvent(new Event('change')); + modelSelect.value = model.id; + modelSelect.dispatchEvent(new Event('change')); + } else { + localModelId.value = model.id; + localModelUrl.value = 'modelUrl' in model ? String(model.modelUrl ?? '') : ''; + localModelLib.value = 'modelLibUrl' in model ? String(model.modelLibUrl ?? '') : ''; + localModelId.dispatchEvent(new Event('change')); + } + void loadModel(); + }); + const remove = ranButton(t('agentDeleteDownload'), 'agent-cache-delete'); + let confirming = false; + const cancel = ranButton(t('agentPlanCancel'), 'agent-cache-cancel'); + cancel.hidden = true; + cancel.addEventListener('click', () => { + confirming = false; + cancel.hidden = true; + remove.textContent = t('agentDeleteDownload'); + remove.focus(); + }); + remove.addEventListener('click', () => { + if (isBusy()) return; + if (!confirming) { + confirming = true; + remove.textContent = t('agentConfirmDeleteDownload'); + cancel.hidden = false; + cacheStatus.textContent = t('agentDeleteDownloadHint'); + return; + } + void (async () => { + remove.setAttribute('disabled', ''); + setBusy(true); + chat.refreshSendAvailability(); + try { + if (selectedLocalModel() === model.id) { + resetController(); + await cleanup(); + if (cleanupFailed()) throw new Error(t('agentModelCleanupFailed')); + } + await deleteCachedModel(model.id, 'modelUrl' in model ? model : {}); + cacheStatus.textContent = t('agentDownloadDeleted'); + await refreshCache(); + cacheSummary.focus(); + } catch { + cacheStatus.textContent = t(cleanupFailed() ? 'agentModelCleanupFailed' : 'agentCacheDeleteFailed'); + remove.removeAttribute('disabled'); + } finally { + setBusy(false); + chat.refreshSendAvailability(); + } + })(); + }); + row.setAttribute('aria-label', name.textContent ?? ''); + row.append(name); + if (!RETIRED_WEBLLM_MODELS.some((item) => item.id === model.id)) row.append(use); + row.append(remove, cancel); + cacheRows.append(row); + } + for (const item of ggufs) { + const active = currentProvider() === 'wllama' && !!getProvider()?.isReady() && ggufUrl.value.trim() === item.url; + const row = Div().class('agent-model-cache-row').attr('role', 'group').build(); + const name = Span() + .text( + `${new URL(item.url).pathname.split('/').pop() ?? item.name} · ${(item.size / 1e9).toFixed(2)} GB${item.complete ? '' : ' · ' + t('agentDownloadIncomplete')}`, + ) + .build(); + const use = ranButton(t(active ? 'agentModelInUse' : 'agentUseModel'), 'agent-cache-use'); + use.toggleAttribute('disabled', active); + use.addEventListener('click', () => { + if (isBusy() || active) return; + providerSelect.value = 'wllama'; + providerSelect.dispatchEvent(new Event('change')); + ggufUrl.value = item.url; + ggufUrl.dispatchEvent(new Event('change')); + void loadModel(); + }); + const remove = ranButton(t('agentDeleteDownload'), 'agent-cache-delete'); + const cancel = ranButton(t('agentPlanCancel'), 'agent-cache-cancel'); + cancel.hidden = true; + let confirming = false; + cancel.addEventListener('click', () => { + confirming = false; + cancel.hidden = true; + remove.textContent = t('agentDeleteDownload'); + remove.focus(); + }); + remove.addEventListener('click', () => { + if (isBusy()) return; + if (!confirming) { + confirming = true; + remove.textContent = t('agentConfirmDeleteDownload'); + cacheStatus.textContent = t('agentDeleteDownloadHint'); + cancel.hidden = false; + return; + } + remove.setAttribute('disabled', ''); + setBusy(true); + chat.refreshSendAvailability(); + void (async () => { + try { + // Automatic CPU fallback may own this GGUF even when the GPU picker is selected. + resetController(); + await cleanup(); + if (cleanupFailed()) throw new Error(t('agentModelCleanupFailed')); + await deleteCachedGGUF(item.name); + await refreshCache(); + cacheStatus.textContent = t('agentDownloadDeleted'); + cacheSummary.focus(); + } catch { + cacheStatus.textContent = t(cleanupFailed() ? 'agentModelCleanupFailed' : 'agentCacheDeleteFailed'); + remove.removeAttribute('disabled'); + } finally { + setBusy(false); + chat.refreshSendAvailability(); + } + })(); + }); + row.setAttribute('aria-label', name.textContent ?? ''); + row.append(name, use, remove, cancel); + cacheRows.append(row); + } + }; + cacheList.addEventListener('toggle', () => { + if (cacheList.open) void refreshCache(); + }); + const release = ranButton(t('agentReleaseMemory'), 'agent-release-memory'); + release.hidden = true; + release.addEventListener('click', () => { + if (isBusy()) return; + resetController(); + void (async () => { + try { + await cleanup(); + note.textContent = t('agentMemoryReleased'); + } catch (error) { + note.textContent = displayError(error); + } + })(); + }); + return { el: cacheList, release, refresh: refreshCache }; +} diff --git a/lib/agent-plugin/ui/panel-context.ts b/lib/agent-plugin/ui/panel-context.ts new file mode 100644 index 000000000..66439dd2b --- /dev/null +++ b/lib/agent-plugin/ui/panel-context.ts @@ -0,0 +1,189 @@ +import { getEditorApi, type EditorApi } from '../editor-bridge'; +import { captureComposerContext } from './composer-context'; +import { t } from '@ranuts/shared/i18n'; + +// SDK events verified in the bundled Word, spreadsheet and slide runtimes. +const CONTEXT_EVENTS = [ + 'asc_onSelectionChanged', + 'asc_onSelectionEnd', + 'asc_onFocusObject', + 'asc_onCurrentPage', + 'asc_onEditorSelectionChanged', + 'asc_onActiveSheetChanged', + 'asc_onDocumentChanged', + 'asc_onDocumentModifiedChanged', + 'asc_onUserActionEnd', + 'asc_onSheetsChanged', + 'asc_onModifiedDocument', + 'asc_onUndoRedo', + 'asc_onChangeRestrictions', + 'asc_onChangeViewMode', + 'asc_onChangeDocumentProtection', + 'asc_onChangeProtectWorkbook', + 'asc_onChangeProtectWorksheet', + 'asc_onChangeDisplayModeInReview', + 'asc_onOnTrackRevisionsChange', +]; +const INPUT_EVENTS = [ + 'selectionchange', + 'input', + 'pointerdown', + 'pointerup', + 'keydown', + 'keyup', + 'paste', + 'cut', + 'drop', +]; + +/** Own event-driven context refreshes and SDK subscriptions for one panel lifecycle. */ +export function mountPanelContext(options: { + isActive: () => boolean; + refresh: () => void; + getApi?: () => EditorApi | null; +}): { update: () => void; dispose: () => void } { + let pending: ReturnType | undefined; + let api: EditorApi | null = null; + let editorDocument: Document | null = null; + let suspended = false; + let disposed = false; + const active = () => !disposed && !suspended && document.visibilityState !== 'hidden' && options.isActive(); + const scheduleRefresh = () => { + if (active()) pending ??= setTimeout(refresh, 16); + }; + const detach = () => { + for (const name of CONTEXT_EVENTS) { + try { + api?.asc_unregisterCallback?.(name, scheduleRefresh); + } catch { + // A replaced SDK may already have destroyed its callback registry. + } + } + api = null; + for (const name of INPUT_EVENTS) editorDocument?.removeEventListener(name, scheduleRefresh, true); + editorDocument = null; + }; + const stop = () => { + clearTimeout(pending); + pending = undefined; + detach(); + }; + const refresh = () => { + clearTimeout(pending); + pending = undefined; + if (!active()) { + stop(); + return; + } + let next: EditorApi | null = null; + try { + next = (options.getApi ?? getEditorApi)(); + } catch { + // Context is optional while the editor frame is loading/replaced. + } + if (next !== api) { + detach(); + api = next; + if (typeof next?.asc_registerCallback === 'function' && typeof next.asc_unregisterCallback === 'function') { + for (const name of CONTEXT_EVENTS) { + try { + next.asc_registerCallback(name, scheduleRefresh); + } catch { + // Some formats omit notifications; editor input events still refresh context. + } + } + } + } + // Native input is also needed for interaction-based proposal invalidation, + // and provides a fallback for SDKs without disposable callbacks. + if (api && !editorDocument) { + for (const frame of document.querySelectorAll('iframe')) { + try { + const win = frame.contentWindow as (Window & { editor?: EditorApi; Asc?: { editor?: EditorApi } }) | null; + if ((win?.editor ?? win?.Asc?.editor) !== api || !frame.contentDocument) continue; + editorDocument = frame.contentDocument; + for (const name of INPUT_EVENTS) editorDocument.addEventListener(name, scheduleRefresh, true); + break; + } catch { + // Ignore inaccessible frames. + } + } + } + try { + options.refresh(); + } catch { + // A transient SDK read must not interrupt open/disable or visibility updates. + } + }; + const update = () => { + if (!active()) { + stop(); + return; + } + refresh(); + }; + const onDocument = () => { + detach(); + update(); + }; + const onHide = (event: PageTransitionEvent) => { + suspended = true; + stop(); + if (!event.persisted) dispose(); + }; + const onShow = (event: PageTransitionEvent) => { + if (!event.persisted) return; + suspended = false; + update(); + }; + const dispose = () => { + disposed = true; + stop(); + document.removeEventListener('visibilitychange', update); + window.removeEventListener('document:content-ready', onDocument); + window.removeEventListener('pagehide', onHide); + window.removeEventListener('pageshow', onShow); + }; + document.addEventListener('visibilitychange', update); + window.addEventListener('document:content-ready', onDocument); + window.addEventListener('pagehide', onHide); + window.addEventListener('pageshow', onShow); + update(); + return { update, dispose }; +} + +/** Selection chips and the empty-composer hint share one bounded SDK snapshot. */ +export function syncPanelContext(options: { + quoteContext: HTMLElement; + quoteSummary: HTMLElement; + quoteText: HTMLElement; + welcomeHint: HTMLElement; + settings: HTMLElement; + ready: () => boolean; +}): void { + const snapshot = captureComposerContext(); + const context = snapshot.context; + options.quoteContext.hidden = !context || !snapshot.text.trim(); + const label = + context?.kind === 'cell' + ? t('agentUsingSelection', { scope: `${context.sheet ?? 'Excel'} · ${context.range ?? ''}` }) + : context?.kind === 'slide' + ? t('agentSlideContext').replace('{page}', String(context.page ?? '')) + : snapshot.text + ? t('agentSelectionContext').replace('{count}', String(snapshot.text.length)) + : t('agentCurrentDocument'); + if (options.quoteSummary.textContent !== label) options.quoteSummary.textContent = label; + options.quoteSummary.setAttribute('aria-label', `${t('agentReadSelection')} · ${label}`); + if (options.quoteText.textContent !== snapshot.text) options.quoteText.textContent = snapshot.text; + const release = options.settings.querySelector('.agent-release-memory'); + if (release) release.hidden = !options.ready(); + const welcome = + context?.kind === 'cell' + ? 'agentWelcomeCell' + : context?.kind === 'pdf' + ? 'agentWelcomePdf' + : context?.kind === 'slide' + ? 'agentWelcomeSlide' + : 'agentWelcomeWord'; + if (options.welcomeHint.textContent !== t(welcome)) options.welcomeHint.textContent = t(welcome); +} diff --git a/lib/agent-plugin/ui/panel.ts b/lib/agent-plugin/ui/panel.ts index 4c092ee9c..b89a73d21 100644 --- a/lib/agent-plugin/ui/panel.ts +++ b/lib/agent-plugin/ui/panel.ts @@ -5,15 +5,15 @@ import { generateReadAnswer } from '../document-read-answer'; import type { DocumentAgentToolOptions } from '../document-agent-tools'; import { mountSpeechInput } from './speech-input'; /** - * Agent sidebar panel — a thin DOM view over {@link AgentChatController}. + * Assistant sidebar product orchestration over {@link AgentChatController}. * * Header (title + gear + close); a collapsible settings block (provider selector * → automatic local inference or manually imported GGUF models), hidden behind the gear so the * 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). All - * orchestration lives in the controller and the LLM factory; this file only - * builds DOM and forwards events. Loaded after an explicit product opt-in. + * 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. */ import 'ranui/button'; import 'ranui/input'; @@ -23,19 +23,17 @@ import { ButtonBuilder, Div, Label, Span, View, createEffect, signal } from 'ran import { localStorageGetItem, localStorageSetItem } from 'ranuts/utils'; import { getLanguage, type I18nMessages, t } from '@ranuts/shared/i18n'; import { getReadonlyMode } from '../../onlyoffice/readonly'; -import { getEditorApi, type EditorApi } from '../editor-bridge'; +import { getEditorApi } from '../editor-bridge'; +import { mountReviewSettings } from './review-settings'; +import { createModelCacheSettings } from './model-cache-settings'; +import { mountPanelContext, syncPanelContext } from './panel-context'; import { agentTools } from '../tools'; import { parseDirectDocumentIntent } from '../direct-intent'; import { captureDocumentContext, resolveContextCommand } from '../document-context'; import type { LLMMessage, LLMProvider } 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, - RETIRED_WEBLLM_MODELS, - WEBLLM_MODELS, -} from '@ranuts/agent-core/llm/webllm'; +import { DEFAULT_WEBLLM_MODEL, 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'; @@ -89,13 +87,7 @@ import { captureComposerContext } from './composer-context'; import { captureRequestContext } from './request-context'; import { classifyRequest, resolveRequestIntent } from './request-intent'; import { generateDocumentDraft } from './document-draft'; -import { - deleteCachedModel, - listCachedGGUF, - deleteCachedGGUF, - rememberedModelSources, - rememberModelSource, -} from '@ranuts/agent-core/llm/model-cache'; +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 }; @@ -109,9 +101,6 @@ function rememberedModelUrl(value: string | null): string { } } -/** The r-checkbox `change` event detail (a real boolean). */ -type CheckedDetail = CustomEvent<{ checked: boolean }>; - function compactSelect( className: string, options: Array<{ value: string; label: string }>, @@ -223,16 +212,14 @@ export function createAgentPanel( sidebar.dispose(); sidebar = mountSidebar(); syncSidebar(); - clearInterval(scopeTimer); - scopeTimer = setInterval(syncContext, 1000); - syncContext(); - syncReviewControl(); + reviewSettings.sync(); syncProviderUi(); syncRuntimeStatus(); }); window.addEventListener('pagehide', () => disposeResize()); - let open = true; + let contextLifecycle: ReturnType | undefined; + let open = !options.background; let enabled = true; let hasConfiguration = false; let viewRevision = 0; @@ -251,6 +238,7 @@ export function createAgentPanel( if (next && !open && hasConfiguration) showView('chat'); const returnToDocument = !next && panel.contains(document.activeElement); open = next; + contextLifecycle?.update(); panel.classList.toggle('agent-panel-hidden', !next); syncSidebar(); // Dock mode: shrink the editor so the panel takes layout space instead of @@ -282,6 +270,7 @@ export function createAgentPanel( } } enabled = next; + contextLifecycle?.update(); if (!next) { resetController(); disconnectEndpoint(); @@ -853,53 +842,17 @@ export function createAgentPanel( sessionSelect.className = 'agent-session-select'; const sessionRow = Div().class('agent-session-row').children(sessionSelect, clearBtn).build(); const sessionBar = Div().class('agent-session-bar').children(sessionRow).build(); - const scopeLabel = Span().class('agent-document-scope').build(); - scopeLabel.setAttribute('role', 'note'); - - const updateScope = (): void => { - const context = captureDocumentContext(); - const label = context - ? context.kind === 'cell' - ? `Excel · ${context.sheet ?? ''} ${context.range ?? ''}` - : context.kind === 'slide' - ? `PPT · ${context.page ?? ''}` - : context.kind === 'pdf' - ? 'PDF' - : 'DOCX' - : ''; - if (scopeLabel.textContent !== label) scopeLabel.textContent = label; - }; const syncContext = () => { - updateScope(); - const snapshot = captureComposerContext(); - const context = snapshot.context; - quoteContext.hidden = !context || !snapshot.text.trim(); - const label = - context?.kind === 'cell' - ? t('agentUsingSelection', { scope: `${context.sheet ?? 'Excel'} · ${context.range ?? ''}` }) - : context?.kind === 'slide' - ? `${t('agentSlideContext').replace('{page}', String(context.page ?? ''))}` - : snapshot.text - ? t('agentSelectionContext').replace('{count}', String(snapshot.text.length)) - : t('agentCurrentDocument'); - if (quoteSummary.textContent !== label) quoteSummary.textContent = label; - quoteSummary.setAttribute('aria-label', `${t('agentReadSelection')} · ${label}`); - if (quoteText.textContent !== snapshot.text) quoteText.textContent = snapshot.text; - const releaseControl = settings.querySelector('.agent-release-memory'); - if (releaseControl) releaseControl.hidden = !webllmProvider?.isReady(); - const welcome = - context?.kind === 'cell' - ? 'agentWelcomeCell' - : context?.kind === 'pdf' - ? 'agentWelcomePdf' - : context?.kind === 'slide' - ? 'agentWelcomeSlide' - : 'agentWelcomeWord'; - if (welcomeHint.textContent !== t(welcome)) welcomeHint.textContent = t(welcome); + if (!open || !enabled || document.visibilityState === 'hidden') return; + syncPanelContext({ + quoteContext, + quoteSummary, + quoteText, + welcomeHint, + settings, + ready: () => !!webllmProvider?.isReady(), + }); }; - let scopeTimer = setInterval(syncContext, 1000); - window.addEventListener('pagehide', () => clearInterval(scopeTimer)); - syncContext(); let historyControls: ReturnType | undefined; const conversations = createConversationStore( () => { @@ -935,7 +888,7 @@ export function createAgentPanel( }; // Restore a previous conversation into the view on load. - for (const turn of historyToTurns(historyStorage.load())) chat.append(turn); + chat.appendMany(historyToTurns(historyStorage.load())); // ── Controller wiring ─────────────────────────────────────────────────── const currentProvider = (): 'webllm' | 'wllama' => @@ -1168,6 +1121,8 @@ export function createAgentPanel( }; function syncRuntimeStatus(): void { const localReady = !!webllmProvider?.isReady(); + const release = settings.querySelector('.agent-release-memory'); + if (release) release.hidden = !localReady; for (const button of [loadBtn, ggufLoad]) { button.textContent = t(modelLoading ? 'agentPreparing' : localReady ? 'agentModelInUse' : 'agentLoadModel'); button.toggleAttribute('disabled', modelLoading || localReady || !enabled || cacheBusy); @@ -2374,7 +2329,7 @@ export function createAgentPanel( chat.clear(); chat.setRunning(false); chat.setInput(''); - for (const turn of historyToTurns(historyStorage.load())) chat.append(turn); + chat.appendMany(historyToTurns(historyStorage.load())); renderSessions(); if (focus) chat.focus(); }; @@ -2403,65 +2358,7 @@ export function createAgentPanel( }); settings.append(historyControls.el); - // Review-mode toggle reads/sets track-changes directly on the editor. r-checkbox - // reports the new state via the change event's detail (a real boolean), and its - // initial state is set through the `checked` attribute. - // Persistent track-changes is Word-only; detect the runtime settings APIs. - let syncingReview = false; - let reviewApi: EditorApi | undefined; - const detachReview = (): void => { - reviewApi?.asc_unregisterCallback?.('asc_onOnTrackRevisionsChange', syncReviewControl); - reviewApi = undefined; - }; - const syncReviewControl = (): void => { - const api = getEditorApi(); - if (api !== reviewApi) { - detachReview(); - if ( - api?.isDocumentLoadComplete && - api.isLoadFullApi && - typeof api.asc_registerCallback === 'function' && - typeof api.asc_unregisterCallback === 'function' - ) { - reviewApi = api; - api.asc_registerCallback('asc_onOnTrackRevisionsChange', syncReviewControl); - } - } - const canReview = - !!api && - captureDocumentContext()?.kind === 'word' && - api.isDocumentLoadComplete && - api.isLoadFullApi && - !api.isViewMode && - !getReadonlyMode() && - typeof api.asc_IsTrackRevisions === 'function' && - typeof api.asc_SetGlobalTrackRevisions === 'function' && - typeof api.asc_GetGlobalTrackRevisions === 'function' && - typeof api.asc_SetLocalTrackRevisions === 'function'; - reviewCheck.toggleAttribute('disabled', !canReview); - reviewLabel.hidden = !canReview; - const checked = String(typeof api?.asc_IsTrackRevisions === 'function' && !!api.asc_IsTrackRevisions()); - syncingReview = true; - try { - if (reviewCheck.getAttribute('checked') !== checked) reviewCheck.setAttribute('checked', checked); - } finally { - syncingReview = false; - } - }; - syncReviewControl(); - window.addEventListener('document:content-ready', syncReviewControl); - window.addEventListener('pagehide', detachReview); - reviewCheck.addEventListener('change', async (e: Event) => { - if (syncingReview) return; - try { - await agentTools.set_review_mode.execute({ enabled: (e as CheckedDetail).detail.checked }); - } catch (error) { - appendTurn({ role: 'error', text: displayError(error) }); - } finally { - syncReviewControl(); - } - }); - + const reviewSettings = mountReviewSettings({ reviewCheck, reviewLabel, appendTurn }); // Reactive labels: a `lang` signal bumped on languagechange drives one effect // that re-applies every translatable label — replacing a manual re-render pass. const [lang, setLang] = signal(getLanguage()); @@ -2512,6 +2409,7 @@ export function createAgentPanel( languageSelect.setAttribute('aria-label', t('agentTaskLanguage')); chat.setLabels(chatLabels()); // Send/Stop/placeholder/empty + role chips syncProviderUi(); // refresh key placeholder / model hint in the new language + contextLifecycle?.update(); }); const historyBtn = document.createElement('button'); @@ -2592,203 +2490,32 @@ export function createAgentPanel( syncRuntimeStatus(); }; sourcePicker.addEventListener('change', syncSource); - const cacheList = View('details').class('agent-model-cache agent-disclosure').build(); - const cacheSummary = View('summary') - .attr('tabindex', '0') - .text(t('agentDownloadedModels').replace('{count}', '…')) - .build(); - const cacheRows = Div().class('agent-model-cache-rows').build(); - const cacheStatus = Span().attr('role', 'status').build(); - cacheList.append(cacheSummary, cacheRows, cacheStatus); - let cacheRevision = 0; - const refreshCache = async () => { - const revision = ++cacheRevision; - const sources = [...WEBLLM_MODELS, ...RETIRED_WEBLLM_MODELS, ...rememberedModelSources()]; - const unique = sources.filter( - (model, index) => - sources.findIndex( - (item) => - item.id === model.id && - ('modelUrl' in item ? item.modelUrl : undefined) === ('modelUrl' in model ? model.modelUrl : undefined), - ) === index, - ); - const cached = await Promise.all( - unique.map(async (model) => ({ model, cached: await isModelCached(model.id, 'modelUrl' in model ? model : {}) })), - ); - - let ggufs: Awaited> = []; - try { - ggufs = await listCachedGGUF(); - } catch { - cacheStatus.textContent = t('agentCacheUnavailable'); - } - if (revision !== cacheRevision) return; - cacheSummary.textContent = t('agentDownloadedModels').replace( - '{count}', - String(cached.filter((item) => item.cached).length + ggufs.length), - ); - cacheRows.replaceChildren(); - for (const { model, cached: exists } of cached) { - if (!exists) continue; - const row = Div().class('agent-model-cache-row').attr('role', 'group').build(); - const active = - !!webllmProvider?.isReady() && - selectedLocalModel() === model.id && - !(webllmProvider instanceof LocalInferenceProvider && webllmProvider.backend === 'wllama'); - const name = Span() - .text(model.label + (active ? ' · ' + t('agentModelInUse') : '')) - .build(); - const use = ranButton(t(active ? 'agentModelInUse' : 'agentUseModel'), 'agent-cache-use'); - use.toggleAttribute('disabled', active); - use.addEventListener('click', () => { - if (cacheBusy || active) return; - providerSelect.value = 'webllm'; - providerSelect.dispatchEvent(new Event('change')); - if (WEBLLM_MODELS.some((item) => item.id === model.id)) { - localModelId.value = ''; - localModelUrl.value = ''; - localModelLib.value = ''; - localModelId.dispatchEvent(new Event('change')); - modelSelect.value = model.id; - modelSelect.dispatchEvent(new Event('change')); - } else { - localModelId.value = model.id; - localModelUrl.value = 'modelUrl' in model ? String(model.modelUrl ?? '') : ''; - localModelLib.value = 'modelLibUrl' in model ? String(model.modelLibUrl ?? '') : ''; - localModelId.dispatchEvent(new Event('change')); - } - void loadModel(); - }); - const remove = ranButton(t('agentDeleteDownload'), 'agent-cache-delete'); - let confirming = false; - const cancel = ranButton(t('agentPlanCancel'), 'agent-cache-cancel'); - cancel.hidden = true; - cancel.addEventListener('click', () => { - confirming = false; - cancel.hidden = true; - remove.textContent = t('agentDeleteDownload'); - remove.focus(); - }); - remove.addEventListener('click', () => { - if (cacheBusy) return; - if (!confirming) { - confirming = true; - remove.textContent = t('agentConfirmDeleteDownload'); - cancel.hidden = false; - cacheStatus.textContent = t('agentDeleteDownloadHint'); - return; - } - void (async () => { - remove.setAttribute('disabled', ''); - cacheBusy = true; - chat.refreshSendAvailability(); - try { - if (selectedLocalModel() === model.id) { - resetController(); - if (runtimeCleanup) await runtimeCleanup; - if (runtimeCleanupFailed) throw new Error(t('agentModelCleanupFailed')); - } - await deleteCachedModel(model.id, 'modelUrl' in model ? model : {}); - cacheStatus.textContent = t('agentDownloadDeleted'); - await refreshCache(); - cacheSummary.focus(); - } catch { - cacheStatus.textContent = t(runtimeCleanupFailed ? 'agentModelCleanupFailed' : 'agentCacheDeleteFailed'); - remove.removeAttribute('disabled'); - } finally { - cacheBusy = false; - chat.refreshSendAvailability(); - } - })(); - }); - row.setAttribute('aria-label', name.textContent ?? ''); - row.append(name); - if (!RETIRED_WEBLLM_MODELS.some((item) => item.id === model.id)) row.append(use); - row.append(remove, cancel); - cacheRows.append(row); - } - for (const item of ggufs) { - const active = currentProvider() === 'wllama' && !!webllmProvider?.isReady() && ggufUrl.value.trim() === item.url; - const row = Div().class('agent-model-cache-row').attr('role', 'group').build(); - const name = Span() - .text( - `${new URL(item.url).pathname.split('/').pop() ?? item.name} · ${(item.size / 1e9).toFixed(2)} GB${item.complete ? '' : ' · ' + t('agentDownloadIncomplete')}`, - ) - .build(); - const use = ranButton(t(active ? 'agentModelInUse' : 'agentUseModel'), 'agent-cache-use'); - use.toggleAttribute('disabled', active); - use.addEventListener('click', () => { - if (cacheBusy || active) return; - providerSelect.value = 'wllama'; - providerSelect.dispatchEvent(new Event('change')); - ggufUrl.value = item.url; - ggufUrl.dispatchEvent(new Event('change')); - void loadModel(); - }); - const remove = ranButton(t('agentDeleteDownload'), 'agent-cache-delete'); - const cancel = ranButton(t('agentPlanCancel'), 'agent-cache-cancel'); - cancel.hidden = true; - let confirming = false; - cancel.addEventListener('click', () => { - confirming = false; - cancel.hidden = true; - remove.textContent = t('agentDeleteDownload'); - remove.focus(); - }); - remove.addEventListener('click', () => { - if (cacheBusy) return; - if (!confirming) { - confirming = true; - remove.textContent = t('agentConfirmDeleteDownload'); - cacheStatus.textContent = t('agentDeleteDownloadHint'); - cancel.hidden = false; - return; - } - remove.setAttribute('disabled', ''); - cacheBusy = true; - chat.refreshSendAvailability(); - void (async () => { - try { - // Automatic CPU fallback may own this GGUF even when the GPU picker is selected. - resetController(); - if (runtimeCleanup) await runtimeCleanup; - if (runtimeCleanupFailed) throw new Error(t('agentModelCleanupFailed')); - await deleteCachedGGUF(item.name); - await refreshCache(); - cacheStatus.textContent = t('agentDownloadDeleted'); - cacheSummary.focus(); - } catch { - cacheStatus.textContent = t(runtimeCleanupFailed ? 'agentModelCleanupFailed' : 'agentCacheDeleteFailed'); - remove.removeAttribute('disabled'); - } finally { - cacheBusy = false; - chat.refreshSendAvailability(); - } - })(); - }); - row.setAttribute('aria-label', name.textContent ?? ''); - row.append(name, use, remove, cancel); - cacheRows.append(row); - } - }; - cacheList.addEventListener('toggle', () => { - if (cacheList.open) void refreshCache(); - }); - const release = ranButton(t('agentReleaseMemory'), 'agent-release-memory'); - release.hidden = true; - release.addEventListener('click', () => { - if (cacheBusy) return; - resetController(); - void (async () => { - try { - if (runtimeCleanup) await runtimeCleanup; - note.textContent = t('agentMemoryReleased'); - } catch (error) { - note.textContent = displayError(error); - } - })(); + const modelCacheSettings = createModelCacheSettings({ + providerSelect, + modelSelect, + localModelId, + localModelUrl, + localModelLib, + ggufUrl, + note, + selectedLocalModel, + currentProvider, + getProvider: () => webllmProvider, + isBusy: () => cacheBusy, + setBusy: (busy) => { + cacheBusy = busy; + }, + resetController, + cleanup: async () => { + if (runtimeCleanup) await runtimeCleanup; + }, + cleanupFailed: () => runtimeCleanupFailed, + loadModel, + chat, }); - modelActions.append(release); + const cacheList = modelCacheSettings.el; + const refreshCache = modelCacheSettings.refresh; + modelActions.append(modelCacheSettings.release); syncSource(); void refreshCache(); settings.replaceChildren(settingsHero, sourcePicker, localCard, serviceCard, cacheList, note, advanced); @@ -2870,6 +2597,10 @@ export function createAgentPanel( hasConfiguration = !!(endpoint && (!isCloudEndpointKind(endpoint.kind) || getEndpointKey(endpointKeySlot(endpoint.kind)))) || (currentProvider() === 'webllm' ? WEBLLM_MODELS.some((model) => model.id === savedPreset) : !!ggufUrl.value.trim()); + contextLifecycle = mountPanelContext({ + isActive: () => panel.isConnected && open && enabled, + refresh: syncContext, + }); setOpen(!options.background); void historyControls.start(); showView('chat'); diff --git a/lib/agent-plugin/ui/review-settings.ts b/lib/agent-plugin/ui/review-settings.ts new file mode 100644 index 000000000..abed1daaa --- /dev/null +++ b/lib/agent-plugin/ui/review-settings.ts @@ -0,0 +1,97 @@ +import { getEditorApi, type EditorApi } from '../editor-bridge'; +import { captureDocumentContext } from '../document-context'; +import { getReadonlyMode } from '../../onlyoffice/readonly'; +import { agentTools } from '../tools'; +import { displayError } from './presentation'; +import type { ChatTurn } from './controller'; +type CheckedDetail = CustomEvent<{ checked: boolean }>; + +/** Own Word review settings and their native tracking-change subscription. */ +export function mountReviewSettings(options: { + reviewCheck: HTMLElement; + reviewLabel: HTMLElement; + appendTurn: (turn: ChatTurn) => void; +}): { sync: () => void; dispose: () => void } { + const { reviewCheck, reviewLabel, appendTurn } = options; + // Review-mode toggle reads/sets track-changes directly on the editor. r-checkbox + // reports the new state via the change event's detail (a real boolean), and its + // initial state is set through the `checked` attribute. + // Persistent track-changes is Word-only; detect the runtime settings APIs. + let syncingReview = false; + let suspended = false; + let disposed = false; + let reviewApi: EditorApi | undefined; + const detachReview = (): void => { + reviewApi?.asc_unregisterCallback?.('asc_onOnTrackRevisionsChange', syncReviewControl); + reviewApi = undefined; + }; + const syncReviewControl = (): void => { + if (disposed || suspended) return; + const api = getEditorApi(); + if (api !== reviewApi) { + detachReview(); + if ( + api?.isDocumentLoadComplete && + api.isLoadFullApi && + typeof api.asc_registerCallback === 'function' && + typeof api.asc_unregisterCallback === 'function' + ) { + reviewApi = api; + api.asc_registerCallback('asc_onOnTrackRevisionsChange', syncReviewControl); + } + } + const canReview = + !!api && + captureDocumentContext()?.kind === 'word' && + api.isDocumentLoadComplete && + api.isLoadFullApi && + !api.isViewMode && + !getReadonlyMode() && + typeof api.asc_IsTrackRevisions === 'function' && + typeof api.asc_SetGlobalTrackRevisions === 'function' && + typeof api.asc_GetGlobalTrackRevisions === 'function' && + typeof api.asc_SetLocalTrackRevisions === 'function'; + reviewCheck.toggleAttribute('disabled', !canReview); + reviewLabel.hidden = !canReview; + const checked = String(typeof api?.asc_IsTrackRevisions === 'function' && !!api.asc_IsTrackRevisions()); + syncingReview = true; + try { + if (reviewCheck.getAttribute('checked') !== checked) reviewCheck.setAttribute('checked', checked); + } finally { + syncingReview = false; + } + }; + syncReviewControl(); + window.addEventListener('document:content-ready', syncReviewControl); + const onHide = (event: PageTransitionEvent) => { + suspended = true; + detachReview(); + if (!event.persisted) dispose(); + }; + const onShow = (event: PageTransitionEvent) => { + if (!event.persisted) return; + suspended = false; + syncReviewControl(); + }; + const dispose = () => { + disposed = true; + detachReview(); + window.removeEventListener('document:content-ready', syncReviewControl); + window.removeEventListener('pagehide', onHide); + window.removeEventListener('pageshow', onShow); + }; + window.addEventListener('pagehide', onHide); + window.addEventListener('pageshow', onShow); + reviewCheck.addEventListener('change', async (e: Event) => { + if (syncingReview) return; + try { + await agentTools.set_review_mode.execute({ enabled: (e as CheckedDetail).detail.checked }); + } catch (error) { + appendTurn({ role: 'error', text: displayError(error) }); + } finally { + syncReviewControl(); + } + }); + + return { sync: syncReviewControl, dispose }; +} diff --git a/lib/agent-plugin/ui/sessions.ts b/lib/agent-plugin/ui/sessions.ts index 82e3adb78..a5e35db31 100644 --- a/lib/agent-plugin/ui/sessions.ts +++ b/lib/agent-plugin/ui/sessions.ts @@ -41,12 +41,29 @@ export function createConversationStore(onChange?: () => void, options: Conversa let savedIds = new Set(); let queue: Promise = Promise.resolve(); let lastError: unknown; + let savePending = false; + let saveQueued = false; + const detached = new WeakMap(); const snapshot = (): ConversationData => structuredClone({ version: 1, activeId: active, sessions }); + // Session records are replaced on edits, so unchanged messages need only one detached copy. + const persistenceSnapshot = (): ConversationData => ({ + version: 1, + activeId: active, + sessions: sessions.map((item) => { + let copy = detached.get(item); + if (!copy) { + copy = structuredClone(item); + detached.set(item, copy); + } + return copy; + }), + }); const enqueue = (operation: () => Promise): Promise => { const task = queue.then(operation); queue = task.catch((error) => { saving = false; + savePending = false; epoch++; lastError = error; options.onError?.(error); @@ -56,17 +73,27 @@ export function createConversationStore(onChange?: () => void, options: Conversa }; const persist = (): void => { if (!saving) return; - const data = snapshot(); + savePending = true; + if (saveQueued) return; + saveQueued = true; const attempt = epoch; - const revision = memoryRevision; void enqueue(async () => { - if (!saving || attempt !== epoch) return; - knownRevision = await repository.save(data, knownRevision ?? 0); - savedIds = new Set(data.sessions.map((item) => item.id)); - if (revision === memoryRevision) { - dirty = false; - touched.clear(); - removed.clear(); + try { + // Keep only one in-flight snapshot and one pending latest state, including streaming bursts. + while (savePending && saving && attempt === epoch) { + savePending = false; + const data = persistenceSnapshot(); + const revision = memoryRevision; + knownRevision = await repository.save(data, knownRevision ?? 0); + savedIds = new Set(data.sessions.map((item) => item.id)); + if (revision === memoryRevision) { + dirty = false; + touched.clear(); + removed.clear(); + } + } + } finally { + saveQueued = false; } }); }; @@ -119,10 +146,12 @@ export function createConversationStore(onChange?: () => void, options: Conversa const item = sessions.find((item) => item.id === id); // A detached controller may finish after deletion. It must never resurrect history. if (!item) return; - item.messages = structuredClone(messages); + sessions = sessions.map((session) => + session.id === id + ? { ...session, messages: structuredClone(messages), title: titleFor(messages), updatedAt: Date.now() } + : session, + ); touched.add(id); - item.title = titleFor(messages); - item.updatedAt = Date.now(); changed(); }; return { @@ -195,6 +224,7 @@ export function createConversationStore(onChange?: () => void, options: Conversa setSaving: async (enabled: boolean): Promise => { if (!enabled) { saving = false; + savePending = false; epoch++; onChange?.(); await queue; @@ -206,7 +236,7 @@ export function createConversationStore(onChange?: () => void, options: Conversa const stored = await repository.load(); if (attempt !== epoch) return; mergeSaved(stored); - const data = snapshot(); + const data = persistenceSnapshot(); const revision = memoryRevision; if ( !stored || @@ -264,6 +294,7 @@ export function createConversationStore(onChange?: () => void, options: Conversa }, clearAll: (): Promise => { saving = false; + savePending = false; epoch++; return enqueue(async () => { lastError = undefined; diff --git a/lib/agent-plugin/ui/sidebar-entry.ts b/lib/agent-plugin/ui/sidebar-entry.ts index 52895a9a6..1b2f0e8fd 100644 --- a/lib/agent-plugin/ui/sidebar-entry.ts +++ b/lib/agent-plugin/ui/sidebar-entry.ts @@ -5,21 +5,48 @@ export function createSidebarEntry(toggle: () => void, label: () => string, isAv let button: HTMLButtonElement | null = null; let ready = false, open = false; + let disposed = false; + let frameObserver: MutationObserver | null = null; + type FrameBinding = { + doc: Document | null; + media: MediaQueryList | null; + observer: MutationObserver | null; + onLoad: () => void; + onMediaChange: () => void; + }; + const frames = new Map(); + const enabled = () => !disposed && ready && (!isAvailable || isAvailable()); + const removeButton = () => { + button?.remove(); + button = null; + }; + const unbindDocument = (binding: FrameBinding) => { + binding.observer?.disconnect(); + binding.media?.removeEventListener?.('change', binding.onMediaChange); + binding.observer = null; + binding.media = null; + binding.doc = null; + }; + const stopWatching = () => { + frameObserver?.disconnect(); + frameObserver = null; + for (const [frame, binding] of frames) { + frame.removeEventListener('load', binding.onLoad); + unbindDocument(binding); + } + frames.clear(); + }; const sync = () => { - if (!ready || (isAvailable && !isAvailable())) { - button?.remove(); - button = null; + if (!enabled()) { + stopWatching(); + removeButton(); return; } let rail: Element | null = null; - for (const frame of document.querySelectorAll('iframe')) { + for (const binding of frames.values()) { try { - const doc = frame.contentDocument; - // Match the editor's compact chrome CSS, including touch landscape. - const compact = - frame.contentWindow?.matchMedia?.( - `(max-width: ${COMPACT_VIEWPORT_MAX_WIDTH}px), (pointer: coarse) and (max-height: ${COMPACT_VIEWPORT_MAX_WIDTH}px)`, - ).matches ?? false; + const doc = binding.doc; + const compact = binding.media?.matches ?? false; rail = doc?.querySelector(compact ? '#left-menu .tool-menu-btns' : '#right-menu .tool-menu-btns') ?? doc?.querySelector('#left-menu .tool-menu-btns') ?? @@ -30,8 +57,7 @@ export function createSidebarEntry(toggle: () => void, label: () => string, isAv if (rail) break; } if (!rail) { - button?.remove(); - button = null; + removeButton(); return; } if (!button || button.ownerDocument !== rail.ownerDocument) { @@ -46,7 +72,7 @@ export function createSidebarEntry(toggle: () => void, label: () => string, isAv 'display:flex;align-items:center;justify-content:center;padding:0;color:inherit;cursor:pointer;'; button.addEventListener('click', (event) => { event.stopPropagation(); - if (ready && (!isAvailable || isAvailable())) toggle(); + if (enabled()) toggle(); }); } if (button.parentElement !== rail) { @@ -59,23 +85,103 @@ export function createSidebarEntry(toggle: () => void, label: () => string, isAv button.setAttribute('aria-pressed', String(open)); button.classList.toggle('active', open); }; - const timer = setInterval(sync, 500); + // Observe structural rail changes, not editor/composer text or our own entry insertion. + const changesRails = (records: MutationRecord[]) => + records.some((record) => + [...record.addedNodes, ...record.removedNodes].some((node) => { + if (node === button) return !button?.isConnected; + if (node.nodeType !== Node.ELEMENT_NODE) return false; + const element = node as Element; + if (element.matches('#left-menu, #right-menu') || element.querySelector('#left-menu, #right-menu')) return true; + const insideMenu = (record.target as Element).closest?.('#left-menu, #right-menu'); + return !!insideMenu && (element.matches('.tool-menu-btns') || !!element.querySelector('.tool-menu-btns')); + }), + ); + const bindDocument = (frame: HTMLIFrameElement, binding: FrameBinding) => { + unbindDocument(binding); + try { + const doc = frame.contentDocument; + if (!doc) return; + binding.doc = doc; + // Match the editor's compact chrome CSS, including touch landscape. + binding.media = + frame.contentWindow?.matchMedia?.( + `(max-width: ${COMPACT_VIEWPORT_MAX_WIDTH}px), (pointer: coarse) and (max-height: ${COMPACT_VIEWPORT_MAX_WIDTH}px)`, + ) ?? null; + binding.media?.addEventListener?.('change', binding.onMediaChange); + binding.observer = new MutationObserver((records) => { + if (enabled() && frames.get(frame) === binding && changesRails(records)) sync(); + }); + binding.observer.observe(doc, { childList: true, subtree: true }); + } catch { + // Cross-origin or navigating frames may become accessible on a later load. + unbindDocument(binding); + } + }; + const refreshFrames = () => { + const currentFrames = new Set(document.querySelectorAll('iframe')); + for (const [frame, binding] of frames) { + if (currentFrames.has(frame)) continue; + frame.removeEventListener('load', binding.onLoad); + unbindDocument(binding); + frames.delete(frame); + } + for (const frame of currentFrames) { + if (frames.has(frame)) continue; + const binding: FrameBinding = { + doc: null, + media: null, + observer: null, + onLoad: () => { + if (!enabled() || frames.get(frame) !== binding) return; + bindDocument(frame, binding); + sync(); + }, + onMediaChange: () => { + if (enabled() && frames.get(frame) === binding) sync(); + }, + }; + frames.set(frame, binding); + frame.addEventListener('load', binding.onLoad); + bindDocument(frame, binding); + } + }; + const startWatching = () => { + if (frameObserver) return; + frameObserver = new MutationObserver((records) => { + if (!enabled()) return; + const changed = records.some((record) => + [...record.addedNodes, ...record.removedNodes].some( + (node) => + node.nodeType === Node.ELEMENT_NODE && + ((node as Element).matches('iframe') || !!(node as Element).querySelector('iframe')), + ), + ); + if (!changed) return; + refreshFrames(); + sync(); + }); + frameObserver.observe(document, { childList: true, subtree: true }); + refreshFrames(); + }; return { focus: (): boolean => { - if (!ready || !button?.isConnected || (isAvailable && !isAvailable())) return false; + if (!enabled() || !button?.isConnected) return false; button.focus({ preventScroll: true }); return button.ownerDocument.activeElement === button; }, update: (available: boolean, expanded: boolean) => { + if (disposed) return; ready = available; open = expanded; + if (enabled()) startWatching(); sync(); }, dispose: () => { - clearInterval(timer); - button?.remove(); - button = null; + disposed = true; ready = false; + stopWatching(); + removeButton(); }, }; } diff --git a/lib/assistant-offline.ts b/lib/assistant-offline.ts new file mode 100644 index 000000000..742ecab23 --- /dev/null +++ b/lib/assistant-offline.ts @@ -0,0 +1,49 @@ +interface OfflineWorker { + postMessage(message: unknown, transfer: Transferable[]): void; +} +interface OfflineWorkerContainer { + controller: OfflineWorker | null; + ready: PromiseLike<{ active: OfflineWorker | null }>; +} + +/** Prepare build-owned AI runtime assets after opt-in; never download model weights. */ +export function prepareAssistantOffline( + container: OfflineWorkerContainer | undefined = typeof navigator === 'undefined' + ? undefined + : navigator.serviceWorker, + timeoutMs = 60000, +): Promise { + if (!container || typeof MessageChannel === 'undefined') return Promise.resolve(false); + return new Promise((resolve) => { + let channel: MessageChannel | undefined; + let finished = false; + const done = (ready: boolean): void => { + if (finished) return; + finished = true; + clearTimeout(timer); + channel?.port1.close(); + channel?.port2.close(); + resolve(ready); + }; + const timer = setTimeout(() => done(false), timeoutMs); + const send = (worker: OfflineWorker | null): void => { + if (finished) return; + if (!worker) return done(false); + try { + channel = new MessageChannel(); + channel.port1.onmessage = (event) => { + if (event.data?.type === 'ASSISTANT_PREPARED') done(event.data.ok === true); + }; + worker.postMessage({ type: 'PREPARE_ASSISTANT' }, [channel.port2]); + } catch { + done(false); + } + }; + if (container.controller) send(container.controller); + else + Promise.resolve(container.ready).then( + (registration) => send(registration.active), + () => done(false), + ); + }); +} diff --git a/lib/history/db.ts b/lib/history/db.ts index 2605eb6bd..2929cea04 100644 --- a/lib/history/db.ts +++ b/lib/history/db.ts @@ -14,7 +14,7 @@ * takes for the landing-page handoff. */ export const DB_NAME = 'document-history'; -export const DB_VERSION = 2; +export const DB_VERSION = 3; export const DOCS_STORE = 'docs'; export const BLOBS_STORE = 'blobs'; @@ -30,6 +30,8 @@ export const HANDLES_STORE = 'handles'; export const DOCS_BY_UPDATED = 'by_updatedAt'; /** All revisions of one document, for retention trimming and deletion. */ export const BLOBS_BY_DOC = 'by_docId'; +/** Revision order and byte accounting available to key cursors, without bytes. */ +export const BLOBS_BY_REVISION = 'by_docId_rev_byteLength'; let dbPromise: Promise | null = null; @@ -45,6 +47,7 @@ export function openHistoryDb(): Promise { return; } let request: IDBOpenDBRequest; + let blocked = false; try { request = indexedDB.open(DB_NAME, DB_VERSION); } catch { @@ -61,6 +64,13 @@ export function openHistoryDb(): Promise { const blobs = db.createObjectStore(BLOBS_STORE, { keyPath: ['docId', 'rev'] }); blobs.createIndex(BLOBS_BY_DOC, 'docId'); } + const blobs = request.transaction!.objectStore(BLOBS_STORE); + if (!blobs.indexNames.contains(BLOBS_BY_REVISION)) { + // Added in v3. IndexedDB backfills the small index keys from existing + // v1/v2 snapshots within this upgrade transaction; no JS byte reads or + // payload rewrites are needed, and an aborted upgrade preserves v2. + blobs.createIndex(BLOBS_BY_REVISION, ['docId', 'rev', 'byteLength']); + } // Added in v2. The guard is what makes the upgrade incremental: a // database created at v1 gains this store and keeps its documents. if (!db.objectStoreNames.contains(HANDLES_STORE)) { @@ -69,6 +79,12 @@ export function openHistoryDb(): Promise { }; request.onsuccess = () => { const db = request.result; + if (blocked) { + // The caller already received null. Do not leak a late connection or + // let it replace a newer retry after the blocking tab finally closes. + db.close(); + return; + } // Another tab opened a newer version: let go rather than block it, and // stop serving reads from a connection whose schema is behind. db.onversionchange = () => { @@ -78,7 +94,11 @@ export function openHistoryDb(): Promise { resolve(db); }; request.onerror = () => resolve(null); - request.onblocked = () => resolve(null); + request.onblocked = () => { + blocked = true; + dbPromise = null; + resolve(null); + }; }); return dbPromise; } diff --git a/lib/history/store.ts b/lib/history/store.ts index 41a0af43e..2014a8864 100644 --- a/lib/history/store.ts +++ b/lib/history/store.ts @@ -17,7 +17,16 @@ * Everything here resolves to a null/empty result when IndexedDB is missing; * see lib/history/db.ts for why. */ -import { BLOBS_BY_DOC, BLOBS_STORE, DOCS_STORE, HANDLES_STORE, requestToPromise, withStores } from './db'; +import { + BLOBS_BY_DOC, + BLOBS_BY_REVISION, + BLOBS_STORE, + DOCS_BY_UPDATED, + DOCS_STORE, + HANDLES_STORE, + requestToPromise, + withStores, +} from './db'; import type { HistoryDoc, HistoryOrigin, HistorySnapshot } from './types'; import { MAX_AGE_MS, expiresAt, hasUnsavedWork } from './types'; import { deleteDocumentSources } from '../active-document-source'; @@ -123,20 +132,36 @@ async function deleteDocumentData(tx: IDBTransaction, docId: string): Promise clearSessionSaveTargets(docId), { once: true }); } -/** Drop revisions past MAX_REVS_PER_DOC, oldest first. Returns bytes freed. */ +/** Keep the newest MAX_REVS_PER_DOC revisions. Returns bytes freed. */ async function trimRevisions(tx: IDBTransaction, docId: string): Promise { const store = tx.objectStore(BLOBS_STORE); - const rows = ((await requestToPromise(store.index(BLOBS_BY_DOC).getAll(IDBKeyRange.only(docId)))) ?? - []) as HistorySnapshot[]; - if (rows.length <= MAX_REVS_PER_DOC) return 0; - - const doomed = rows.sort((a, b) => a.rev - b.rev).slice(0, rows.length - MAX_REVS_PER_DOC); - let freed = 0; - for (const row of doomed) { - store.delete([row.docId, row.rev]); - freed += row.byteLength; - } - return freed; + // Array-valued keys sort after numeric revisions. These bounds include + // exactly this document's [docId, rev, byteLength] keys, even with rev gaps. + const range = IDBKeyRange.bound([docId], [docId, []]); + return new Promise((resolve, reject) => { + const request = store.index(BLOBS_BY_REVISION).openKeyCursor(range, 'prev'); + let kept = 0; + let freed = 0; + request.onerror = () => reject(request.error ?? new Error('Revision cursor failed')); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(freed); + return; + } + try { + if (kept < MAX_REVS_PER_DOC) { + kept += 1; + } else { + store.delete(cursor.primaryKey); + freed += (cursor.key as [string, number, number])[2]; + } + cursor.continue(); + } catch (error) { + reject(error); + } + }; + }); } /** @@ -145,8 +170,17 @@ async function trimRevisions(tx: IDBTransaction, docId: string): Promise * not something that waits for the user to visit a page. */ async function sweepExpired(tx: IDBTransaction, now: number): Promise { - const docs = await readAllDocs(tx); - const expired = docs.filter((doc) => expiresAt(doc) <= now); + // A recent snapshot cannot expire, regardless of when it was last opened. + // Read only old snapshot metadata, then keep the opening-time renewal rule. + const docs = (await requestToPromise( + tx + .objectStore(DOCS_STORE) + .index(DOCS_BY_UPDATED) + .getAll(IDBKeyRange.upperBound(now - MAX_AGE_MS)), + )) as HistoryDoc[]; + // The forward index orders timestamp ties by ascending primary key, just + // like getAll did. A stable sort preserves the returned expiry ID order. + const expired = docs.filter((doc) => expiresAt(doc) <= now).sort((a, b) => b.updatedAt - a.updatedAt); for (const doc of expired) { await deleteDocumentData(tx, doc.id); } @@ -364,11 +398,12 @@ export async function getLatestSnapshot(id: string): Promise { - const rows = ((await requestToPromise( - tx.objectStore(BLOBS_STORE).index(BLOBS_BY_DOC).getAll(IDBKeyRange.only(id)), - )) ?? []) as HistorySnapshot[]; - if (!rows.length) return null; - return rows.sort((a, b) => b.rev - a.rev)[0]; + // Equal index keys are ordered by the [docId, rev] primary key, so a + // reverse cursor clones only the highest revision and never advances. + const cursor = await requestToPromise( + tx.objectStore(BLOBS_STORE).index(BLOBS_BY_DOC).openCursor(IDBKeyRange.only(id), 'prev'), + ); + return (cursor?.value as HistorySnapshot | undefined) ?? null; })) ?? null ); } catch { diff --git a/package.json b/package.json index 4d63b3297..f3c814dfe 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "sitemap:lastmod": "node bin/sitemap-lastmod.mjs", "build:single": "node bin/bundle_single_html.js", "preview": "vite preview", - "tsc": "tsc --noEmit", + "tsc": "pnpm --recursive --filter '@ranuts/*' build && tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", @@ -22,7 +22,7 @@ "test:e2e:docker": "sh ./bin/test-e2e-docker.sh", "format": "node bin/archive-quality.mjs format", "format:check": "node bin/archive-quality.mjs format:check", - "lint:ts": "node bin/archive-quality.mjs lint && tsc --noEmit", + "lint:ts": "node bin/archive-quality.mjs lint && pnpm run tsc", "lint:docker": "docker compose -f docker-compose.yaml config --quiet", "lint": "pnpm run lint:ts && pnpm run lint:docker", "test:e2e:corpus": "playwright test test/e2e/corpus.spec.ts", diff --git a/packages/agent-core/src/llm/model-cache.ts b/packages/agent-core/src/llm/model-cache.ts index f5e1514ca..bc3bdffb5 100644 --- a/packages/agent-core/src/llm/model-cache.ts +++ b/packages/agent-core/src/llm/model-cache.ts @@ -54,7 +54,9 @@ export async function deleteCachedGGUF(name: string): Promise { const manager = await ggufCache(); const group = groupGGUF(await manager.list()).find((item) => item.name === name); if (!group) throw new Error('Cached model is no longer available'); - for (const file of group.files) await manager.delete(file); + // Avoid a full inventory scan per shard (body reads on the Cache API fallback). + const files = new Set(group.files); + await manager.deleteMany((entry) => files.has(entry.name)); } export interface CachedModelSource extends ModelSource { diff --git a/packages/chat-ui/CLAUDE.md b/packages/chat-ui/CLAUDE.md index 95659ce21..167bebd61 100644 --- a/packages/chat-ui/CLAUDE.md +++ b/packages/chat-ui/CLAUDE.md @@ -44,6 +44,7 @@ container.appendChild(chat.el); // mount the root element | Method | Purpose | | ---------------------------------------------------- | ------------------------------------------------------------------------------------- | | `append({ role, text })` | Add a finished message. `role`: `'user' \| 'agent' \| 'tool' \| 'status' \| 'error'`. | +| `appendMany(messages)` | Add finished messages in order with one final layout update, for restoring history. | | `appendDelta(text)` | Stream into a live agent bubble (auto-created on first delta). | | `endStream()` | Finalise the streaming bubble (drops the caret). | | `setRunning(bool)` | Toggle Send⇄Stop; keep the next draft editable, without queuing it. | @@ -81,6 +82,7 @@ onSend: async (text) => { - Modern look: assistant messages are bubble-less/full-width, the user gets an accent bubble, tool/error render as subtle chips; a jump-to-latest button appears when scrolled up and new content only auto-scrolls when already near the bottom. - Width changes reflow the draft. Layout changes keep following the latest reply only while the reader is attached to the end; browsing history preserves its position. - Message arrivals consume the current following state. They must not recompute it from post-reflow geometry: scroll handling owns reader attachment, while jumping to the end and clearing reset it. +- Use `appendMany` for history restoration. It keeps the same message rendering and tool grouping as `append`, while deferring intermediate scroll measurements. Completed and interrupted streams publish their original source when finalized; streaming actions stay hidden. - Copy feedback uses fixed-size icons and a separate polite announcement. Clipboard failure leaves the action available to retry; feedback never replaces running activity. - Restyle via CSS custom properties on `.cui-root` (`--cui-accent`, `--cui-user-bg`, …) — don't depend on internal `cui-*` class names. - DOM is built with the ranui `builder` (View/Div/Span/ButtonBuilder) and the scroll handler is throttled via ranuts; the package depends on `ranui` + `ranuts`. diff --git a/packages/chat-ui/src/chat-view.ts b/packages/chat-ui/src/chat-view.ts index db545c9de..b54bdb194 100644 --- a/packages/chat-ui/src/chat-view.ts +++ b/packages/chat-ui/src/chat-view.ts @@ -73,6 +73,10 @@ export class ChatView { private liveText = ''; private liveNode: Text | null = null; private pendingText = ''; + private actionRunning = false; + private actionHasDraft = false; + private appendBatchDepth = 0; + private appendBatchChanged = false; constructor(private readonly options: ChatViewOptions) { ensureChatUiStyles(); @@ -234,8 +238,7 @@ export class ChatView { group.querySelector('ul')!.append(li); group.querySelector('summary')!.replaceChildren(item); } - if (stick) this.scrollToEnd(); - else this.updateScrollBtn(); + this.finishAppendLayout(stick); this.resizeObserver?.observe(group); return item; } @@ -329,12 +332,38 @@ export class ChatView { this.messagesEl.appendChild(row); this.resizeObserver?.observe(row); - this.updateRestoreState(); - if (stick) this.scrollToEnd(); - else this.updateScrollBtn(); + this.updateRestoreState(row); + this.finishAppendLayout(stick); return bubble; } + /** Append finished messages in order, measuring and scrolling only after the batch. */ + appendMany(messages: readonly ChatMessage[]): void { + if (this.disposed || !messages.length) return; + this.appendBatchDepth++; + try { + for (const message of messages) { + if (this.disposed) break; + this.append(message); + } + } finally { + this.appendBatchDepth--; + if (!this.appendBatchDepth && this.appendBatchChanged) { + this.appendBatchChanged = false; + if (!this.disposed) { + if (!this.following) this.recordScrollGeometry(); + this.finishAppendLayout(this.following); + } + } + } + } + + private finishAppendLayout(stick: boolean): void { + if (this.appendBatchDepth) this.appendBatchChanged = true; + else if (stick) this.scrollToEnd(); + else this.updateScrollBtn(); + } + /** * Append streamed text to a live agent bubble, creating it on the first delta. * Call {@link endStream} when the turn completes. @@ -357,6 +386,7 @@ export class ChatView { endStream(): void { const stick = this.following; this.flushStream(); + if (this.liveMsg) this.liveMsg.dataset.source = this.liveText; const bubble = this.liveMsg?.querySelector('.cui-bubble'); if (bubble) renderMarkdown(bubble, this.liveText); this.liveMsg?.classList.remove('cui-streaming'); @@ -364,7 +394,7 @@ export class ChatView { this.liveNode = null; this.liveText = ''; this.updateActivity(); - if (bubble && stick) this.scrollToEnd(); + if (bubble && stick) this.finishAppendLayout(stick); } /** Only explicit stream completion can make a preview available for writing. */ @@ -381,8 +411,7 @@ export class ChatView { const stick = this.following; this.messagesEl.append(content); this.resizeObserver?.observe(content); - if (stick) this.scrollToEnd(); - else this.updateScrollBtn(); + this.finishAppendLayout(stick); } /** Toggle Send/Stop while keeping the next draft editable and never queued. */ @@ -525,8 +554,12 @@ export class ChatView { /** Reflect run state + empty input on the send button (icon, disabled, title). */ private updateSendState(): void { - this.updateRestoreState(); const hasDraft = !!this.input.value.trim(); + if (this.actionRunning !== this.running || this.actionHasDraft !== hasDraft) { + this.actionRunning = this.running; + this.actionHasDraft = hasDraft; + this.updateRestoreState(); + } const unavailable = !this.running && hasDraft && this.options.canSend?.(this.input.value) === false; const preparation = unavailable ? this.options.getSendPreparation?.() : undefined; if (preparation) this.sendBtn.textContent = preparation.label; @@ -542,10 +575,10 @@ export class ChatView { !this.running && (this.preparingSend || !hasDraft || (unavailable && (!preparation || !!preparation.disabled))); } - private updateRestoreState(): void { - for (const button of this.messagesEl.querySelectorAll('.cui-restore')) + private updateRestoreState(parent: HTMLElement = this.messagesEl): void { + for (const button of parent.querySelectorAll('.cui-restore')) button.disabled = this.running || !!this.input.value.trim(); - for (const button of this.messagesEl.querySelectorAll('.cui-apply')) + for (const button of parent.querySelectorAll('.cui-apply')) button.disabled = this.running || (!!button.dataset.state && button.dataset.state !== 'retry'); } @@ -597,7 +630,6 @@ export class ChatView { if (!this.liveMsg || !this.pendingText) return; this.liveNode?.appendData(this.pendingText); this.pendingText = ''; - this.liveMsg.dataset.source = this.liveText; } private clearCopyFeedback(): void { diff --git a/packages/shared/CLAUDE.md b/packages/shared/CLAUDE.md index 970c014fa..02dc32d9c 100644 --- a/packages/shared/CLAUDE.md +++ b/packages/shared/CLAUDE.md @@ -32,19 +32,19 @@ import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store'; extension, the deployment `BASE_PATH` (handles `/document/` on GitHub Pages vs `/`), and `parseReadonly` for the `?readonly` query flag. - **document-types** — shared interfaces, incl. x2t/Emscripten module shapes. -- **i18n** — `t(key)` over the 8 shell locales in `SHELL_LOCALES` - (en/zh complete, the rest core-only with per-key English fallback); +- **i18n** — `t(key)` over the 7 complete shell locales in `SHELL_LOCALES` + (en/zh-CN/ja/ko/de/es/pt, with per-key English fallback); `getLanguage/setLanguage`; `getOnlyOfficeLang()` resolves the _editor_ locale (any of the 45 the vendor ships, `resolveEditorLocale`); `applyDocumentLanguage()` - sets `` (fa is RTL). Keys are typed via `I18nMessages`. + sets `` (current shell locales are left-to-right). Keys are typed via `I18nMessages`. - **store** — `[getDocmentObj, setDocmentObj]`, a ranuts signal over `{ fileName: string; file?: File; url?: string | URL }` (the current document). ## Gotchas -- `t(key)` is typed against `I18nMessages` — a new UI string must be added to the two - **complete** tables (en + zh) or `t` won't type-check; the other locales are - `Partial` and fall back to English per key, so translating them can come later. +- `t(key)` is typed against `I18nMessages` — a new UI string must be added to all seven complete tables. + Type checking and locale completeness tests enforce the shared message shape; + `t` still falls back to English for an unexpectedly missing runtime key. - `store` holds a `File`/`Blob` — don't serialize it to localStorage. - This package depends on `ranuts`; keep it that way (it's the app's shared layer, not a zero-dep generic lib). diff --git a/playwright.config.ts b/playwright.config.ts index 0a36a900d..a6e7ffef7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -38,7 +38,7 @@ export default defineConfig({ webServer: REMOTE ? undefined : { - command: `./node_modules/.bin/vite build --outDir ${OUT_DIR} && ./node_modules/.bin/vite preview --outDir ${OUT_DIR} --host 127.0.0.1 --port ${PORT}`, + command: `./node_modules/.bin/vite build --outDir ${OUT_DIR} && node bin/offline-assets.mjs ${OUT_DIR} && ./node_modules/.bin/vite preview --outDir ${OUT_DIR} --host 127.0.0.1 --port ${PORT}`, url: `http://127.0.0.1:${PORT}`, reuseExistingServer: !process.env.CI, timeout: 120_000, diff --git a/public/sw-register.js b/public/sw-register.js index 156236783..7bb65989a 100644 --- a/public/sw-register.js +++ b/public/sw-register.js @@ -128,15 +128,37 @@ }); } + // 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 1277135d6..c7d419812 100644 --- a/public/sw.js +++ b/public/sw.js @@ -44,6 +44,8 @@ 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_PREPARED_KEY = '/__document-assistant-prepared__'; const EDITOR_BOOTSTRAP_HTML = [ '/web-apps/apps/documenteditor/main/index.html', '/web-apps/apps/spreadsheeteditor/main/index.html', @@ -270,6 +272,10 @@ self.addEventListener('install', (event) => { await cache.addAll([...ASSETS_TO_CACHE, ...APP_ASSETS_TO_CACHE]); await precacheBootAssets(cache); }), + // Keep a previously prepared assistant usable after updates, even if its + // panel stays closed while this build installs. Default-off visitors have + // no marker and never download the optional graph. + restorePreparedAssistant(), // Take over at once unless doing so would discard vendor assets an open // page of the outgoing build still needs (see wouldDiscardVendorAssets). // When it would, stay waiting: a page with a document open is @@ -284,11 +290,53 @@ self.addEventListener('install', (event) => { .then((wouldDiscard) => { if (!wouldDiscard) self.skipWaiting(); }), - ]), + ]).then(restorePreparedAssistant), ); }); +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(); + }); +} + +let assistantPreparation; +function prepareAssistantAssets() { + if (!assistantPreparation) { + assistantPreparation = caches + .open(CORE_CACHE) + .then(async (cache) => { + const missing = []; + for (const asset of ASSISTANT_ASSETS_TO_CACHE) { + 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' } }), + ); + }) + .finally(() => { + assistantPreparation = undefined; + }); + } + return assistantPreparation; +} + self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'PREPARE_ASSISTANT') { + const port = event.ports && event.ports[0]; + if (!port) return; + // The page cannot supply URLs: only this build's generated allowlist is cached. + event.waitUntil( + prepareAssistantAssets().then( + () => port.postMessage({ type: 'ASSISTANT_PREPARED', ok: true }), + () => port.postMessage({ type: 'ASSISTANT_PREPARED', ok: false }), + ), + ); + } + if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } @@ -379,9 +427,10 @@ self.addEventListener('activate', (event) => { }) // The runtime cache can be one we inherited from the previous build (same // vendor content, different app code), and nothing else ever clears it. - .then(() => pruneAppAssets(RUNTIME_CACHE)), + .then(() => pruneAppAssets(RUNTIME_CACHE)) + .then(restorePreparedAssistant) + .then(() => self.clients.claim()), ); - self.clients.claim(); }); // Fetch event: Strategy-based resource handling diff --git a/test/e2e/agent-context-events.spec.ts b/test/e2e/agent-context-events.spec.ts new file mode 100644 index 000000000..bb8a52d2f --- /dev/null +++ b/test/e2e/agent-context-events.spec.ts @@ -0,0 +1,98 @@ +import { expect, test } from './lib/l0'; + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC'); + }); +}); + +test('Word selection updates the composer through SDK events without idle reads', async ({ page }) => { + test.setTimeout(90_000); + 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(); + await expect(page.locator('.agent-runtime-panel')).toBeVisible(); + await page.evaluate(() => { + const win = window.__ooFrames.editor() as unknown as { + Asc: { + editor: { + pluginMethod_InputText(text: string): void; + asc_EditSelectAll(): void; + pluginMethod_GetSelectedText: (...args: unknown[]) => string; + }; + }; + contextReads?: number; + }; + const api = win.Asc.editor; + const original = api.pluginMethod_GetSelectedText; + win.contextReads = 0; + api.pluginMethod_GetSelectedText = function (...args: unknown[]) { + win.contextReads!++; + return original.apply(this, args); + }; + api.pluginMethod_InputText('Event-driven selection'); + api.asc_EditSelectAll(); + }); + await expect(page.locator('.agent-quoted-text')).toHaveText('Event-driven selection'); + // Let notification bursts settle, then observe longer than the former interval. + await page.waitForTimeout(100); + const reads = () => + page.evaluate(() => (window.__ooFrames.editor() as unknown as { contextReads: number }).contextReads); + const before = await reads(); + await page.waitForTimeout(1200); + expect(await reads()).toBe(before); +}); + +test('spreadsheet cell navigation updates context without reopening the panel', async ({ page }) => { + test.setTimeout(90_000); + await page.goto('/editor?new=xlsx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await page.evaluate(() => { + const win = window.__ooFrames.editor() as unknown as { Asc: { editor: { asc_findCell(address: string): void } } }; + win.Asc.editor.asc_findCell('B2'); + }); + await expect(page.locator('.agent-quote-context summary')).toContainText('B2'); + await page.evaluate(() => { + const win = window.__ooFrames.editor() as unknown as { + Asc: { editor: { asc_renameWorksheet(name: string): void } }; + }; + win.Asc.editor.asc_renameWorksheet('Updated sheet'); + }); + await expect(page.locator('.agent-quote-context summary')).toContainText('Updated sheet'); + // Keep the same selection: a completed history action must update its contents. + await page.evaluate(() => { + const win = window.__ooFrames.editor() as unknown as { + Asc: { + editor: { + wb: { + getWorksheet(): { + model: { getRange3(r1: number, c1: number, r2: number, c2: number): { setValue(value: string): void } }; + }; + }; + }; + }; + AscCommon: { History: { Create_NewPoint(): void; StartTransaction(): void; EndTransaction(): void } }; + }; + const history = win.AscCommon.History; + history.Create_NewPoint(); + history.StartTransaction(); + win.Asc.editor.wb.getWorksheet().model.getRange3(1, 1, 1, 1).setValue('Updated B2'); + history.EndTransaction(); + }); + await expect(page.locator('.agent-quoted-text')).toHaveText('Updated B2'); +}); + +test('native sidebar follows compact media changes while the panel stays closed', async ({ page }) => { + test.setTimeout(90_000); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + const frame = page.frameLocator('iframe[name="frameEditor"]'); + await expect(frame.locator('#right-menu .agent-sidebar-entry')).toBeVisible(); + await page.setViewportSize({ width: 375, height: 850 }); + await expect(frame.locator('#left-menu .agent-sidebar-entry')).toBeVisible(); + await page.setViewportSize({ width: 1280, height: 900 }); + await expect(frame.locator('#right-menu .agent-sidebar-entry')).toBeVisible(); + await expect(frame.locator('.agent-sidebar-entry')).toHaveCount(1); +}); diff --git a/test/e2e/agent-history-rendering.spec.ts b/test/e2e/agent-history-rendering.spec.ts new file mode 100644 index 000000000..0f2a67127 --- /dev/null +++ b/test/e2e/agent-history-rendering.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from './lib/l0'; + +test('restores a long saved conversation and switches sessions with completed answer actions', async ({ page }) => { + test.setTimeout(90_000); + await page.goto('/'); + const ids = await page.evaluate(async () => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC'); + localStorage.setItem('agent_conversation_saving_v2', 'true'); + const long = crypto.randomUUID(); + const short = crypto.randomUUID(); + const now = Date.now(); + const request = indexedDB.open('document-ai-conversations', 2); + request.onupgradeneeded = () => { + request.result.createObjectStore('conversations'); + request.result.createObjectStore('sessions'); + }; + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const tx = db.transaction(['conversations', 'sessions'], 'readwrite'); + tx.objectStore('conversations').put( + { version: 2, revision: 1, activeId: long, sessionIds: [long, short] }, + 'state', + ); + const messages = Array.from({ length: 200 }, (_, index) => [ + { role: 'user', content: `Question ${index}` }, + { role: 'assistant', content: `**Answer ${index}**` }, + ]).flat(); + tx.objectStore('sessions').put({ id: long, title: 'Long history', createdAt: now, updatedAt: now, messages }, long); + tx.objectStore('sessions').put( + { + id: short, + title: 'Short history', + createdAt: now, + updatedAt: now, + messages: [ + { role: 'user', content: 'Short request' }, + { role: 'assistant', content: 'Short answer' }, + ], + }, + short, + ); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error); + }); + db.close(); + return { long, short }; + }); + 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 rows = page.locator('.cui-messages .cui-msg'); + await expect(rows).toHaveCount(400); + await expect(rows.first().locator('.cui-bubble')).toHaveText('Question 0'); + await expect(rows.last().locator('.cui-bubble strong')).toHaveText('Answer 199'); + await expect(rows.last().locator('.cui-copy')).toBeEnabled(); + await expect + .poll(() => + page + .locator('.cui-messages') + .evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop), + ) + .toBeLessThanOrEqual(2); + await page.locator('.agent-history-toggle').click(); + await page.locator('.agent-session-select').selectOption(ids.short); + await expect(rows).toHaveCount(2); + await expect(rows.last().locator('.cui-bubble')).toHaveText('Short answer'); + await page.locator('.agent-history-toggle').click(); + await page.locator('.agent-session-select').selectOption(ids.long); + await expect(rows).toHaveCount(400); + await expect(rows.last()).toHaveAttribute('data-source', '**Answer 199**'); + await page.locator('.cui-input').fill('Next request'); + await expect(page.locator('.cui-input')).toHaveValue('Next request'); +}); diff --git a/test/e2e/assistant-offline-assets.spec.ts b/test/e2e/assistant-offline-assets.spec.ts new file mode 100644 index 000000000..00812bb12 --- /dev/null +++ b/test/e2e/assistant-offline-assets.spec.ts @@ -0,0 +1,72 @@ +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 () => { + 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') }; + }); + expect(lists.core.length).toBeGreaterThan(0); + expect(lists.assistant.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); + expect(cached.every((present) => !present)).toBe(true); + await page.reload(); + await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true); + const prepare = () => + page.evaluate( + () => + new Promise((resolve, reject) => { + const channel = new MessageChannel(); + const timer = setTimeout(() => reject(new Error('Assistant cache 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]); + }), + ); + await page.goto('/editor?new=docx'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor()), undefined, { timeout: 60_000 }); + expect(await prepare()).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, + ); + expect(available.every(Boolean)).toBe(true); + 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 page.locator('.agent-onboarding .agent-enable-switch').click(); + 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.setTimeout(90_000); + await page.addInitScript(() => localStorage.setItem('document-assistant-enabled', 'true')); + 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)))), + }; + }); + expect(present.count).toBeGreaterThan(0); + expect(present.cached.every(Boolean)).toBe(true); +}); diff --git a/test/unit/agent-action-preview.test.ts b/test/unit/agent-action-preview.test.ts index 72655927f..9d03a2f4e 100644 --- a/test/unit/agent-action-preview.test.ts +++ b/test/unit/agent-action-preview.test.ts @@ -66,6 +66,208 @@ vi.mock('../../lib/agent-plugin/tools', () => ({ agentTools: { insert_text: { ex afterEach(() => { document.body.innerHTML = ''; execute.mockReset(); + vi.useRealTimers(); +}); + +function previewEditor() { + const frame = document.createElement('iframe'); + document.body.append(frame); + const callbacks = new Map void>(); + Object.assign(frame.contentWindow!, { + editor: { + asc_registerCallback: (name: string, callback: () => void) => callbacks.set(name, callback), + asc_unregisterCallback: (name: string, callback: () => void) => { + if (callbacks.get(name) === callback) callbacks.delete(name); + }, + }, + }); + return { frame, callbacks }; +} + +it('does no repeated target validation while a pending preview is idle', () => { + vi.useFakeTimers(); + const view = new ActionPreview(); + let reads = 0; + view.show( + new ReviewedAction( + { + editor: 'word', + label: 'Word', + selectedText: '', + isCurrent: () => { + reads++; + return true; + }, + }, + { tool: 'insert_text', input: { text: 'Draft' } }, + ), + ); + const initialReads = reads; + vi.advanceTimersByTime(60000); + view.dispose(); + expect(reads).toBe(initialReads); +}); + +it('expires a stale preview when the editor selection notification arrives', () => { + vi.useFakeTimers(); + const sdk = previewEditor(); + const view = new ActionPreview(); + let current = true; + const outcomes: string[] = []; + view.show( + new ReviewedAction( + { editor: 'word', label: 'Word', selectedText: '', isCurrent: () => current }, + { tool: 'insert_text', input: { text: 'Draft' } }, + ), + undefined, + undefined, + (outcome) => outcomes.push(outcome.status), + ); + current = false; + sdk.callbacks.get('asc_onSelectionEnd')?.(); + vi.advanceTimersByTime(20); + expect(view.el.dataset.state).toBe('expired'); + expect(outcomes).toEqual(['expired']); + expect(sdk.callbacks.size).toBe(0); + view.dispose(); +}); + +it('expires a stale preview after an interaction in the editor frame', () => { + vi.useFakeTimers(); + const { frame } = previewEditor(); + const view = new ActionPreview(); + let current = true; + frame.contentDocument!.addEventListener('pointerdown', () => { + current = false; + }); + view.show( + new ReviewedAction( + { editor: 'word', label: 'Word', selectedText: '', isCurrent: () => current }, + { tool: 'insert_text', input: { text: 'Draft' } }, + ), + ); + frame.contentDocument!.dispatchEvent(new Event('pointerdown')); + vi.advanceTimersByTime(20); + expect(view.el.dataset.state).toBe('expired'); + view.dispose(); +}); + +it('expires a stale preview immediately when a replacement document becomes ready', () => { + const view = new ActionPreview(); + let current = true; + view.show( + new ReviewedAction( + { editor: 'word', label: 'Word', selectedText: '', isCurrent: () => current }, + { tool: 'insert_text', input: { text: 'Draft' } }, + ), + ); + current = false; + window.dispatchEvent(new Event('document:content-ready')); + expect(view.el.dataset.state).toBe('expired'); + view.dispose(); +}); + +it.each(['hide', 'invalidate', 'dispose', 'apply'] as const)( + 'removes preview observers and queued validation when %s ends review', + (method) => { + vi.useFakeTimers(); + const sdk = previewEditor(); + const view = new ActionPreview(); + let reads = 0; + view.show({ + target: { label: 'Word', selectedText: '' }, + plan: { tool: 'insert_text', input: { text: 'Draft' } }, + isCurrent: () => { + reads++; + return true; + }, + cancel: () => {}, + apply: async () => 'verified', + }); + expect(sdk.callbacks.size).toBeGreaterThan(0); + sdk.callbacks.get('asc_onSelectionEnd')?.(); + if (method === 'apply') view.el.querySelector('.agent-plan-apply')!.click(); + else view[method](); + const endedReads = reads; + window.dispatchEvent(new Event('document:content-ready')); + vi.advanceTimersByTime(1000); + expect(reads).toBe(endedReads); + expect(sdk.callbacks.size).toBe(0); + view.dispose(); + }, +); + +it('tears down observers when initial validation finds an already stale preview', () => { + const sdk = previewEditor(); + const view = new ActionPreview(); + view.show( + new ReviewedAction( + { editor: 'word', label: 'Word', selectedText: '', isCurrent: () => false }, + { tool: 'insert_text', input: { text: 'Draft' } }, + ), + ); + expect(view.el.dataset.state).toBe('expired'); + expect(sdk.callbacks.size).toBe(0); + view.dispose(); +}); + +it('does not validate a replaced preview from its previously queued notification', () => { + vi.useFakeTimers(); + const sdk = previewEditor(); + const view = new ActionPreview(); + let firstReads = 0; + const first = new ReviewedAction( + { + editor: 'word', + label: 'Word', + selectedText: '', + isCurrent: () => { + firstReads++; + return true; + }, + }, + { tool: 'insert_text', input: { text: 'First' } }, + ); + view.show(first); + sdk.callbacks.get('asc_onSelectionEnd')!(); + const replacement = new ReviewedAction( + { editor: 'word', label: 'Word', selectedText: '', isCurrent: () => true }, + { tool: 'insert_text', input: { text: 'Replacement' } }, + ); + view.show(replacement); + const previousReads = firstReads; + vi.advanceTimersByTime(20); + expect(firstReads).toBe(previousReads); + expect(view.pending()).toBe(replacement); + view.dispose(); + expect(sdk.callbacks.size).toBe(0); +}); + +it('keeps the replacement observer when initial expiry starts another review synchronously', () => { + vi.useFakeTimers(); + const sdk = previewEditor(); + const view = new ActionPreview(); + let current = true; + const replacement = new ReviewedAction( + { editor: 'word', label: 'Word', selectedText: '', isCurrent: () => current }, + { tool: 'insert_text', input: { text: 'Replacement' } }, + ); + view.show( + new ReviewedAction( + { editor: 'word', label: 'Word', selectedText: '', isCurrent: () => false }, + { tool: 'insert_text', input: { text: 'Expired' } }, + ), + undefined, + undefined, + () => view.show(replacement), + ); + expect(view.pending()).toBe(replacement); + current = false; + sdk.callbacks.get('asc_onSelectionEnd')!(); + vi.advanceTimersByTime(20); + expect(view.el.dataset.state).toBe('expired'); + expect(sdk.callbacks.size).toBe(0); + view.dispose(); }); it.each(['hide', 'invalidate', 'supersede', 'dispose'] as const)( 'settles a pending review once when %s interrupts it', diff --git a/test/unit/agent-editor-bridge.test.ts b/test/unit/agent-editor-bridge.test.ts index 8da753f66..1905444f4 100644 --- a/test/unit/agent-editor-bridge.test.ts +++ b/test/unit/agent-editor-bridge.test.ts @@ -66,13 +66,13 @@ describe('agent editor-bridge', () => { it('skips iframes whose contentWindow access throws (cross-origin) and keeps scanning', () => { const crossOrigin = document.createElement('iframe'); + document.body.appendChild(crossOrigin); Object.defineProperty(crossOrigin, 'contentWindow', { get() { throw new Error('cross-origin'); }, configurable: true, }); - document.body.appendChild(crossOrigin); const api = { pluginMethod_PasteHtml: () => {} }; mountIframe({ editor: api }, { name: 'frameEditor' }); expect(getEditorApi()).toBe(api); diff --git a/test/unit/agent-entry.test.ts b/test/unit/agent-entry.test.ts index ee23de8e5..8b3b060c1 100644 --- a/test/unit/agent-entry.test.ts +++ b/test/unit/agent-entry.test.ts @@ -1,5 +1,7 @@ import { afterEach, expect, it, vi } from 'vitest'; import { createAgentEntry } from '../../lib/agent-entry'; +const prepare = vi.hoisted(() => vi.fn().mockResolvedValue(true)); +vi.mock('../../lib/assistant-offline', () => ({ prepareAssistantOffline: prepare })); const runtime = vi.hoisted(() => ({ create: vi.fn(), enable: vi.fn().mockResolvedValue(undefined), open: vi.fn() })); vi.mock('../../lib/agent-plugin/agent-plugin', () => ({ createAgentPanel: runtime.create, @@ -20,6 +22,7 @@ it('opens a default-off welcome without importing the runtime or honoring the ol expect(toggle.getAttribute('aria-checked')).toBe('false'); expect(document.querySelector('.agent-onboarding')?.classList.contains('agent-panel-hidden')).toBe(false); expect(runtime.create).not.toHaveBeenCalled(); + expect(prepare).not.toHaveBeenCalled(); }); it('enables explicitly, remembers the choice and never prepares a model on its own', async () => { const entry = createAgentEntry(); @@ -28,11 +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(); }); -it('a remembered choice stays lazy until the user opens the assistant', async () => { +it('a remembered choice prepares offline assets but keeps runtime execution lazy until opening', async () => { localStorage.setItem('document-assistant-enabled', 'true'); const entry = createAgentEntry(); expect(runtime.create).not.toHaveBeenCalled(); + expect(prepare).toHaveBeenCalledOnce(); entry.toggle(); await vi.waitFor(() => expect(runtime.create).toHaveBeenCalledOnce()); }); diff --git a/test/unit/agent-model-cache.test.ts b/test/unit/agent-model-cache.test.ts index 4b1e47ee9..9d5c98027 100644 --- a/test/unit/agent-model-cache.test.ts +++ b/test/unit/agent-model-cache.test.ts @@ -1,10 +1,42 @@ -import { expect, it, vi } from 'vitest'; +import { beforeEach, expect, it, vi } from 'vitest'; +import { Blob } from 'node:buffer'; import { deleteCachedModel, + deleteCachedGGUF, rememberModelSource, rememberedModelSources, } from '../../packages/agent-core/src/llm/model-cache'; const sdk = vi.hoisted(() => ({ weights: vi.fn(), config: vi.fn(), wasm: vi.fn() })); +const gguf = vi.hoisted(() => ({ + entries: new Map(), + list: vi.fn(), + remove: vi.fn(), +})); +// Keep the SDK's list/delete/deleteMany behavior real; only browser storage is replaced. +vi.mock('../../packages/agent-core/src/llm/wllama-cache-storage', () => ({ + selectWllamaStorage: async () => ({ + isSupported: () => true, + list: gguf.list, + read: async (key: string) => gguf.entries.get(key) ?? null, + delete: gguf.remove, + }), +})); +beforeEach(() => { + gguf.entries.clear(); + gguf.list + .mockReset() + .mockImplementation(async () => [...gguf.entries].map(([key, value]) => ({ key, size: value.size }))); + gguf.remove.mockReset().mockImplementation(async (key: string) => { + gguf.entries.delete(key); + }); +}); +function cacheGGUF(key: string, url: string): void { + gguf.entries.set(key, new Blob(['model'])); + gguf.entries.set( + `__metadata__${key}`, + new Blob([JSON.stringify({ originalURL: url, originalSize: 5, etag: 'test' })]), + ); +} vi.mock('../../packages/agent-core/node_modules/@mlc-ai/web-llm/lib/index.js', () => ({ deleteModelInCache: sdk.weights, deleteChatConfigInCache: sdk.config, @@ -38,3 +70,27 @@ it('retains valid relative artifact sources and rejects unsafe protocols', () => rememberModelSource({ id: 'unsafe', label: 'Unsafe', modelUrl: 'file:///models/private' }); expect(rememberedModelSources().map((item) => item.id)).toEqual(['custom']); }); + +it('deletes a GGUF shard group and its metadata with two inventory scans', async () => { + cacheGGUF('first', 'https://example.com/model-00001-of-00003.gguf?revision=1'); + cacheGGUF('second', 'https://example.com/model-00002-of-00003.gguf?revision=1'); + cacheGGUF('third', 'https://example.com/model-00003-of-00003.gguf?revision=1'); + cacheGGUF('other', 'https://other.example/model-00001-of-00003.gguf?revision=1'); + await deleteCachedGGUF('https://example.com/model-00001-of-00003.gguf?revision=1'); + expect([...gguf.entries.keys()]).toEqual(['other', '__metadata__other']); + expect(gguf.list).toHaveBeenCalledTimes(2); +}); + +it('rejects a missing GGUF group without deleting any cache entry', async () => { + cacheGGUF('other', 'https://example.com/other.gguf'); + await expect(deleteCachedGGUF('https://example.com/missing.gguf')).rejects.toThrow(/no longer available/); + expect([...gguf.entries.keys()]).toEqual(['other', '__metadata__other']); +}); + +it('propagates GGUF deletion failure and retains unrelated models', async () => { + cacheGGUF('model', 'https://example.com/model.gguf'); + cacheGGUF('other', 'https://example.com/other.gguf'); + gguf.remove.mockRejectedValueOnce(new DOMException('Denied', 'UnknownError')); + await expect(deleteCachedGGUF('https://example.com/model.gguf')).rejects.toMatchObject({ name: 'UnknownError' }); + expect([...gguf.entries.keys()]).toEqual(['model', '__metadata__model', 'other', '__metadata__other']); +}); diff --git a/test/unit/agent-panel.test.ts b/test/unit/agent-panel.test.ts index 528690471..0195895f8 100644 --- a/test/unit/agent-panel.test.ts +++ b/test/unit/agent-panel.test.ts @@ -1,7 +1,7 @@ import { t } from '@ranuts/shared/i18n'; import type { EditorApi } from '../../lib/agent-plugin/editor-bridge'; import { afterEach, expect, it, vi } from 'vitest'; -import { createAgentPanel } from '../../lib/agent-plugin/ui/panel'; +import { createAgentPanel, setAgentPanelOpen, setAgentPanelEnabled } from '../../lib/agent-plugin/ui/panel'; HTMLElement.prototype.scrollTo = vi.fn(); const editorState = vi.hoisted(() => ({ api: null as null | Partial })); vi.mock('@ranuts/agent-core/llm/local', () => ({ @@ -34,6 +34,97 @@ afterEach(() => { window.dispatchEvent(new Event('pagehide')); document.body.innerHTML = ''; editorState.api = null; + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it('avoids context reads while closed or disabled and refreshes immediately on reopening', async () => { + vi.useFakeTimers(); + const read = vi.fn(() => 'selected text'); + editorState.api = { + isDocumentLoadComplete: true, + isLoadFullApi: true, + pluginMethod_GetSelectedText: read, + pluginMethod_GetSelectionType: () => 'text', + }; + const panel = createAgentPanel({ background: true }); + read.mockClear(); + vi.advanceTimersByTime(3000); + expect(read).not.toHaveBeenCalled(); + setAgentPanelOpen(true); + expect(panel.querySelector('.agent-quote-context')?.textContent).toContain('selected text'); + expect(read).toHaveBeenCalled(); + setAgentPanelOpen(false); + read.mockClear(); + vi.advanceTimersByTime(3000); + expect(read).not.toHaveBeenCalled(); + await setAgentPanelEnabled(false); + setAgentPanelOpen(true); + read.mockClear(); + vi.advanceTimersByTime(3000); + expect(read).not.toHaveBeenCalled(); +}); + +it('keeps background tabs idle and updates the selection chip as soon as the tab resumes', () => { + vi.useFakeTimers(); + let visible = true; + vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => (visible ? 'visible' : 'hidden')); + let selection = 'before background'; + const read = vi.fn(() => selection); + editorState.api = { isDocumentLoadComplete: true, isLoadFullApi: true, pluginMethod_GetSelectedText: read }; + const panel = createAgentPanel(); + visible = false; + document.dispatchEvent(new Event('visibilitychange')); + read.mockClear(); + selection = 'after background'; + vi.advanceTimersByTime(3000); + expect(read).not.toHaveBeenCalled(); + visible = true; + document.dispatchEvent(new Event('visibilitychange')); + expect(panel.querySelector('.agent-quoted-text')?.textContent).toBe('after background'); + vi.restoreAllMocks(); +}); + +it('updates selection chips through SDK callbacks and detaches context subscriptions when closed', () => { + vi.useFakeTimers(); + let selection = 'before selection'; + const callbacks = new Map void>(); + editorState.api = { + isDocumentLoadComplete: true, + isLoadFullApi: true, + pluginMethod_GetSelectedText: () => selection, + asc_registerCallback: (event, callback) => { + callbacks.set(event, callback); + }, + asc_unregisterCallback: (event, callback) => { + if (callbacks.get(event) === callback) callbacks.delete(event); + }, + }; + const panel = createAgentPanel(); + selection = 'after selection'; + callbacks.get('asc_onSelectionEnd')!(); + vi.advanceTimersByTime(20); + expect(panel.querySelector('.agent-quoted-text')?.textContent).toBe('after selection'); + setAgentPanelOpen(false); + expect(callbacks.has('asc_onSelectionEnd')).toBe(false); + selection = 'while closed'; + setAgentPanelOpen(true); + expect(panel.querySelector('.agent-quoted-text')?.textContent).toBe('while closed'); +}); + +it('does not reattach native review callbacks after a terminal pagehide', () => { + const register = vi.fn(); + editorState.api = { + isDocumentLoadComplete: true, + isLoadFullApi: true, + asc_registerCallback: register, + asc_unregisterCallback: vi.fn(), + }; + createAgentPanel(); + window.dispatchEvent(new PageTransitionEvent('pagehide')); + register.mockClear(); + window.dispatchEvent(new Event('document:content-ready')); + expect(register).not.toHaveBeenCalled(); }); it('offers only browser-local backends and keeps remote credentials out of the product', () => { @@ -204,9 +295,11 @@ it('names the review-mode checkbox with its visible localized label', () => { it('syncs native tracking changes without writing settings and detaches on pagehide', () => { let enabled = false; - let callback: (() => void) | undefined; + const callbacks = new Set<(...args: unknown[]) => void>(); const setter = vi.fn(); - const unregister = vi.fn(); + const unregister = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'asc_onOnTrackRevisionsChange') callbacks.delete(handler); + }); editorState.api = { isDocumentLoadComplete: true, isLoadFullApi: true, @@ -214,8 +307,8 @@ it('syncs native tracking changes without writing settings and detaches on pageh asc_GetGlobalTrackRevisions: () => enabled, asc_SetGlobalTrackRevisions: setter, asc_SetLocalTrackRevisions: vi.fn(), - asc_registerCallback: (_event, handler) => { - callback = handler; + asc_registerCallback: (event, handler) => { + if (event === 'asc_onOnTrackRevisionsChange') callbacks.add(handler); }, asc_unregisterCallback: unregister, }; @@ -223,11 +316,12 @@ it('syncs native tracking changes without writing settings and detaches on pageh const checkbox = panel.querySelector('.agent-panel-review r-checkbox')!; expect(checkbox.getAttribute('checked')).toBe('false'); enabled = true; - callback?.(); + for (const callback of callbacks) callback(); expect(checkbox.getAttribute('checked')).toBe('true'); expect(setter).not.toHaveBeenCalled(); window.dispatchEvent(new Event('pagehide')); - expect(unregister).toHaveBeenCalledWith('asc_onOnTrackRevisionsChange', callback); + expect(unregister).toHaveBeenCalledWith('asc_onOnTrackRevisionsChange', expect.any(Function)); + expect(callbacks.size).toBe(0); }); it('returns keyboard focus to the document rail when closing a focused panel', async () => { diff --git a/test/unit/agent-sessions.test.ts b/test/unit/agent-sessions.test.ts index 21c1730ef..c2a4d7aa7 100644 --- a/test/unit/agent-sessions.test.ts +++ b/test/unit/agent-sessions.test.ts @@ -1,6 +1,7 @@ import 'fake-indexeddb/auto'; import { beforeEach, expect, it, vi } from 'vitest'; import { createConversationStore } from '../../lib/agent-plugin/ui/sessions'; +import { createConversationRepository } from '../../lib/agent-plugin/ui/conversation-db'; import { createHistoryStorage } from '../../lib/agent-plugin/ui/storage'; beforeEach(async () => { localStorage.clear(); @@ -299,3 +300,89 @@ it('retains current and legacy history when confirmed disk deletion cannot commi expect(createHistoryStorage().load()[0].content).toBe('legacy private'); expect(store.exportJSON()).toContain('current private'); }); + +it('coalesces a synchronous burst before cloning snapshots and commits only its latest messages', async () => { + const repository = createConversationRepository(); + const store = createConversationStore(undefined, { repository }); + await store.setSaving(true); + const clone = vi.spyOn(globalThis, 'structuredClone'); + try { + for (let index = 0; index < 40; index++) store.history().save([{ role: 'user', content: `update ${index}` }]); + const snapshotClones = clone.mock.calls.filter( + ([value]) => + value && typeof value === 'object' && !Array.isArray(value) && ('sessions' in value || 'messages' in value), + ); + expect(snapshotClones).toHaveLength(0); + await store.flush(); + const saved = await repository.load(); + expect(saved?.revision).toBe(2); + expect(saved?.sessions[0].messages).toEqual([{ role: 'user', content: 'update 39' }]); + } finally { + clone.mockRestore(); + } +}); + +it('coalesces updates arriving during a commit into one trailing latest save', async () => { + const repository = createConversationRepository(); + const save = repository.save.bind(repository); + let finish!: () => void; + let commits = 0; + repository.save = async (data, revision) => { + commits++; + if (revision === 1) + await new Promise((resolve) => { + finish = resolve; + }); + return save(data, revision); + }; + const store = createConversationStore(undefined, { repository }); + await store.setSaving(true); + store.history().save([{ role: 'user', content: 'in flight' }]); + await vi.waitFor(() => expect(finish).toBeTypeOf('function')); + for (let index = 0; index < 40; index++) store.history().save([{ role: 'user', content: `trailing ${index}` }]); + finish(); + await store.flush(); + expect(commits).toBe(3); + expect((await repository.load())?.sessions[0].messages).toEqual([{ role: 'user', content: 'trailing 39' }]); +}); + +it('turning saving off cancels a queued burst without replacing previously saved history', async () => { + const repository = createConversationRepository(); + const store = createConversationStore(undefined, { repository }); + store.history().save([{ role: 'user', content: 'saved' }]); + await store.setSaving(true); + store.history().save([{ role: 'user', content: 'queued private edit' }]); + await store.setSaving(false); + await store.flush(); + expect((await repository.load())?.sessions[0].messages).toEqual([{ role: 'user', content: 'saved' }]); + expect(store.history().load()).toEqual([{ role: 'user', content: 'queued private edit' }]); +}); + +it('erasing while a save is in flight cancels its queued successor and retains the tombstone', async () => { + const repository = createConversationRepository(); + const save = repository.save.bind(repository); + let finish!: () => void; + let commits = 0; + repository.save = async (data, revision) => { + commits++; + if (revision === 1) + await new Promise((resolve) => { + finish = resolve; + }); + return save(data, revision); + }; + const store = createConversationStore(undefined, { repository }); + await store.setSaving(true); + const captured = store.history(); + captured.save([{ role: 'user', content: 'in flight' }]); + await vi.waitFor(() => expect(finish).toBeTypeOf('function')); + captured.save([{ role: 'user', content: 'queued' }]); + const clearing = store.clearAll(); + finish(); + await clearing; + captured.save([{ role: 'assistant', content: 'late response' }]); + await store.flush(); + expect(commits).toBe(2); + expect(await repository.load()).toEqual({ version: 1, revision: 3, activeId: '', sessions: [] }); + expect(store.history().load()).toEqual([]); +}); diff --git a/test/unit/agent-sidebar-entry.test.ts b/test/unit/agent-sidebar-entry.test.ts index 533b7f6c8..0642af1c1 100644 --- a/test/unit/agent-sidebar-entry.test.ts +++ b/test/unit/agent-sidebar-entry.test.ts @@ -3,7 +3,23 @@ import { createSidebarEntry } from '../../lib/agent-plugin/ui/sidebar-entry'; afterEach(() => { document.body.replaceChildren(); vi.useRealTimers(); + vi.restoreAllMocks(); }); + +function frameMedia(frame: HTMLIFrameElement) { + const media = new EventTarget() as MediaQueryList; + Object.defineProperty(media, 'matches', { value: false, writable: true }); + frame.contentWindow!.matchMedia = () => media; + return (compact: boolean) => { + Object.defineProperty(media, 'matches', { value: compact }); + media.dispatchEvent(new Event('change')); + }; +} + +async function flushMutations() { + await Promise.resolve(); + await Promise.resolve(); +} it('only mounts a native rail entry when ready and removes it when unavailable', () => { const iframe = document.createElement('iframe'); iframe.name = 'frameEditor'; @@ -30,7 +46,14 @@ it('moves the same entry to the left rail in compact layouts and restores it on doc.body.innerHTML = '
'; let compact = false; - iframe.contentWindow!.matchMedia = vi.fn(() => ({ matches: compact }) as MediaQueryList); + iframe.contentWindow!.matchMedia = vi.fn( + () => + ({ + get matches() { + return compact; + }, + }) as MediaQueryList, + ); const toggle = vi.fn(); const entry = createSidebarEntry(toggle, () => 'AI Assistant'); entry.update(true, false); @@ -56,7 +79,14 @@ it('does not steal parent composer focus when moving an inactive remembered rail const iframe = document.createElement('iframe'); document.body.append(iframe); let compact = false; - iframe.contentWindow!.matchMedia = vi.fn(() => ({ matches: compact }) as MediaQueryList); + iframe.contentWindow!.matchMedia = vi.fn( + () => + ({ + get matches() { + return compact; + }, + }) as MediaQueryList, + ); const doc = iframe.contentDocument!; doc.body.innerHTML = '
'; @@ -72,6 +102,7 @@ it('does not steal parent composer focus when moving an inactive remembered rail composer.focus(); // jsdom reports only the iframe's remembered activeElement; model browser focus ownership. vi.spyOn(doc, 'hasFocus').mockReturnValue(false); + vi.spyOn(doc, 'activeElement', 'get').mockReturnValue(button); expect(doc.activeElement).toBe(button); const refocus = vi.spyOn(button, 'focus'); compact = true; @@ -93,3 +124,115 @@ it('uses the left rail when a desktop PDF has no right rail', () => { expect(frame.contentDocument!.querySelector('#left-menu .agent-sidebar-entry')).not.toBeNull(); entry.dispose(); }); + +it('mounts when the iframe and its rail arrive after readiness without a timer', async () => { + vi.useFakeTimers(); + const entry = createSidebarEntry( + () => {}, + () => 'AI', + ); + entry.update(true, false); + expect(vi.getTimerCount()).toBe(0); + const wrapper = document.createElement('div'); + const frame = document.createElement('iframe'); + wrapper.append(frame); + document.body.append(wrapper); + await flushMutations(); + const doc = frame.contentDocument!; + doc.body.innerHTML = '
'; + await flushMutations(); + doc.querySelector('#right-menu')!.innerHTML = '
'; + await flushMutations(); + expect(doc.querySelector('#right-menu .agent-sidebar-entry')).not.toBeNull(); + entry.dispose(); +}); + +it('follows media query crossings while preserving the focused entry', () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const setCompact = frameMedia(frame); + const doc = frame.contentDocument!; + doc.body.innerHTML = + '
'; + const entry = createSidebarEntry( + () => {}, + () => 'AI', + ); + entry.update(true, false); + const button = doc.querySelector('.agent-sidebar-entry')!; + button.focus(); + setCompact(true); + expect(doc.querySelector('#left-menu .agent-sidebar-entry')).toBe(button); + expect(doc.activeElement).toBe(button); + setCompact(false); + expect(doc.querySelector('#right-menu .agent-sidebar-entry')).toBe(button); + expect(doc.activeElement).toBe(button); + entry.dispose(); +}); + +it('rebinds replaced frames and rails without refreshing for editor text or its own insertion', async () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const doc = frame.contentDocument!; + doc.body.innerHTML = '
'; + const label = vi.fn(() => 'AI'); + const entry = createSidebarEntry(() => {}, label); + entry.update(true, false); + const initialCalls = label.mock.calls.length; + await flushMutations(); + expect(label.mock.calls.length).toBe(initialCalls); + doc.querySelector('#editor')!.append(doc.createTextNode('typing')); + document.body.append(document.createElement('textarea')); + await flushMutations(); + expect(label.mock.calls.length).toBe(initialCalls); + const rail = doc.createElement('div'); + rail.className = 'tool-menu-btns'; + doc.querySelector('#right-menu .tool-menu-btns')!.replaceWith(rail); + await flushMutations(); + expect(rail.querySelector('.agent-sidebar-entry')).not.toBeNull(); + const replacement = document.createElement('iframe'); + frame.replaceWith(replacement); + const replacementDoc = replacement.contentDocument!; + replacementDoc.body.innerHTML = '
'; + await flushMutations(); + expect(replacementDoc.querySelector('.agent-sidebar-entry')).not.toBeNull(); + expect(doc.querySelector('.agent-sidebar-entry')).toBeNull(); + entry.dispose(); +}); + +it('rebinds a frame document on load and ignores late callbacks after disabling or disposal', async () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const oldDoc = frame.contentDocument!; + oldDoc.body.innerHTML = '
'; + const setCompact = frameMedia(frame); + const entry = createSidebarEntry( + () => {}, + () => 'AI', + ); + entry.update(true, false); + const nextDoc = document.implementation.createHTMLDocument(); + nextDoc.body.innerHTML = + '
'; + vi.spyOn(frame, 'contentDocument', 'get').mockReturnValue(nextDoc); + frame.dispatchEvent(new Event('load')); + expect(nextDoc.querySelector('.agent-sidebar-entry')).not.toBeNull(); + expect(oldDoc.querySelector('.agent-sidebar-entry')).toBeNull(); + const queuedRail = nextDoc.createElement('div'); + queuedRail.className = 'tool-menu-btns'; + nextDoc.querySelector('#right-menu')!.append(queuedRail); + entry.update(false, false); + setCompact(true); + frame.dispatchEvent(new Event('load')); + await flushMutations(); + expect(nextDoc.querySelector('.agent-sidebar-entry')).toBeNull(); + entry.update(true, false); + expect(nextDoc.querySelector('.agent-sidebar-entry')).not.toBeNull(); + nextDoc.querySelector('#left-menu')!.append(queuedRail); + entry.dispose(); + entry.update(true, false); + setCompact(false); + frame.dispatchEvent(new Event('load')); + await flushMutations(); + expect(nextDoc.querySelector('.agent-sidebar-entry')).toBeNull(); +}); diff --git a/test/unit/assistant-offline.test.ts b/test/unit/assistant-offline.test.ts new file mode 100644 index 000000000..70ff55552 --- /dev/null +++ b/test/unit/assistant-offline.test.ts @@ -0,0 +1,230 @@ +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import { afterEach, expect, it, vi } from 'vitest'; +import { prepareAssistantOffline } from '../../lib/assistant-offline'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +function worker(addAll = vi.fn(async (_assets: string[]) => {})) { + const listeners = new Map void>(); + const cache = { + put: vi.fn(async () => {}), + addAll, + match: async (url: string) => (url.includes('/assets/') ? undefined : { text: async () => '' }), + }; + const source = readFileSync('public/sw.js', 'utf8').replace( + 'const ASSISTANT_ASSETS_TO_CACHE = [];', + 'const ASSISTANT_ASSETS_TO_CACHE = ["/assets/agent.js", "/assets/runtime.wasm"];', + ); + runInNewContext(source, { + URL, + Promise, + Date, + Response, + Set, + self: { + location: { origin: 'https://editor.example' }, + addEventListener: (type: string, fn: any) => listeners.set(type, fn), + skipWaiting: () => {}, + }, + 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; + }, + }); + 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; + }, + }); + await job!; + expect(addAll).toHaveBeenCalledWith(['/assets/agent.js', '/assets/runtime.wasm']); + expect(reply).toHaveBeenCalledWith({ type: 'ASSISTANT_PREPARED', ok: true }); +}); + +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('bounds a missing worker response and closes message ports', async () => { + vi.useFakeTimers(); + const close = vi.fn(); + vi.stubGlobal( + 'MessageChannel', + class { + port1 = { onmessage: null, close }; + port2 = { close }; + }, + ); + const postMessage = vi.fn(); + const pending = prepareAssistantOffline( + { controller: { postMessage }, ready: Promise.resolve({ active: { postMessage } }) }, + 50, + ); + 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( + 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 () => { + const postMessage = vi.fn(); + const close = vi.fn(); + let factory: any; + 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 }; + }, + }); + 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); +}); diff --git a/test/unit/chat-view-performance.test.ts b/test/unit/chat-view-performance.test.ts new file mode 100644 index 000000000..1d8d84a6d --- /dev/null +++ b/test/unit/chat-view-performance.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { ChatView } from '../../packages/chat-ui/src/chat-view'; +import type { ChatMessage } from '../../packages/chat-ui/src/types'; + +let frames: FrameRequestCallback[]; +let views: ChatView[]; +beforeEach(() => { + document.body.replaceChildren(); + frames = []; + views = []; + HTMLElement.prototype.scrollTo = vi.fn(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => frames.push(callback)); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); +}); +afterEach(() => { + views.forEach((view) => view.dispose()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +function mount(options: Partial[0]> = {}) { + const view = new ChatView({ onSend: vi.fn(), onApplyMessage: async () => 'retry', ...options }); + views.push(view); + document.body.append(view.el); + const messages = view.el.querySelector('.cui-messages')!; + return { view, messages }; +} +function countControlWrites() { + let writes = 0; + const set = Object.getOwnPropertyDescriptor(HTMLButtonElement.prototype, 'disabled')!.set!; + vi.spyOn(HTMLButtonElement.prototype, 'disabled', 'set').mockImplementation(function ( + this: HTMLButtonElement, + value: boolean, + ) { + if (this.matches('.cui-restore, .cui-apply')) writes++; + set.call(this, value); + }); + return () => writes; +} + +it.each([100, 500])('initializes %s new message actions without revisiting earlier controls', (count) => { + const { view } = mount(); + const visits = countControlWrites(); + for (let i = 0; i < count; i++) view.append({ role: 'agent', text: 'Answer' }); + expect(view.el.querySelectorAll('.cui-apply')).toHaveLength(count); + expect(visits()).toBe(count); +}); + +it('refreshes existing actions only when draft emptiness or running state changes', async () => { + const { view } = mount({ onApplyMessage: async () => 'verified' }); + view.append({ role: 'user', text: 'Original request' }); + view.append({ role: 'error', text: 'Failed' }); + view.append({ role: 'agent', text: 'Answer' }); + const restore = view.el.querySelector('.cui-restore')!; + const apply = view.el.querySelector('.cui-apply')!; + apply.click(); + await Promise.resolve(); + expect(apply.disabled).toBe(true); + const visits = countControlWrites(); + view.setInput('Draft'); + expect(restore.disabled).toBe(true); + const afterDraft = visits(); + view.setInput('Draft with more text'); + view.refreshSendAvailability(); + view.setLabels({ restore: 'Restore' }); + expect(visits()).toBe(afterDraft); + view.setRunning(true); + const afterRunning = visits(); + view.setRunning(true); + expect(visits()).toBe(afterRunning); + view.append({ role: 'error', text: 'Another failure' }); + expect(view.el.querySelectorAll('.cui-restore')[1].disabled).toBe(true); + view.setRunning(false); + view.setInput(' '); + expect(restore.disabled).toBe(false); + expect(apply.disabled).toBe(true); + restore.click(); + expect(view.getInput()).toBe('Original request'); +}); + +it.each([100, 500])('restores %s messages with one scroll and bounded geometry reads', (count) => { + const { view, messages } = mount(); + let heightReads = 0; + Object.defineProperties(messages, { + scrollHeight: { + configurable: true, + get: () => { + heightReads++; + return 1000; + }, + }, + clientHeight: { configurable: true, value: 200 }, + }); + const visits = countControlWrites(); + view.appendMany(Array.from({ length: count }, () => ({ role: 'agent', text: 'Answer' as string }))); + expect(view.getLastAnswer()).toBe('Answer'); + expect(messages.scrollTo).toHaveBeenCalledExactlyOnceWith({ top: 1000, behavior: 'auto' }); + expect(heightReads).toBeLessThanOrEqual(3); + expect(visits()).toBe(count); + // Normal append retains its immediate scrolling contract; restoration avoids + // the same layout update being repeated after every insertion. + view.clear(); + heightReads = 0; + vi.mocked(messages.scrollTo).mockClear(); + for (let i = 0; i < count; i++) view.append({ role: 'agent', text: 'Answer' }); + expect(messages.scrollTo).toHaveBeenCalledTimes(count); + expect(heightReads).toBe(count * 3); +}); + +it('keeps batch grouping, original sources, interruption metadata and request recovery', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + const { view, messages } = mount({ requireDocumentArtifact: true }); + const turns: ChatMessage[] = [ + { role: 'user', text: 'First request' }, + { role: 'tool', text: 'Read' }, + { role: 'tool', text: 'Inspect' }, + { role: 'tool', text: 'Write' }, + { role: 'agent', text: '**Body**', documentArtifact: true }, + { role: 'agent', text: 'Partial', interrupted: true }, + { role: 'status', text: 'Stopped' }, + ]; + view.appendMany(turns); + expect(messages.querySelector('details summary')?.textContent).toBe('Write'); + expect(Array.from(messages.querySelectorAll('details li'), (li) => li.textContent)).toEqual(['Read', 'Inspect']); + expect(messages.querySelector('strong')?.textContent).toBe('Body'); + expect(view.getLastAnswer()).toBe('**Body**'); + expect(view.getLastDocumentBody()).toBe('**Body**'); + expect(messages.querySelectorAll('.cui-apply')).toHaveLength(1); + messages.querySelector('[data-interrupted] .cui-copy')!.click(); + await Promise.resolve(); + expect(writeText).toHaveBeenCalledWith('Partial'); + messages.querySelector('.cui-restore')!.click(); + expect(view.getInput()).toBe('First request'); +}); + +it('preserves historical reading and leaves empty or disposed batches alone', () => { + const { view, messages } = mount(); + Object.defineProperties(messages, { + scrollHeight: { configurable: true, value: 1000 }, + clientHeight: { configurable: true, value: 200 }, + }); + messages.scrollTop = 100; + messages.dispatchEvent(new Event('scroll')); + vi.mocked(messages.scrollTo).mockClear(); + view.appendMany([{ role: 'agent', text: 'More history' }]); + expect(messages.scrollTo).not.toHaveBeenCalled(); + expect(messages.scrollTop).toBe(100); + expect(view.el.querySelector('.cui-scroll-bottom')!.hidden).toBe(false); + view.clear(); + view.appendMany([]); + expect(messages.querySelector('.cui-empty')).not.toBeNull(); + view.dispose(); + view.appendMany([{ role: 'agent', text: 'Discarded' }]); + expect(messages.querySelector('.cui-msg')).toBeNull(); +}); + +it('releases batch layout deferral when a host callback throws after an earlier message', () => { + let calls = 0; + const { view, messages } = mount({ + canApplyMessage: () => { + if (++calls === 2) throw new Error('Host unavailable'); + return true; + }, + }); + expect(() => + view.appendMany([ + { role: 'agent', text: 'Rendered' }, + { role: 'agent', text: 'Failed' }, + ]), + ).toThrow('Host unavailable'); + expect(messages.querySelectorAll('.cui-msg')).toHaveLength(1); + expect(messages.scrollTo).toHaveBeenCalledOnce(); + vi.mocked(messages.scrollTo).mockClear(); + view.append({ role: 'agent', text: 'Recovered' }); + expect(view.getLastAnswer()).toBe('Recovered'); + expect(messages.scrollTo).toHaveBeenCalledOnce(); +}); + +it.each(['complete', 'interrupt'] as const)('writes streamed source only on %s after many frames', async (ending) => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + const { view, messages } = mount(); + view.appendDelta('First'); + const row = messages.querySelector('.cui-streaming')!; + const mutations: MutationRecord[] = []; + const observer = new MutationObserver((records) => mutations.push(...records)); + observer.observe(row, { attributes: true, attributeFilter: ['data-source'] }); + try { + for (let i = 0; i < 20; i++) { + view.appendDelta(' chunk'); + frames.splice(0).forEach((callback) => callback(i * 16)); + } + await Promise.resolve(); + expect(mutations).toHaveLength(0); + view.appendDelta(' tail'); + if (ending === 'complete') view.endStream(); + else view.append({ role: 'status', text: 'Stopped' }); + await Promise.resolve(); + expect(mutations).toHaveLength(1); + expect(row.dataset.source).toBe('First' + ' chunk'.repeat(20) + ' tail'); + row.querySelector('.cui-copy')!.click(); + await Promise.resolve(); + expect(writeText).toHaveBeenCalledWith(row.dataset.source); + expect(view.getLastAnswer()).toBe(ending === 'complete' ? row.dataset.source : ''); + } finally { + observer.disconnect(); + } +}); diff --git a/test/unit/conversation-db.test.ts b/test/unit/conversation-db.test.ts index 48a59d332..b4f052a8b 100644 --- a/test/unit/conversation-db.test.ts +++ b/test/unit/conversation-db.test.ts @@ -1,6 +1,6 @@ import 'fake-indexeddb/auto'; import { createConversationStore } from '../../lib/agent-plugin/ui/sessions'; -import { beforeEach, expect, it } from 'vitest'; +import { beforeEach, expect, it, vi } from 'vitest'; import { CONVERSATION_DB_NAME, createConversationRepository, @@ -45,7 +45,7 @@ it('clears malformed records and invalidates stale writers with a revision tombs const repository = createConversationRepository(); await repository.save(data, 0); await new Promise((resolve, reject) => { - const opening = indexedDB.open(CONVERSATION_DB_NAME, 1); + const opening = indexedDB.open(CONVERSATION_DB_NAME); opening.onsuccess = () => { const db = opening.result; const tx = db.transaction('conversations', 'readwrite'); @@ -69,3 +69,226 @@ it('clears malformed records and invalidates stale writers with a revision tombs expect(await repository.load()).toEqual({ version: 1, revision: 2, activeId: '', sessions: [] }); await expect(repository.save(data, 1)).rejects.toThrow(/another tab/i); }); + +it('writes only the changed session and keeps active selection and removals atomic', async () => { + const other = '00000000-0000-0000-0000-000000000001'; + const initial: ConversationData = { + ...data, + sessions: [...data.sessions, { ...data.sessions[0], id: other, title: 'unchanged' }], + }; + const repository = createConversationRepository(); + await repository.save(initial, 0); + const put = vi.spyOn(IDBObjectStore.prototype, 'put'); + const get = vi.spyOn(IDBObjectStore.prototype, 'get'); + try { + await repository.save( + { ...initial, sessions: [{ ...initial.sessions[0], title: 'changed' }, initial.sessions[1]] }, + 1, + ); + const writes = put.mock.calls.map((args, index) => ({ + name: (put.mock.contexts[index] as IDBObjectStore).name, + args, + })); + expect(writes.filter(({ name }) => name === 'sessions').map(({ args }) => args[0].id)).toEqual(['default']); + expect(get.mock.contexts.filter((store) => (store as IDBObjectStore).name === 'sessions')).toHaveLength(0); + put.mockClear(); + await repository.save( + { ...initial, activeId: other, sessions: [{ ...initial.sessions[0], title: 'changed' }, initial.sessions[1]] }, + 2, + ); + expect(put.mock.contexts.filter((store) => (store as IDBObjectStore).name === 'sessions')).toHaveLength(0); + await repository.save({ version: 1, activeId: other, sessions: [initial.sessions[1]] }, 3); + expect(await repository.load()).toEqual({ + version: 1, + revision: 4, + activeId: other, + sessions: [initial.sessions[1]], + }); + } finally { + put.mockRestore(); + get.mockRestore(); + } +}); + +it('migrates v1 state into separate records without changing history or its revision', async () => { + await new Promise((resolve, reject) => { + const request = indexedDB.open(CONVERSATION_DB_NAME, 1); + request.onupgradeneeded = () => request.result.createObjectStore('conversations'); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction('conversations', 'readwrite'); + tx.objectStore('conversations').put({ ...data, revision: 7 }, 'state'); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onabort = () => { + db.close(); + reject(tx.error); + }; + }; + request.onerror = () => reject(request.error); + }); + const repository = createConversationRepository(); + expect(await repository.load()).toEqual({ ...data, revision: 7 }); + const put = vi.spyOn(IDBObjectStore.prototype, 'put'); + try { + expect(await repository.save(data, 7)).toBe(8); + expect(put.mock.contexts.filter((store) => (store as IDBObjectStore).name === 'sessions')).toHaveLength(0); + } finally { + put.mockRestore(); + } + await new Promise((resolve, reject) => { + const request = indexedDB.open(CONVERSATION_DB_NAME); + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains('sessions')) { + db.close(); + reject(new Error('Saved messages were not migrated to per-session records')); + return; + } + const tx = db.transaction(['conversations', 'sessions'], 'readonly'); + const state = tx.objectStore('conversations').get('state'); + const session = tx.objectStore('sessions').get('default'); + tx.oncomplete = () => { + db.close(); + expect(state.result).not.toHaveProperty('sessions'); + expect(session.result).toEqual(data.sessions[0]); + resolve(); + }; + tx.onabort = () => { + db.close(); + reject(tx.error); + }; + }; + request.onerror = () => reject(request.error); + }); +}); + +it('a fresh repository avoids rewriting unchanged sessions without requiring a prior load', async () => { + await createConversationRepository().save(data, 0); + const repository = createConversationRepository(); + const put = vi.spyOn(IDBObjectStore.prototype, 'put'); + try { + expect(await repository.save(structuredClone(data), 1)).toBe(2); + expect(put.mock.contexts.filter((store) => (store as IDBObjectStore).name === 'sessions')).toHaveLength(0); + expect(await repository.load()).toEqual({ ...data, revision: 2 }); + } finally { + put.mockRestore(); + } +}); + +it('detects a caller mutating a previously loaded record rather than trusting object identity', async () => { + const repository = createConversationRepository(); + await repository.save(data, 0); + const loaded = (await repository.load())!; + loaded.sessions[0].messages[0].content = 'changed in place'; + await repository.save(loaded, 1); + expect((await createConversationRepository().load())?.sessions[0].messages[0].content).toBe('changed in place'); +}); + +it('rolls back session writes and the revision together when a later session write fails', async () => { + const other = '00000000-0000-0000-0000-000000000001'; + const initial: ConversationData = { ...data, sessions: [...data.sessions, { ...data.sessions[0], id: other }] }; + const repository = createConversationRepository(); + await repository.save(initial, 0); + const originalPut = IDBObjectStore.prototype.put; + const put = vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (this: IDBObjectStore, value, key) { + if (this.name === 'sessions' && key === other) throw new DOMException('Full', 'QuotaExceededError'); + return originalPut.call(this, value, key); + }); + try { + await expect( + repository.save({ ...initial, sessions: initial.sessions.map((item) => ({ ...item, title: 'changed' })) }, 1), + ).rejects.toMatchObject({ name: 'QuotaExceededError' }); + } finally { + put.mockRestore(); + } + expect(await repository.load()).toEqual({ ...initial, revision: 1 }); +}); + +it('rejects a blocked v1 upgrade and closes the abandoned connection after other tabs release it', async () => { + const legacy = await new Promise((resolve, reject) => { + const request = indexedDB.open(CONVERSATION_DB_NAME, 1); + request.onupgradeneeded = () => request.result.createObjectStore('conversations'); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + try { + await new Promise((resolve, reject) => { + const tx = legacy.transaction('conversations', 'readwrite'); + tx.objectStore('conversations').put({ ...data, revision: 5 }, 'state'); + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error); + }); + await expect(createConversationRepository().load()).rejects.toThrow(/Close other tabs/i); + } finally { + legacy.close(); + } + expect(await createConversationRepository().load()).toEqual({ ...data, revision: 5 }); +}); + +it('normalizes externally stored records before exposing them through the repository', async () => { + const repository = createConversationRepository(); + await repository.save(data, 0); + await new Promise((resolve, reject) => { + const request = indexedDB.open(CONVERSATION_DB_NAME); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction('sessions', 'readwrite'); + tx.objectStore('sessions').put({ ...data.sessions[0], apiKey: 'not conversation data' }, 'default'); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onabort = () => { + db.close(); + reject(tx.error); + }; + }; + request.onerror = () => reject(request.error); + }); + expect((await repository.load())?.sessions[0]).not.toHaveProperty('apiKey'); +}); + +it('keeps damaged v1 history erasable after upgrading and retains its revision tombstone', async () => { + await new Promise((resolve, reject) => { + const request = indexedDB.open(CONVERSATION_DB_NAME, 1); + request.onupgradeneeded = () => request.result.createObjectStore('conversations'); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction('conversations', 'readwrite'); + tx.objectStore('conversations').put({ version: 1, revision: 7, sessions: 'damaged private text' }, 'state'); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onabort = () => { + db.close(); + reject(tx.error); + }; + }; + request.onerror = () => reject(request.error); + }); + const repository = createConversationRepository(); + await expect(repository.load()).rejects.toThrow(/invalid/i); + expect(await repository.clear()).toBe(8); + expect(await repository.load()).toEqual({ version: 1, revision: 8, activeId: '', sessions: [] }); + await expect(repository.save(data, 7)).rejects.toThrow(/another tab/i); +}); + +it('a racing erase cannot be undone by a save from the previous revision', async () => { + const writer = createConversationRepository(); + await writer.save(data, 0); + const eraser = createConversationRepository(); + const results = await Promise.allSettled([ + writer.save({ ...data, sessions: [{ ...data.sessions[0], title: 'racing edit' }] }, 1), + eraser.clear(), + ]); + expect(results[1].status).toBe('fulfilled'); + const erased = await writer.load(); + expect(erased?.sessions).toEqual([]); + expect(erased?.activeId).toBe(''); + expect(erased?.revision).toBeGreaterThanOrEqual(2); + await expect(writer.save(data, 1)).rejects.toThrow(/another tab/i); +}); diff --git a/test/unit/history-bounded-reads.test.ts b/test/unit/history-bounded-reads.test.ts new file mode 100644 index 000000000..a14d69871 --- /dev/null +++ b/test/unit/history-bounded-reads.test.ts @@ -0,0 +1,210 @@ +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + BLOBS_BY_DOC, + BLOBS_STORE, + DB_NAME, + DOCS_BY_UPDATED, + DOCS_STORE, + HANDLES_STORE, + openHistoryDb, + requestToPromise, + resetHistoryDbForTests, + withStores, +} from '../../lib/history/db'; +import { + getDoc, + getLatestSnapshot, + historyUsage, + putSnapshot, + resetHistoryClockForTests, +} from '../../lib/history/store'; +import type { HistoryDoc, HistorySnapshot } from '../../lib/history/types'; + +const legacyDoc: HistoryDoc = { + id: 'legacy', + title: 'Legacy.docx', + titleLower: 'legacy.docx', + ext: 'docx', + origin: 'local', + size: 19, + totalBytes: 39, + createdAt: Date.now(), + updatedAt: Date.now(), + lastOpenedAt: Date.now(), + revCount: 3, + nextRev: 25, + savedToDiskAt: 123, +}; +const legacySnapshots: HistorySnapshot[] = [ + { docId: 'legacy', rev: 2, savedAt: 300, byteLength: 7, bytes: new Uint8Array(7).fill(2) }, + { docId: 'legacy', rev: 11, savedAt: 200, byteLength: 13, bytes: new Uint8Array(13).fill(11) }, + { docId: 'legacy', rev: 24, savedAt: 100, byteLength: 19, bytes: new Uint8Array(19).fill(24) }, +]; + +async function seedLegacy(version = 2): Promise { + const request = indexedDB.open(DB_NAME, version); + request.onupgradeneeded = () => { + const docs = request.result.createObjectStore(DOCS_STORE, { keyPath: 'id' }); + docs.createIndex(DOCS_BY_UPDATED, 'updatedAt'); + const blobs = request.result.createObjectStore(BLOBS_STORE, { keyPath: ['docId', 'rev'] }); + blobs.createIndex(BLOBS_BY_DOC, 'docId'); + if (version === 2) request.result.createObjectStore(HANDLES_STORE, { keyPath: 'docId' }); + }; + const db = await requestToPromise(request); + const stores = version === 2 ? [DOCS_STORE, BLOBS_STORE, HANDLES_STORE] : [DOCS_STORE, BLOBS_STORE]; + await new Promise((resolve, reject) => { + const tx = db.transaction(stores, 'readwrite'); + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error); + tx.objectStore(DOCS_STORE).put(legacyDoc); + for (const snapshot of legacySnapshots) tx.objectStore(BLOBS_STORE).put(snapshot); + if (version === 2) tx.objectStore(HANDLES_STORE).put({ docId: 'legacy', handle: { name: 'Legacy.docx' } }); + }); + return db; +} + +function forbidBulkBlobReads(): void { + const storeGetAll = IDBObjectStore.prototype.getAll; + vi.spyOn(IDBObjectStore.prototype, 'getAll').mockImplementation(function (this: IDBObjectStore, ...args) { + if (this.name === BLOBS_STORE) throw new Error('Bulk snapshot reads copy every revision'); + return storeGetAll.apply(this, args); + }); + const indexGetAll = IDBIndex.prototype.getAll; + vi.spyOn(IDBIndex.prototype, 'getAll').mockImplementation(function (this: IDBIndex, ...args) { + if (this.objectStore.name === BLOBS_STORE) throw new Error('Bulk snapshot reads copy every revision'); + return indexGetAll.apply(this, args); + }); +} + +function forbidBlobValueCursors(): void { + const storeCursor = IDBObjectStore.prototype.openCursor; + vi.spyOn(IDBObjectStore.prototype, 'openCursor').mockImplementation(function (this: IDBObjectStore, ...args) { + if (this.name === BLOBS_STORE) throw new Error('Trimming must not clone snapshot bytes'); + return storeCursor.apply(this, args); + }); + const indexCursor = IDBIndex.prototype.openCursor; + vi.spyOn(IDBIndex.prototype, 'openCursor').mockImplementation(function (this: IDBIndex, ...args) { + if (this.objectStore.name === BLOBS_STORE) throw new Error('Trimming must not clone snapshot bytes'); + return indexCursor.apply(this, args); + }); +} + +beforeEach(() => { + resetHistoryDbForTests(); + resetHistoryClockForTests(); + vi.stubGlobal('indexedDB', new IDBFactory()); + vi.stubGlobal('navigator', { storage: { estimate: async () => ({ quota: 10000 }) } }); +}); +afterEach(() => { + resetHistoryDbForTests(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('bounded history snapshot reads', () => { + it('reads only the highest revision even when revision numbers have gaps and timestamps disagree', async () => { + (await seedLegacy()).close(); + forbidBulkBlobReads(); + const advance = IDBCursor.prototype.continue; + vi.spyOn(IDBCursor.prototype, 'continue').mockImplementation(function (this: IDBCursor, ...args) { + const store = this.source instanceof IDBIndex ? this.source.objectStore : this.source; + if (store.name === BLOBS_STORE) throw new Error('Latest read must stop at its first snapshot'); + return advance.apply(this, args); + }); + + const latest = await getLatestSnapshot('legacy'); + expect(latest?.rev).toBe(24); + expect(Array.from(latest!.bytes)).toEqual(Array.from({ length: 19 }, () => 24)); + expect(await getLatestSnapshot('missing')).toBeNull(); + }); + + it.each([1, 2])( + 'trims legacy v%s revisions without reading blob values and keeps exact accounting', + async (version) => { + (await seedLegacy(version)).close(); + forbidBulkBlobReads(); + forbidBlobValueCursors(); + + const doc = await putSnapshot({ + id: 'legacy', + title: 'Legacy.docx', + origin: 'new', + bytes: new Uint8Array(23).fill(25), + }); + expect(doc).toMatchObject({ + revCount: 3, + totalBytes: 55, + nextRev: 26, + size: 23, + origin: 'local', + savedToDiskAt: 123, + }); + expect(await historyUsage()).toBe(55); + expect(await getDoc('legacy')).toEqual(doc); + const retained = await withStores([BLOBS_STORE], 'readonly', (tx) => + requestToPromise(tx.objectStore(BLOBS_STORE).index(BLOBS_BY_DOC).getAllKeys(IDBKeyRange.only('legacy'))), + ); + expect(retained).toEqual([ + ['legacy', 11], + ['legacy', 24], + ['legacy', 25], + ]); + const handle = await withStores([HANDLES_STORE], 'readonly', (tx) => + requestToPromise(tx.objectStore(HANDLES_STORE).get('legacy')), + ); + expect(handle).toEqual(version === 2 ? { docId: 'legacy', handle: { name: 'Legacy.docx' } } : undefined); + }, + ); + + it('rolls back trimming, revision allocation and byte accounting when the owner cancels during deletion', async () => { + (await seedLegacy()).close(); + const controller = new AbortController(); + const remove = IDBObjectStore.prototype.delete; + vi.spyOn(IDBObjectStore.prototype, 'delete').mockImplementation(function (this: IDBObjectStore, ...args) { + const request = remove.apply(this, args); + if (this.name === BLOBS_STORE) controller.abort(); + return request; + }); + + expect( + await putSnapshot( + { id: 'legacy', title: 'Changed.docx', origin: 'local', bytes: new Uint8Array(23) }, + controller.signal, + ), + ).toBeNull(); + expect(await getDoc('legacy')).toEqual(legacyDoc); + expect(await historyUsage()).toBe(39); + expect((await getLatestSnapshot('legacy'))?.rev).toBe(24); + const retained = await withStores([BLOBS_STORE], 'readonly', (tx) => + requestToPromise(tx.objectStore(BLOBS_STORE).index(BLOBS_BY_DOC).getAllKeys(IDBKeyRange.only('legacy'))), + ); + expect(retained).toEqual([ + ['legacy', 2], + ['legacy', 11], + ['legacy', 24], + ]); + }); + + it('can retry a blocked legacy upgrade after the other tab releases its connection', async () => { + const oldTab = await seedLegacy(); + try { + expect(await openHistoryDb()).toBeNull(); + } finally { + oldTab.close(); + } + // Let the previously blocked open finish before retrying. Its connection + // must not remain open after the caller has been told storage is unavailable. + await new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME); + request.onsuccess = () => { + request.result.close(); + resolve(); + }; + request.onerror = () => reject(request.error); + }); + expect(await getDoc('legacy')).toEqual(legacyDoc); + expect((await getLatestSnapshot('legacy'))?.rev).toBe(24); + }); +}); diff --git a/test/unit/history-expiry-index.test.ts b/test/unit/history-expiry-index.test.ts new file mode 100644 index 000000000..aebc98e80 --- /dev/null +++ b/test/unit/history-expiry-index.test.ts @@ -0,0 +1,165 @@ +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + BLOBS_STORE, + DOCS_BY_UPDATED, + DOCS_STORE, + HANDLES_STORE, + requestToPromise, + resetHistoryDbForTests, + withStores, +} from '../../lib/history/db'; +import { + getDoc, + getLatestSnapshot, + pruneExpired, + putSnapshot, + resetHistoryClockForTests, +} from '../../lib/history/store'; +import { MAX_AGE_MS, type HistoryDoc } from '../../lib/history/types'; + +const NOW = 2_000_000_000_000; +const CUTOFF = NOW - MAX_AGE_MS; +const EXPIRED_IDS = ['boundary-a', 'boundary-b', 'earlier']; + +function doc(id: string, updatedAt: number, lastOpenedAt = updatedAt): HistoryDoc { + return { + id, + title: `${id}.docx`, + titleLower: `${id}.docx`, + ext: 'docx', + origin: 'local', + size: 1, + totalBytes: 1, + createdAt: updatedAt, + updatedAt, + lastOpenedAt, + revCount: 1, + nextRev: 1, + }; +} + +async function seedLibrary(): Promise { + const rows = Array.from({ length: 2000 }, (_, index) => doc(`recent-${index}`, NOW - index)); + rows.push( + doc('boundary-b', CUTOFF), + doc('earlier', CUTOFF - 1), + doc('boundary-a', CUTOFF), + doc('reopened-a', CUTOFF - 2, NOW), + doc('reopened-b', CUTOFF - 3, NOW), + ); + await withStores([DOCS_STORE, BLOBS_STORE, HANDLES_STORE], 'readwrite', async (tx) => { + for (const row of rows) { + tx.objectStore(DOCS_STORE).put(row); + tx.objectStore(BLOBS_STORE).put({ + docId: row.id, + rev: 0, + savedAt: row.updatedAt, + bytes: new Uint8Array([1]), + byteLength: 1, + }); + tx.objectStore(HANDLES_STORE).put({ docId: row.id, handle: { name: row.title } }); + } + return true; + }); +} + +function recordMetadataReads(): Array<{ source: string; rows: number }> { + const reads: Array<{ source: string; rows: number }> = []; + const storeGetAll = IDBObjectStore.prototype.getAll; + vi.spyOn(IDBObjectStore.prototype, 'getAll').mockImplementation(function (this: IDBObjectStore, ...args) { + const request = storeGetAll.apply(this, args); + if (this.name === DOCS_STORE) { + request.addEventListener('success', () => reads.push({ source: 'store', rows: request.result.length })); + } + return request; + }); + const indexGetAll = IDBIndex.prototype.getAll; + vi.spyOn(IDBIndex.prototype, 'getAll').mockImplementation(function (this: IDBIndex, ...args) { + const request = indexGetAll.apply(this, args); + if (this.objectStore.name === DOCS_STORE) { + const source = this.name; + request.addEventListener('success', () => reads.push({ source, rows: request.result.length })); + } + return request; + }); + return reads; +} + +async function storedHandle(id: string): Promise { + return withStores([HANDLES_STORE], 'readonly', (tx) => requestToPromise(tx.objectStore(HANDLES_STORE).get(id))); +} + +beforeEach(async () => { + resetHistoryDbForTests(); + resetHistoryClockForTests(); + vi.stubGlobal('indexedDB', new IDBFactory()); + vi.stubGlobal('navigator', { storage: { estimate: async () => ({ quota: 1_000_000 }) } }); + vi.spyOn(Date, 'now').mockReturnValue(NOW); + await seedLibrary(); +}); + +afterEach(() => { + resetHistoryDbForTests(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('indexed history expiry', () => { + it('reads only five expiry candidates among 2005 documents and preserves boundary/tie ordering', async () => { + const reads = recordMetadataReads(); + expect(await pruneExpired()).toEqual(EXPIRED_IDS); + expect(reads).toEqual([{ source: DOCS_BY_UPDATED, rows: 5 }]); + + for (const id of EXPIRED_IDS) { + expect(await getDoc(id)).toBeNull(); + expect(await getLatestSnapshot(id)).toBeNull(); + expect(await storedHandle(id)).toBeUndefined(); + } + for (const id of ['recent-0', 'recent-1999', 'reopened-a', 'reopened-b']) { + expect(await getDoc(id)).not.toBeNull(); + expect(Array.from((await getLatestSnapshot(id))!.bytes)).toEqual([1]); + expect(await storedHandle(id)).toEqual({ docId: id, handle: { name: `${id}.docx` } }); + } + }); + + it('leaves only the budget full scan when a snapshot also sweeps expiry', async () => { + const reads = recordMetadataReads(); + const saved = await putSnapshot({ id: 'fresh', title: 'Fresh.docx', origin: 'local', bytes: new Uint8Array(7) }); + expect(saved).toMatchObject({ id: 'fresh', totalBytes: 7, revCount: 1 }); + expect(reads).toEqual([ + { source: DOCS_BY_UPDATED, rows: 5 }, + { source: 'store', rows: 2003 }, + ]); + expect(await getDoc('earlier')).toBeNull(); + expect(await getDoc('reopened-a')).not.toBeNull(); + expect((await getLatestSnapshot('fresh'))?.byteLength).toBe(7); + }); + + it('rolls back expiry, handles and the new snapshot when its owner cancels during deletion', async () => { + const reads = recordMetadataReads(); + const controller = new AbortController(); + const remove = IDBObjectStore.prototype.delete; + vi.spyOn(IDBObjectStore.prototype, 'delete').mockImplementation(function (this: IDBObjectStore, ...args) { + const request = remove.apply(this, args); + if (this.name === BLOBS_STORE) controller.abort(); + return request; + }); + + expect( + await putSnapshot( + { id: 'cancelled', title: 'Cancelled.docx', origin: 'local', bytes: new Uint8Array(7) }, + controller.signal, + ), + ).toBeNull(); + expect(reads).toEqual([{ source: DOCS_BY_UPDATED, rows: 5 }]); + expect(await getDoc('cancelled')).toBeNull(); + expect(await getLatestSnapshot('cancelled')).toBeNull(); + for (const id of EXPIRED_IDS) { + expect(await getDoc(id)).not.toBeNull(); + expect(Array.from((await getLatestSnapshot(id))!.bytes)).toEqual([1]); + expect(await storedHandle(id)).toEqual({ docId: id, handle: { name: `${id}.docx` } }); + } + }); +}); diff --git a/test/unit/offline-assets-build.test.ts b/test/unit/offline-assets-build.test.ts index dee71685f..c6e39fe6a 100644 --- a/test/unit/offline-assets-build.test.ts +++ b/test/unit/offline-assets-build.test.ts @@ -3,26 +3,87 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, it } from 'vitest'; import { stampOfflineAssets } from '../../bin/offline-assets.mjs'; -it('builds an offline runtime manifest without model weights, maps or documents', async () => { + +async function fixture() { const dir = await mkdtemp(join(tmpdir(), 'document-offline-')); + await mkdir(join(dir, 'assets')); + await mkdir(join(dir, 'ran-fonts')); + await mkdir(join(dir, '.vite')); + for (const name of [ + 'editor.js', + 'shared.js', + 'recovery.js', + 'agent.js', + 'engine.js', + 'runtime.wasm', + 'base.css', + 'private.docx', + 'model.gguf', + ]) + await writeFile(join(dir, 'assets', name), ''); + 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 manifest = { + 'editor.html': { + file: 'assets/editor.js', + isEntry: true, + imports: ['shared'], + dynamicImports: ['recovery', 'lib/agent-plugin/agent-plugin.ts'], + css: ['assets/base.css'], + }, + shared: { file: 'assets/shared.js' }, + recovery: { file: 'assets/recovery.js' }, + 'lib/agent-plugin/agent-plugin.ts': { file: 'assets/agent.js', imports: ['shared'], dynamicImports: ['engine'] }, + engine: { file: 'assets/engine.js', assets: ['assets/runtime.wasm'] }, + }; + await writeFile(join(dir, '.vite', 'manifest.json'), JSON.stringify(manifest)); + return { dir, manifest }; +} + +it('keeps offline editor dependencies while deferring the assistant and its runtime', async () => { + const { dir } = await fixture(); try { - await mkdir(join(dir, 'assets')); - await mkdir(join(dir, 'ran-fonts')); - for (const name of [ - 'agent.js', - 'runtime.wasm', - 'base.css', - 'model.gguf', - 'weights.bin', - 'source.js.map', - 'private.docx', - ]) - await writeFile(join(dir, 'assets', name), ''); - await writeFile(join(dir, 'ran-fonts', 'Geist.woff2'), ''); - await writeFile(join(dir, 'sw.js'), 'const APP_ASSETS_TO_CACHE = []; // BUILD_APP_ASSETS'); - const assets = await stampOfflineAssets(dir); - expect(assets).toEqual(['/assets/agent.js', '/assets/base.css', '/assets/runtime.wasm', '/ran-fonts/Geist.woff2']); - expect(await readFile(join(dir, 'sw.js'), 'utf8')).toContain('/assets/agent.js'); + 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 }); + } +}); + +it('rejects graph dependencies that could cache an external URL or private file', 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); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/test/unit/panel-context.test.ts b/test/unit/panel-context.test.ts new file mode 100644 index 000000000..d281bfeab --- /dev/null +++ b/test/unit/panel-context.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { mountPanelContext } from '../../lib/agent-plugin/ui/panel-context'; +import type { EditorApi } from '../../lib/agent-plugin/editor-bridge'; + +let cleanup: (() => void) | undefined; +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + cleanup?.(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function editor() { + const callbacks = new Map void>(); + const api = { + asc_registerCallback: (name: string, cb: (...args: unknown[]) => void) => callbacks.set(name, cb), + asc_unregisterCallback: (name: string, cb: (...args: unknown[]) => void) => { + if (callbacks.get(name) === cb) callbacks.delete(name); + }, + } as unknown as EditorApi; + return { api, callbacks }; +} + +it('pauses hidden page subscriptions and refreshes immediately on visibility resume', () => { + let visible = true; + vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => (visible ? 'visible' : 'hidden')); + const refresh = vi.fn(); + const context = mountPanelContext({ isActive: () => true, refresh, getApi: () => null }); + cleanup = context.dispose; + expect(refresh).toHaveBeenCalledTimes(1); + visible = false; + document.dispatchEvent(new Event('visibilitychange')); + refresh.mockClear(); + vi.advanceTimersByTime(3000); + expect(refresh).not.toHaveBeenCalled(); + visible = true; + document.dispatchEvent(new Event('visibilitychange')); + expect(refresh).toHaveBeenCalledTimes(1); +}); + +it('coalesces SDK notification bursts and unregisters replaced editor callbacks', () => { + const old = editor(); + const next = editor(); + let api = old.api; + const refresh = vi.fn(); + const context = mountPanelContext({ isActive: () => true, refresh, getApi: () => api }); + cleanup = context.dispose; + refresh.mockClear(); + for (let count = 0; count < 20; count++) old.callbacks.get('asc_onSelectionEnd')!(); + expect(refresh).not.toHaveBeenCalled(); + vi.advanceTimersByTime(20); + expect(refresh).toHaveBeenCalledTimes(1); + api = next.api; + window.dispatchEvent(new Event('document:content-ready')); + expect(old.callbacks.size).toBe(0); + expect(next.callbacks.size).toBeGreaterThan(0); + context.dispose(); + expect(next.callbacks.size).toBe(0); +}); + +it('cancels a queued SDK refresh when the panel closes before delivery', () => { + const sdk = editor(); + let active = true; + const refresh = vi.fn(); + const context = mountPanelContext({ isActive: () => active, refresh, getApi: () => sdk.api }); + cleanup = context.dispose; + refresh.mockClear(); + sdk.callbacks.get('asc_onSelectionEnd')!(); + active = false; + context.update(); + vi.advanceTimersByTime(20); + expect(refresh).not.toHaveBeenCalled(); + expect(sdk.callbacks.size).toBe(0); +}); + +it('contains optional SDK registration, read and unregister failures across hide and disable', () => { + let active = true; + const sdk = { + asc_registerCallback: () => { + throw new Error('SDK replacing editor'); + }, + asc_unregisterCallback: () => { + throw new Error('SDK disposed editor'); + }, + } as unknown as EditorApi; + const context = mountPanelContext({ + isActive: () => active, + getApi: () => sdk, + refresh: () => { + throw new Error('selection not ready'); + }, + }); + cleanup = context.dispose; + active = false; + expect(() => context.update()).not.toThrow(); + expect(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true }))).not.toThrow(); + expect(() => context.dispose()).not.toThrow(); +}); + +it('refreshes only on explicit signals when the SDK cannot unregister callbacks', () => { + const register = vi.fn(); + const api = { asc_registerCallback: register } as unknown as EditorApi; + let active = false; + const refresh = vi.fn(); + const context = mountPanelContext({ isActive: () => active, refresh, getApi: () => api }); + cleanup = context.dispose; + vi.advanceTimersByTime(3000); + expect(refresh).not.toHaveBeenCalled(); + active = true; + context.update(); + expect(refresh).toHaveBeenCalledTimes(1); + expect(register).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1000); + expect(refresh).toHaveBeenCalledTimes(1); + active = false; + context.update(); + refresh.mockClear(); + vi.advanceTimersByTime(3000); + expect(refresh).not.toHaveBeenCalled(); +}); + +it('detaches and stops on BFCache pagehide, reattaching once and refreshing on pageshow', () => { + const sdk = editor(); + const refresh = vi.fn(); + const context = mountPanelContext({ isActive: () => true, refresh, getApi: () => sdk.api }); + cleanup = context.dispose; + window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })); + expect(sdk.callbacks.size).toBe(0); + refresh.mockClear(); + vi.advanceTimersByTime(3000); + expect(refresh).not.toHaveBeenCalled(); + window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); + expect(refresh).toHaveBeenCalledTimes(1); + refresh.mockClear(); + vi.advanceTimersByTime(1000); + expect(refresh).not.toHaveBeenCalled(); + context.dispose(); + window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); + expect(sdk.callbacks.size).toBe(0); +}); + +it('waits for document readiness without polling for a late SDK', () => { + const sdk = editor(); + let api: EditorApi | null = null; + const getApi = vi.fn(() => api); + const refresh = vi.fn(); + const context = mountPanelContext({ isActive: () => true, refresh, getApi }); + cleanup = context.dispose; + refresh.mockClear(); + getApi.mockClear(); + vi.advanceTimersByTime(30_000); + expect(refresh).not.toHaveBeenCalled(); + expect(getApi).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + api = sdk.api; + window.dispatchEvent(new Event('document:content-ready')); + expect(refresh).toHaveBeenCalledOnce(); + expect(sdk.callbacks.size).toBeGreaterThan(0); +}); + +it('refreshes content, sheet, protection and review changes without a selection move', () => { + const sdk = editor(); + const refresh = vi.fn(); + const context = mountPanelContext({ isActive: () => true, refresh, getApi: () => sdk.api }); + cleanup = context.dispose; + refresh.mockClear(); + sdk.callbacks.get('asc_onDocumentChanged')!(); + for (const name of [ + 'asc_onActiveSheetChanged', + 'asc_onDocumentModifiedChanged', + 'asc_onUserActionEnd', + 'asc_onSheetsChanged', + 'asc_onChangeRestrictions', + 'asc_onChangeViewMode', + 'asc_onChangeDocumentProtection', + 'asc_onChangeProtectWorkbook', + 'asc_onChangeProtectWorksheet', + 'asc_onChangeDisplayModeInReview', + 'asc_onOnTrackRevisionsChange', + ]) + sdk.callbacks.get(name)!(); + vi.advanceTimersByTime(20); + expect(refresh).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); +}); + +it('uses editor input signals even without SDK callback support and detaches on close', () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const api = {} as EditorApi; + Object.assign(frame.contentWindow!, { editor: api }); + let active = true; + const refresh = vi.fn(); + const context = mountPanelContext({ isActive: () => active, refresh, getApi: () => api }); + cleanup = () => { + context.dispose(); + frame.remove(); + }; + refresh.mockClear(); + frame.contentDocument!.dispatchEvent(new Event('input')); + frame.contentDocument!.dispatchEvent(new Event('selectionchange')); + vi.advanceTimersByTime(20); + expect(refresh).toHaveBeenCalledOnce(); + active = false; + context.update(); + refresh.mockClear(); + frame.contentDocument!.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(30_000); + expect(refresh).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/test/unit/workflow-contract.test.ts b/test/unit/workflow-contract.test.ts index 8fef3727f..f4e64cbeb 100644 --- a/test/unit/workflow-contract.test.ts +++ b/test/unit/workflow-contract.test.ts @@ -417,7 +417,8 @@ describe('.github/workflows/nightly-corpus.yml', () => { const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')) as { scripts: Record; }; - expect(pkg.scripts['lint:ts']).toBe('node bin/archive-quality.mjs lint && tsc --noEmit'); + expect(pkg.scripts['lint:ts']).toBe('node bin/archive-quality.mjs lint && pnpm run tsc'); + expect(pkg.scripts.tsc).toBe("pnpm --recursive --filter '@ranuts/*' build && tsc --noEmit"); const archiveGate = readFileSync(resolve(ROOT, 'bin/archive-quality.mjs'), 'utf8'); expect(archiveGate).toContain("'oxlint'"); expect(archiveGate).toContain("'--deny-warnings'"); diff --git a/vite.config.ts b/vite.config.ts index da3d8f2b6..1b144d92c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -212,6 +212,7 @@ export default defineConfig(({ command }) => { }, build: { outDir: 'dist', + manifest: true, // Three HTML entries: / (static landing, no editor bundle), /editor (the // app) and /history (the local library, which reads IndexedDB metadata // and never loads the editor stack). diff --git a/vitest.config.ts b/vitest.config.ts index a11c6b1aa..da61bdc7a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,15 +20,12 @@ export default defineConfig({ reporter: ['text', 'html', 'lcov'], reportsDirectory: 'coverage', thresholds: { - lines: 35, - branches: 25, - functions: 35, - // 34, not 35: patchHeaderSerializeCrash (onlyoffice-editor.ts) added - // defensive SDK-crash-guard code that, like its sibling patch - // functions in the same file, only runs against a real OnlyOffice - // iframe and isn't meaningfully unit-testable (see the "为什么 - // onlyoffice-editor.ts 覆盖率低" section in CLAUDE.md). - statements: 34, + // Application regressions must not hide behind vendor-only E2E coverage. + lines: 80, + branches: 75, + functions: 80, + statements: 80, + 'lib/agent-plugin/**/*.ts': { lines: 85, branches: 80, functions: 85, statements: 80 }, 'lib/history/**/*.ts': { lines: 85, branches: 75, functions: 85, statements: 85 }, 'packages/shared/src/**/*.ts': { lines: 90, branches: 80, functions: 90, statements: 90 }, 'packages/converter/src/**/*.ts': { lines: 60, branches: 55, functions: 65, statements: 60 },