From efdcc9d9f65eb86390b2f2b45a1af5f1312321ef Mon Sep 17 00:00:00 2001 From: aurora Date: Wed, 25 Mar 2026 08:51:59 +0800 Subject: [PATCH] feat: add sqlite opfs read-model for local queries --- ...xtension_local_storage_engineering_spec.md | 288 +++ ...-sqlite-opfs-storage-foundation-handoff.md | 210 ++ frontend/package.json | 1 + frontend/plasmo.config.ts | 48 +- frontend/public/offscreen.html | 10 - frontend/src/background/index.ts | 815 +++---- frontend/src/background/offscreenDocument.ts | 43 + frontend/src/lib/db/archiveStore.ts | 85 + frontend/src/lib/db/dexieArchiveStore.ts | 190 ++ frontend/src/lib/db/knowledgeQueryStore.ts | 508 +++++ .../src/lib/db/knowledgeWorkerProtocol.ts | 148 ++ frontend/src/lib/db/repository.ts | 1363 +++++++----- frontend/src/lib/db/storageEngineState.ts | 138 ++ frontend/src/lib/messaging/protocol.ts | 818 +++---- .../lib/services/captureSettingsService.ts | 102 +- .../src/lib/services/llmSettingsService.ts | 65 +- frontend/src/lib/services/searchService.ts | 1655 ++++++++------ frontend/src/lib/services/storageService.ts | 454 ++-- frontend/src/lib/types/index.ts | 672 +++--- frontend/src/lib/utils/chromeStorageBridge.ts | 126 ++ frontend/src/offscreen/index.ts | 464 ++-- frontend/src/options.tsx | 14 +- .../components/DataManagementPanel.tsx | 356 +-- .../sidepanel/containers/ConversationList.tsx | 599 ++--- frontend/src/sidepanel/pages/InsightsPage.tsx | 1264 ++++++----- .../src/workers/knowledge-store.worker.ts | 1959 +++++++++++++++++ pnpm-lock.yaml | 9 + 27 files changed, 8325 insertions(+), 4079 deletions(-) create mode 100644 documents/capture_engine/chrome_extension_local_storage_engineering_spec.md create mode 100644 documents/engineering_handoffs/2026-03-24-sqlite-opfs-storage-foundation-handoff.md delete mode 100644 frontend/public/offscreen.html create mode 100644 frontend/src/background/offscreenDocument.ts create mode 100644 frontend/src/lib/db/archiveStore.ts create mode 100644 frontend/src/lib/db/dexieArchiveStore.ts create mode 100644 frontend/src/lib/db/knowledgeQueryStore.ts create mode 100644 frontend/src/lib/db/knowledgeWorkerProtocol.ts create mode 100644 frontend/src/lib/db/storageEngineState.ts create mode 100644 frontend/src/lib/utils/chromeStorageBridge.ts create mode 100644 frontend/src/workers/knowledge-store.worker.ts diff --git a/documents/capture_engine/chrome_extension_local_storage_engineering_spec.md b/documents/capture_engine/chrome_extension_local_storage_engineering_spec.md new file mode 100644 index 0000000..9a71ac6 --- /dev/null +++ b/documents/capture_engine/chrome_extension_local_storage_engineering_spec.md @@ -0,0 +1,288 @@ +# Chrome Extension Local Storage Engineering Spec + +Status: Active baseline +Audience: Runtime engineers, capture/search maintainers, dashboard and data-management contributors +Scope: Local-first persistence and query execution inside the Chrome extension runtime + +## 1. Summary + +Vesti remains a Chrome extension first. The local storage roadmap therefore treats Dexie as the authoritative capture store in the near term while introducing a SQLite read-model for query-heavy paths. + +The storage foundation is now split into two internal roles: + +- `ArchiveStore` + - authoritative local archive for capture, dedupe, notes, annotations, summaries, and exports + - phase 1 implementation: Dexie / IndexedDB +- `KnowledgeQueryStore` + - query-optimized local read-model for text search, related-conversation retrieval, and materialized similarity edges + - phase 1 implementation: SQLite WASM on OPFS, owned by a single offscreen worker + +This spec defines the runtime ownership boundary, storage roles, migration rules, and rollout order for that split. + +Phase status in this version: + +- phase 1 foundation landed: + - offscreen owns worker lifecycle + - SQLite + OPFS read-model is initialized, migrated, validated, and rebuildable + - text search and relationship queries are SQLite-first +- phase 2 query rollout landed: + - library conversation listing and date/platform filtering are SQLite-first + - topic counts and dashboard aggregates are SQLite-first + - Explore weekly-range retrieval and semantic context assembly are SQLite-first after query embedding generation + +## 2. Product and engineering boundary + +### In scope + +- keep the extension fully local-first +- preserve the current `StorageApi` contract for UI consumers +- keep Dexie as the authoritative write path for capture and data mutation +- introduce SQLite + OPFS as a read-model for query-dense paths +- make offscreen the only runtime owner for storage execution outside Dexie authoring code +- make edge materialization a database concern instead of repeated JS graph reconstruction + +### Out of scope for this spec version + +- replacing Dexie as the authoritative write engine +- user-visible `.sqlite` file management +- companion app or desktop-shell packaging +- FTS, compressed vectors, or user-managed file export workflows +- artifact, attention-signal, or interaction-event persistence rollout + +## 3. Runtime ownership model + +### 3.1 Background responsibilities + +`background` is a coordinator only. + +It may: + +- call `setupOffscreenDocument()` +- forward runtime messages addressed to `target: "offscreen"` +- report bootstrap and forwarding failures +- run background-only tasks such as scheduling and vectorization triggers + +It must not: + +- execute repository or search business logic that is owned by offscreen +- open SQLite directly +- duplicate request handling that is already addressed to offscreen + +### 3.2 Offscreen responsibilities + +The offscreen document is the single owner of storage execution for offscreen-targeted requests. + +It must: + +- be created explicitly through `chrome.offscreen.createDocument()` +- remain the only runtime context that handles forwarded `target: "offscreen"` requests +- bootstrap one storage worker for SQLite / OPFS access +- keep Dexie-authoritative writes and SQLite read-model sync in the same runtime domain + +### 3.3 Worker responsibilities + +All SQLite and OPFS access must happen inside one worker created by the offscreen document. + +The worker owns: + +- SQLite initialization +- OPFS VFS setup +- schema creation and compatibility columns +- full snapshot imports +- conversation-scoped delta upserts +- materialized edge rebuilds +- read-model query execution + +No UI context, content script, or background context should access SQLite directly. + +## 4. Storage roles + +### 4.1 ArchiveStore + +`ArchiveStore` is the authoritative archive and mutation source. + +Phase 1 implementation details: + +- backing store: Dexie / IndexedDB +- owns capture persistence +- owns dedupe and update semantics +- remains the source for exports +- remains the source of truth when SQLite is unavailable or invalid + +### 4.2 KnowledgeQueryStore + +`KnowledgeQueryStore` is a local read-model fed from the authoritative archive. + +Phase 1 implementation details: + +- backing store: SQLite WASM + OPFS +- booted from the offscreen document through a worker +- synchronized from Dexie snapshots and conversation deltas +- used only for query-dense paths first +- treated as disposable and rebuildable from Dexie + +## 5. Phase 1 schema boundary + +Phase 1 SQLite tables: + +- `conversations` +- `messages` +- `topics` +- `notes` +- `annotations` +- `summaries` +- `weekly_reports` +- `explore_sessions` +- `explore_messages` +- `embeddings` +- `edges` + +Important phase 1 notes: + +- `conversations` must preserve `uuid` in addition to platform/title/time metadata +- `conversations` also persist a derived `origin_at` query column defined as `COALESCE(source_created_at, first_captured_at, created_at)` +- `conversations` maintain SQLite indexes on `origin_at`, `(platform, origin_at)`, and `(is_trash, is_archived, origin_at)` +- `embeddings` currently support `target_type = 'conversation'` only +- embeddings are stored as raw `Float32` blobs in phase 1 +- `edges` are materialized from embeddings inside SQLite + +Explicitly deferred from phase 1: + +- `artifacts` +- `attention_signals` +- `interaction_events` +- compressed vector storage +- FTS-specific search tables + +## 6. Engine state and migration rules + +SQLite rollout state is stored in `chrome.storage.local`. + +Tracked fields: + +- `activeEngine` +- `migrationState` +- `snapshotWatermark` +- `appliedWatermark` +- `lastError` + +Default posture: + +- `activeEngine = dexie` +- Dexie remains authoritative even after SQLite becomes query-ready + +### 6.1 Initial migration + +The first migration follows this sequence: + +1. initialize worker and SQLite / OPFS +2. export a full snapshot from Dexie +3. import that snapshot into SQLite +4. validate record counts and spot-check digest +5. mark the read-model ready only after validation passes + +### 6.2 Steady-state sync + +After initialization: + +- conversation-scoped writes mark affected conversation ids as dirty +- broad mutations mark the read-model for a full snapshot refresh +- a watermark in `chrome.storage.local` tracks whether pending Dexie changes have been applied +- query entrypoints flush pending changes before using SQLite + +### 6.3 Failure policy + +If SQLite init, import, validation, or sync fails: + +- record the latest error in `chrome.storage.local` +- fall back to Dexie reads +- do not block capture or other authoritative writes +- keep the UI contract stable + +The read-model must be treated as rebuildable, not mission-critical. + +## 7. Query rollout + +Phase 1 SQLite reads were limited to hot paths where JS scans or O(n^2) graph work were the weakest fit. + +Initial read-model consumers: + +- `searchConversationIdsByText` +- `searchConversationMatchesByText` +- `findAllEdges` +- `findRelatedConversations` + +Phase 2 extends SQLite-first reads into library and Explore query surfaces without changing UI contracts. + +Current additional read-model consumers: + +- `listConversations` +- `listConversationsByRange` +- `getTopics` +- `getDashboardStats` +- `retrieveRagContext` + +Current UI pushdown rules: + +- library date presets are converted to `dateRange` before storage calls +- library multi-platform filters are passed as `platforms[]` +- Insights weekly lists call `getConversations({ dateRange, includeTrash: false })` +- grouping labels such as `Started Today / This Week / Earlier` remain presentation logic in the UI + +Current boundaries that still must not move: + +- capture writes +- authoring mutations +- export generation +- LLM / embedding generation routing + +Near-term rollout order after this phase: + +1. tighten incremental sync so conversation and metadata changes avoid heavy snapshot-style refreshes +2. move more weighted ranking and aggregate queries into SQLite +3. consider FTS or vector extensions only after current SQL-first query rollout is stable + +## 8. Clear, export, and recovery rules + +- `clearAllData()` must clear both Dexie and SQLite read-model tables +- export continues to come from Dexie authoritative data in phase 1 +- LLM and other local settings stored in `chrome.storage.local` are not part of the read-model clear path +- an empty SQLite read-model after clear is still a valid ready state if Dexie is also empty + +## 9. Search and graph semantics + +Phase 1 preserves current functional behavior while changing execution strategy. + +Allowed phase 1 simplifications: + +- text search may continue to use substring matching instead of FTS +- edge reasons may remain coarse, for example `embedding_similarity` +- edge rebuild can remain batch-oriented after imports or deltas + +Required phase 1 improvement: + +- Network and related-conversation retrieval must stop depending on repeated Dexie vector scans plus ad hoc JS edge construction as the primary path + +## 10. Future phases + +### Next candidates inside the extension + +- add local embedding generation as an optional path so new vectors can be created offline +- tighten delta sync and table-local upserts to reduce write amplification +- evaluate FTS tables for text search ranking once current substring compatibility is no longer required +- evaluate `sqlite-vec` or another ANN path only after local embedding generation and query contracts are stable +- add materialized scores, time-decay ranking, and richer edge reasons beyond pure vector similarity + +### Product-shape escalation path + +- consider a companion app only if the product needs user-visible local files, larger indexing workloads, or desktop-grade lifecycle control + +## 11. Historical note + +This spec intentionally preserves the local-first extension shape: + +- UI consumers still talk to the same `StorageApi` +- Dexie remains authoritative for now +- SQLite is introduced as a rebuildable read-model rather than a product-shape change + +Any future move to companion apps or user-managed SQLite files requires a new spec rather than silently extending this one. diff --git a/documents/engineering_handoffs/2026-03-24-sqlite-opfs-storage-foundation-handoff.md b/documents/engineering_handoffs/2026-03-24-sqlite-opfs-storage-foundation-handoff.md new file mode 100644 index 0000000..f3f9c08 --- /dev/null +++ b/documents/engineering_handoffs/2026-03-24-sqlite-opfs-storage-foundation-handoff.md @@ -0,0 +1,210 @@ +# 2026-03-24 Handoff: SQLite + OPFS storage foundation for the Chrome extension + +## 0. Summary + +This branch implements the storage-foundation slice for keeping Chrome extension as the primary product shape while introducing a local SQLite read-model, plus the next query-rollout slice that moves more Library and Explore reads onto that read-model. + +The key decisions now reflected in code are: + +- Dexie remains the authoritative write path +- background no longer duplicates offscreen business logic +- offscreen becomes the single owner for offscreen-targeted runtime execution +- SQLite + OPFS run in one offscreen worker only +- query-heavy paths can use SQLite, but must fall back to Dexie safely +- Library list filtering, topic counts, dashboard stats, and Explore semantic context retrieval now attempt SQLite first + +This is a storage/runtime foundation branch, not a release-facing polish branch. + +## 1. Branch and workspace state + +- worktree: `/Users/aurora/vesti_combine/worktrees/storage-foundation-opfs` +- branch: `codex/storage-foundation-opfs` +- intended role: candidate / spike branch for storage foundation work +- `CHANGELOG.md`: intentionally not updated + +## 2. What landed + +### 2.1 New storage state and abstraction layer + +Added: + +- `frontend/src/lib/db/storageEngineState.ts` +- `frontend/src/lib/db/archiveStore.ts` +- `frontend/src/lib/db/dexieArchiveStore.ts` +- `frontend/src/lib/db/knowledgeWorkerProtocol.ts` +- `frontend/src/lib/db/knowledgeQueryStore.ts` + +These files establish: + +- persistent engine state in `chrome.storage.local` +- a Dexie-backed authoritative archive export layer +- a worker protocol for SQLite initialization, sync, and query execution +- an offscreen-owned query store that can initialize, validate, sync, and fall back + +### 2.2 Offscreen ownership and worker bootstrap + +Added: + +- `frontend/src/background/offscreenDocument.ts` +- `frontend/src/workers/knowledge-store.worker.ts` + +Updated: + +- `frontend/src/background/index.ts` +- `frontend/src/offscreen/index.ts` +- `frontend/src/options.tsx` +- `frontend/src/lib/utils/chromeStorageBridge.ts` +- `frontend/src/lib/services/captureSettingsService.ts` +- `frontend/src/lib/services/llmSettingsService.ts` + +Behavioral changes: + +- background now creates the offscreen document and forwards offscreen-targeted requests instead of handling those repository/search actions itself +- offscreen only accepts forwarded requests marked as coming through background +- offscreen bootstraps the SQLite read-model in the background +- all SQLite / OPFS access is isolated to one worker +- the offscreen container now reuses `options.html?offscreen=1` so Plasmo emits a real page for `chrome.offscreen.createDocument()` +- offscreen-specific settings reads and writes no longer touch `chrome.storage.local` directly; they bridge through background because that API is unavailable in offscreen documents + +### 2.3 SQLite schema and sync model + +Phase 1 SQLite tables now include: + +- conversations +- messages +- topics +- notes +- annotations +- summaries +- weekly_reports +- explore_sessions +- explore_messages +- embeddings +- edges + +Important implementation details: + +- `conversations.uuid` is preserved in the read-model +- `conversations.origin_at` is persisted as a derived query column for ordering and range filtering +- SQLite indexes now cover `origin_at`, `platform + origin_at`, and `trash/archive + origin_at` +- embeddings are stored as raw `Float32` blobs +- similarity edges are materialized into `edges` +- worker-side `CLEAR_KNOWLEDGE_DATA` clears all read-model tables, not only a subset + +### 2.4 Query-path rollout + +Updated: + +- `frontend/src/lib/db/repository.ts` +- `frontend/src/lib/services/searchService.ts` + +SQLite read-model is now attempted first for: + +- text search id lookup +- text search match summaries +- all-edge retrieval +- related-conversation retrieval +- library conversation listing with date/platform pushdown +- conversation-range listing used by weekly surfaces +- topic counts used to build the topic tree +- dashboard aggregate stats +- Explore semantic context retrieval after query embedding generation + +If the read-model is unavailable or stale-sync fails, these paths fall back to Dexie. + +### 2.5 UI pushdown changes + +Updated: + +- `frontend/src/sidepanel/containers/ConversationList.tsx` +- `frontend/src/sidepanel/pages/InsightsPage.tsx` + +Behavioral changes: + +- Library no longer fetches the whole conversation set and then applies date/platform filters only in React +- date presets are converted into `dateRange` before calling storage +- selected platforms are passed as `platforms[]` to storage +- weekly Insights conversation lists request `includeTrash: false` from storage, while preserving the existing UI guard +- front-end grouping labels such as `Started Today / Started This Week / Started Earlier` stay in the presentation layer + +### 2.6 UI visibility and protocol alignment + +Updated: + +- `frontend/src/lib/types/index.ts` +- `frontend/src/sidepanel/components/DataManagementPanel.tsx` +- `frontend/src/lib/messaging/protocol.ts` + +This adds: + +- storage-engine status into the data overview snapshot +- migration/error visibility in the data-management panel +- `via: "background"` support on offscreen-routed explore messages touched by the forwarding change + +## 3. Validation run during this handoff + +Executed: + +- `pnpm -C frontend build` +- `pnpm -C frontend exec tsc --noEmit` + +Result: + +- extension build passes +- no remaining TypeScript errors in the newly added storage-foundation files +- workspace still has unrelated baseline failures in `src/vendor/vesti-ui.ts` +- workspace also still has unrelated package-level missing module/type issues in `../packages/vesti-ui` + +These baseline issues prevent using the current workspace typecheck as a full green gate for the entire repo, but the new storage code is no longer the source of TS failures. + +## 4. Known limitations and follow-up items + +### 4.1 Delta application is correct but heavy + +`UPSERT_CONVERSATION_DELTA` currently reconstructs a synthetic snapshot from retained SQLite rows plus delta rows, then reuses `replaceSnapshot()`. + +That keeps logic simple and correct for a first implementation, but it is heavier than a true table-local upsert path. A later pass should replace this with direct per-table updates. + +### 4.2 Search is still substring-based + +Phase 1 text search still uses substring matching semantics rather than SQLite FTS tables. This is acceptable for compatibility, but not the final ranking/search design. + +### 4.3 Embedding generation is still on the existing route + +Explore semantic retrieval now assembles context from SQLite, but query embedding generation still uses the existing embedding route and settings flow. This branch does not make embedding generation local or offline yet. + +### 4.4 Edge rebuild is still batch-oriented + +Edges are now materialized inside SQLite, which is a better ownership model than JS recomputation in dashboard code. However, rebuild remains batch-oriented after imports and deltas and may need smarter incremental maintenance later. + +### 4.5 Runtime verification still needs extension smoke + +This branch has code-level and type-level verification, but still needs manual extension validation for: + +- offscreen creation on cold start +- worker re-open after extension reload +- OPFS availability in the target Chrome runtime +- Dexie fallback when SQLite bootstrap fails +- Gemini/Claude/ChatGPT capture after offscreen settings bootstrap, specifically verifying that no `Page failed to load.` or `STORAGE_UNAVAILABLE` errors remain +- Library date/platform filters still matching prior behavior after storage pushdown +- dashboard counts and topic counts staying aligned with Dexie results on the same dataset +- Explore scoped retrieval producing comparable sources and context drafts after the SQLite-first change + +### 4.6 Platform constraints discovered during runtime validation + +Runtime smoke uncovered two Chrome extension platform constraints that now shape this branch: + +- `chrome.offscreen.createDocument()` must point at an emitted extension page; a source-only `offscreen.html` path is not enough in the current Plasmo build output +- offscreen documents have `chrome.runtime` messaging but do not expose `chrome.storage.local`, so any storage-backed settings or engine state accessed from offscreen must use a bridge or another owner context + +## 5. Recommended next step + +Keep this work on the current candidate branch while validating the runtime path in a real extension session. + +If the flow is stable: + +1. cut a minimal `codex/pr-*` branch from a clean baseline +2. move only the storage foundation slice needed for upstream review +3. keep broader experiments or follow-up optimizations off that PR branch + +After that, the next engineering target should be local embedding generation as an optional path. That will do more for true offline Explore behavior than adding `sqlite-vec` before the generation side is local. diff --git a/frontend/package.json b/frontend/package.json index 2279b12..90a1b17 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@fontsource/newsreader": "^5.2.10", "@fontsource/nunito-sans": "^5.2.7", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@sqlite.org/sqlite-wasm": "3.51.2-build8", "@vesti/ui": "file:../packages/vesti-ui", "dexie": "^4.3.0", "katex": "^0.16.25", diff --git a/frontend/plasmo.config.ts b/frontend/plasmo.config.ts index b6be9d2..91b2b04 100644 --- a/frontend/plasmo.config.ts +++ b/frontend/plasmo.config.ts @@ -1,14 +1,19 @@ -import * as path from "path"; +import * as path from "path" export default { vite: (config) => { - const repoRoot = path.resolve(__dirname, "..", ".."); - const packagesRoot = path.resolve(repoRoot, "packages"); - const vestiUiEntry = path.resolve(packagesRoot, "vesti-ui", "src", "index.ts"); - const localNodeModules = path.resolve(__dirname, "node_modules"); + const repoRoot = path.resolve(__dirname, "..", "..") + const packagesRoot = path.resolve(repoRoot, "packages") + const vestiUiEntry = path.resolve( + packagesRoot, + "vesti-ui", + "src", + "index.ts" + ) + const localNodeModules = path.resolve(__dirname, "node_modules") - config.resolve = config.resolve || {}; - config.resolve.preserveSymlinks = false; + config.resolve = config.resolve || {} + config.resolve.preserveSymlinks = false config.resolve.alias = { ...(config.resolve.alias || {}), // Force the extension to consume the shared UI through a frontend-controlled @@ -17,21 +22,26 @@ export default { react: path.join(localNodeModules, "react"), "react-dom": path.join(localNodeModules, "react-dom"), "react/jsx-runtime": path.join(localNodeModules, "react/jsx-runtime"), - "react/jsx-dev-runtime": path.join(localNodeModules, "react/jsx-dev-runtime"), - "lucide-react": path.join(localNodeModules, "lucide-react"), - }; + "react/jsx-dev-runtime": path.join( + localNodeModules, + "react/jsx-dev-runtime" + ), + "lucide-react": path.join(localNodeModules, "lucide-react") + } config.resolve.dedupe = [ ...(config.resolve.dedupe || []), "react", "react-dom", - "lucide-react", - ]; + "lucide-react" + ] - config.server = config.server || {}; - config.server.fs = config.server.fs || {}; - const allowList = config.server.fs.allow ?? []; - config.server.fs.allow = Array.from(new Set([...allowList, repoRoot, packagesRoot])); + config.server = config.server || {} + config.server.fs = config.server.fs || {} + const allowList = config.server.fs.allow ?? [] + config.server.fs.allow = Array.from( + new Set([...allowList, repoRoot, packagesRoot]) + ) - return config; - }, -}; + return config + } +} diff --git a/frontend/public/offscreen.html b/frontend/public/offscreen.html deleted file mode 100644 index 0a4f8e0..0000000 --- a/frontend/public/offscreen.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Vesti Offscreen - - - - - diff --git a/frontend/src/background/index.ts b/frontend/src/background/index.ts index 92e9309..95625a4 100644 --- a/frontend/src/background/index.ts +++ b/frontend/src/background/index.ts @@ -1,148 +1,110 @@ -import { isRequestMessage } from "../lib/messaging/protocol"; -import type { RequestMessage, ResponseMessage } from "../lib/messaging/protocol"; -import { interceptAndPersistCapture } from "../lib/capture/storage-interceptor"; -import { - listConversations, - getTopics, - createTopic, - updateConversationTopic, - updateConversation, - listMessages, - listAnnotations, - listNotes, - searchConversationIdsByText, - searchConversationMatchesByText, - deleteConversation, - saveAnnotation, - deleteAnnotation, - createNote, - updateNote, - deleteNote, - updateConversationTitle, - renameTagAcrossConversations, - moveTagAcrossConversations, - removeTagFromConversations, - getDashboardStats, - getDataOverview, - getStorageUsage, - exportAllData, - clearAllData, - clearInsightsCache, - getSummary, - getWeeklyReport, - createExploreSession, - listExploreSessions, - getExploreSession, - getExploreMessages, - deleteExploreSession, - updateExploreSession, - updateExploreMessageContext, -} from "../lib/db/repository"; -import { runGardener } from "../lib/services/gardenerService"; -import { - findRelatedConversations, - findAllEdges, - vectorizeAllConversations, - askKnowledgeBase, -} from "../lib/services/searchService"; -import { - exportAnnotationToMyNotes, - exportAnnotationToNotion, -} from "../lib/services/annotationExportService"; -import { getLlmSettings, setLlmSettings } from "../lib/services/llmSettingsService"; -import { callInference } from "../lib/services/llmService"; -import { - generateConversationSummary, - generateWeeklyReport, -} from "../lib/services/insightGenerationService"; -import { getCaptureSettings } from "../lib/services/captureSettingsService"; +import { bumpStorageSnapshotWatermark } from "../lib/db/storageEngineState" +import { isRequestMessage } from "../lib/messaging/protocol" +import type { RequestMessage, ResponseMessage } from "../lib/messaging/protocol" +import { getCaptureSettings } from "../lib/services/captureSettingsService" +import { vectorizeAllConversations } from "../lib/services/searchService" import type { ActiveCaptureStatus, CaptureMode, ForceArchiveTransientResult, - LlmConfig, - Platform, -} from "../lib/types"; -import { logger } from "../lib/utils/logger"; -import { getLlmAccessMode, normalizeLlmSettings } from "../lib/services/llmConfig"; + Platform +} from "../lib/types" +import type { + InternalStorageBridgeMessage, + InternalStorageBridgeResponse +} from "../lib/utils/chromeStorageBridge" +import { logger } from "../lib/utils/logger" +import { setupOffscreenDocument } from "./offscreenDocument" -let isVectorizing = false; -let rerunVectorizationRequested = false; +let isVectorizing = false +let rerunVectorizationRequested = false async function runVectorizationTask(reason: string): Promise { if (isVectorizing) { - rerunVectorizationRequested = true; - return false; + rerunVectorizationRequested = true + return false } - isVectorizing = true; + isVectorizing = true try { - const created = await vectorizeAllConversations(); + const created = await vectorizeAllConversations() + if (created > 0) { + await bumpStorageSnapshotWatermark() + } logger.info("vectorize", "Vectorization task completed", { reason, - created, - }); + created + }) } catch (error) { logger.warn("vectorize", "Vectorization task failed", { reason, - error: (error as Error)?.message ?? String(error), - }); + error: (error as Error)?.message ?? String(error) + }) } finally { - isVectorizing = false; + isVectorizing = false if (rerunVectorizationRequested) { - rerunVectorizationRequested = false; - void runVectorizationTask("rerun"); + rerunVectorizationRequested = false + void runVectorizationTask("rerun") } } - return true; + return true } -function requireSettings(settings: LlmConfig | null): LlmConfig { - if (!settings) { - throw new Error("LLM_CONFIG_MISSING"); - } - const normalized = normalizeLlmSettings(settings); - const mode = getLlmAccessMode(normalized); - if (mode === "demo_proxy") { - if ((!normalized.proxyBaseUrl && !normalized.proxyUrl) || !normalized.modelId) { - throw new Error("LLM_CONFIG_MISSING"); - } - return normalized; - } - if (!normalized.apiKey || !normalized.modelId || !normalized.baseUrl) { - throw new Error("LLM_CONFIG_MISSING"); - } - return normalized; +async function forwardRequestToOffscreen( + message: RequestMessage +): Promise { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage( + { ...message, via: "background" } as RequestMessage, + (response: ResponseMessage) => { + const error = chrome.runtime.lastError + if (error) { + reject(new Error(error.message)) + return + } + resolve(response) + } + ) + }) +} + +function ensureOffscreenDocument(context: string): void { + void setupOffscreenDocument().catch((error) => { + logger.warn("background", "Failed to ensure offscreen document", { + context, + error: (error as Error).message || String(error) + }) + }) } type ContentTransientStatusResponse = | { - ok: true; + ok: true status: { - available: boolean; - reason: "ok" | "no_transient"; - platform?: Platform; - sessionUUID?: string; - transientKey?: string; - messageCount?: number; - turnCount?: number; - lastDecision?: ActiveCaptureStatus["lastDecision"]; - firstObservedAt?: number; - updatedAt?: number; - }; + available: boolean + reason: "ok" | "no_transient" + platform?: Platform + sessionUUID?: string + transientKey?: string + messageCount?: number + turnCount?: number + lastDecision?: ActiveCaptureStatus["lastDecision"] + firstObservedAt?: number + updatedAt?: number + } } - | { ok: false; error: string }; + | { ok: false; error: string } type ContentForceArchiveResponse = | { - ok: true; + ok: true result: { - saved: boolean; - newMessages: number; - conversationId?: number; - decision: ForceArchiveTransientResult["decision"]; - }; + saved: boolean + newMessages: number + conversationId?: number + decision: ForceArchiveTransientResult["decision"] + } } - | { ok: false; error: string }; + | { ok: false; error: string } const SUPPORTED_CAPTURE_HOSTS = new Set([ "chatgpt.com", @@ -155,66 +117,70 @@ const SUPPORTED_CAPTURE_HOSTS = new Set([ "www.kimi.com", "kimi.com", "kimi.moonshot.cn", - "yuanbao.tencent.com", -]); + "yuanbao.tencent.com" +]) function resolvePlatformFromUrl(url: string): Platform | undefined { try { - const host = new URL(url).hostname.toLowerCase(); + const host = new URL(url).hostname.toLowerCase() if (host === "chatgpt.com" || host === "chat.openai.com") { - return "ChatGPT"; + return "ChatGPT" } if (host === "claude.ai") { - return "Claude"; + return "Claude" } if (host === "gemini.google.com") { - return "Gemini"; + return "Gemini" } if (host === "chat.deepseek.com") { - return "DeepSeek"; + return "DeepSeek" } if (host === "www.doubao.com") { - return "Doubao"; + return "Doubao" } if (host === "chat.qwen.ai") { - return "Qwen"; + return "Qwen" } - if (host === "www.kimi.com" || host === "kimi.com" || host === "kimi.moonshot.cn") { - return "Kimi"; + if ( + host === "www.kimi.com" || + host === "kimi.com" || + host === "kimi.moonshot.cn" + ) { + return "Kimi" } if (host === "yuanbao.tencent.com") { - return "Yuanbao"; + return "Yuanbao" } } catch { - return undefined; + return undefined } - return undefined; + return undefined } function isSupportedCaptureTabUrl(url?: string): boolean { - if (!url) return false; + if (!url) return false try { - const host = new URL(url).hostname.toLowerCase(); - return SUPPORTED_CAPTURE_HOSTS.has(host); + const host = new URL(url).hostname.toLowerCase() + return SUPPORTED_CAPTURE_HOSTS.has(host) } catch { - return false; + return false } } function getModeFromSettings(mode: CaptureMode): CaptureMode { if (mode === "mirror" || mode === "smart" || mode === "manual") { - return mode; + return mode } - return "mirror"; + return "mirror" } async function getActiveTab(): Promise { return new Promise((resolve) => { chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - resolve(tabs[0] ?? null); - }); - }); + resolve(tabs[0] ?? null) + }) + }) } async function sendMessageToTab( @@ -223,28 +189,30 @@ async function sendMessageToTab( ): Promise { return new Promise((resolve, reject) => { chrome.tabs.sendMessage(tabId, message, (response: T) => { - const err = chrome.runtime.lastError; + const err = chrome.runtime.lastError if (err) { - reject(new Error(err.message)); - return; + reject(new Error(err.message)) + return } - resolve(response); - }); - }); + resolve(response) + }) + }) } -async function buildActiveCaptureStatus(mode: CaptureMode): Promise { - const tab = await getActiveTab(); +async function buildActiveCaptureStatus( + mode: CaptureMode +): Promise { + const tab = await getActiveTab() if (!tab?.id || !isSupportedCaptureTabUrl(tab.url)) { return { mode, supported: false, available: false, - reason: "unsupported_tab", - }; + reason: "unsupported_tab" + } } - const platform = tab.url ? resolvePlatformFromUrl(tab.url) : undefined; + const platform = tab.url ? resolvePlatformFromUrl(tab.url) : undefined if (mode === "mirror") { return { @@ -252,14 +220,17 @@ async function buildActiveCaptureStatus(mode: CaptureMode): Promise(tab.id, { - type: "GET_TRANSIENT_CAPTURE_STATUS", - }); + const response = await sendMessageToTab( + tab.id, + { + type: "GET_TRANSIENT_CAPTURE_STATUS" + } + ) if (!response?.ok) { return { @@ -267,8 +238,8 @@ async function buildActiveCaptureStatus(mode: CaptureMode): Promise ): Promise { - const messageType = message.type; + const messageType = message.type try { switch (message.type) { case "GET_ACTIVE_CAPTURE_STATUS": { - const settings = await getCaptureSettings(); - const mode = getModeFromSettings(settings.mode); - const data = await buildActiveCaptureStatus(mode); - return { ok: true, type: messageType, data }; + const settings = await getCaptureSettings() + const mode = getModeFromSettings(settings.mode) + const data = await buildActiveCaptureStatus(mode) + return { ok: true, type: messageType, data } } case "FORCE_ARCHIVE_TRANSIENT": { - const settings = await getCaptureSettings(); - const mode = getModeFromSettings(settings.mode); + const settings = await getCaptureSettings() + const mode = getModeFromSettings(settings.mode) if (mode === "mirror") { - throw new Error("ARCHIVE_MODE_DISABLED"); + throw new Error("ARCHIVE_MODE_DISABLED") } - const tab = await getActiveTab(); + const tab = await getActiveTab() if (!tab?.id) { - throw new Error("ACTIVE_TAB_UNAVAILABLE"); + throw new Error("ACTIVE_TAB_UNAVAILABLE") } if (!isSupportedCaptureTabUrl(tab.url)) { - throw new Error("ACTIVE_TAB_UNSUPPORTED"); + throw new Error("ACTIVE_TAB_UNSUPPORTED") } - let response: ContentForceArchiveResponse; + let response: ContentForceArchiveResponse try { - response = await sendMessageToTab(tab.id, { - type: "FORCE_ARCHIVE_TRANSIENT", - }); + response = await sendMessageToTab( + tab.id, + { + type: "FORCE_ARCHIVE_TRANSIENT" + } + ) } catch (error) { - throw new Error((error as Error).message || "FORCE_ARCHIVE_FAILED"); + throw new Error((error as Error).message || "FORCE_ARCHIVE_FAILED") } if (!response || response.ok === false) { const errorMessage = response && response.ok === false ? response.error - : "FORCE_ARCHIVE_FAILED"; - throw new Error(errorMessage || "FORCE_ARCHIVE_FAILED"); + : "FORCE_ARCHIVE_FAILED" + throw new Error(errorMessage || "FORCE_ARCHIVE_FAILED") } const data: ForceArchiveTransientResult = { @@ -346,357 +320,230 @@ async function handleBackgroundRequest( saved: response.result.saved, newMessages: response.result.newMessages, conversationId: response.result.conversationId, - decision: response.result.decision, - }; + decision: response.result.decision + } - return { ok: true, type: messageType, data }; + return { ok: true, type: messageType, data } } case "RUN_VECTORIZATION": { - void runVectorizationTask("message"); - return { ok: true, type: messageType, data: { queued: true } }; + void runVectorizationTask("message") + return { ok: true, type: messageType, data: { queued: true } } } default: return { ok: false, type: messageType, - error: `Unsupported message type: ${messageType}`, - }; + error: `Unsupported message type: ${messageType}` + } } } catch (error) { - logger.error("background", "Background request failed", error as Error); + logger.error("background", "Background request failed", error as Error) return { ok: false, type: messageType, - error: (error as Error).message || "Unknown error", - }; - } -} - -async function handleOffscreenRequest(message: RequestMessage): Promise { - const messageType = message.type; - try { - switch (message.type) { - case "CAPTURE_CONVERSATION": { - const result = await interceptAndPersistCapture(message.payload); - return { ok: true, type: messageType, data: result }; - } - case "GET_CONVERSATIONS": { - const data = await listConversations(message.payload); - return { ok: true, type: messageType, data }; - } - case "GET_TOPICS": { - const data = await getTopics(); - return { ok: true, type: messageType, data }; - } - case "CREATE_TOPIC": { - const topic = await createTopic(message.payload); - return { ok: true, type: messageType, data: { topic } }; - } - case "UPDATE_CONVERSATION_TOPIC": { - const conversation = await updateConversationTopic( - message.payload.id, - message.payload.topic_id - ); - return { ok: true, type: messageType, data: { updated: true, conversation } }; - } - case "UPDATE_CONVERSATION": { - const data = await updateConversation( - message.payload.id, - message.payload.changes - ); - return { ok: true, type: messageType, data }; - } - case "RUN_GARDENER": { - const data = await runGardener(message.payload.conversationId); - return { ok: true, type: messageType, data }; - } - case "GET_RELATED_CONVERSATIONS": { - const data = await findRelatedConversations( - message.payload.conversationId, - message.payload.limit - ); - return { ok: true, type: messageType, data }; - } - case "GET_ALL_EDGES": { - const data = await findAllEdges(message.payload); - return { ok: true, type: messageType, data }; - } - case "RENAME_FOLDER_TAG": { - const updated = await renameTagAcrossConversations( - message.payload.from, - message.payload.to - ); - return { ok: true, type: messageType, data: { updated } }; - } - case "MOVE_FOLDER_TAG": { - const updated = await moveTagAcrossConversations( - message.payload.from, - message.payload.to - ); - return { ok: true, type: messageType, data: { updated } }; - } - case "REMOVE_FOLDER_TAG": { - const updated = await removeTagFromConversations(message.payload.tag); - return { ok: true, type: messageType, data: { updated } }; - } - case "ASK_KNOWLEDGE_BASE": { - const data = await askKnowledgeBase( - message.payload.query, - message.payload.sessionId, - message.payload.limit, - message.payload.mode, - message.payload.options - ); - return { ok: true, type: messageType, data }; - } - case "GET_MESSAGES": { - const data = await listMessages(message.payload.conversationId); - return { ok: true, type: messageType, data }; - } - case "GET_ANNOTATIONS_BY_CONVERSATION": { - const data = await listAnnotations(message.payload.conversationId); - return { ok: true, type: messageType, data }; - } - case "SAVE_ANNOTATION": { - const annotation = await saveAnnotation(message.payload); - return { ok: true, type: messageType, data: { annotation } }; - } - case "DELETE_ANNOTATION": { - await deleteAnnotation(message.payload.annotationId); - return { ok: true, type: messageType, data: { deleted: true } }; - } - case "EXPORT_ANNOTATION_TO_NOTE": { - const note = await exportAnnotationToMyNotes(message.payload.annotationId); - return { ok: true, type: messageType, data: { note } }; - } - case "EXPORT_ANNOTATION_TO_NOTION": { - const data = await exportAnnotationToNotion(message.payload.annotationId); - return { ok: true, type: messageType, data }; - } - case "GET_NOTES": { - const data = await listNotes(); - return { ok: true, type: messageType, data }; - } - case "CREATE_NOTE": { - const note = await createNote(message.payload); - return { ok: true, type: messageType, data: { note } }; - } - case "UPDATE_NOTE": { - const note = await updateNote(message.payload.id, message.payload.changes); - return { ok: true, type: messageType, data: { note } }; - } - case "DELETE_NOTE": { - await deleteNote(message.payload.id); - return { ok: true, type: messageType, data: { deleted: true } }; - } - case "SEARCH_CONVERSATION_IDS_BY_TEXT": { - const data = await searchConversationIdsByText(message.payload.query); - return { ok: true, type: messageType, data }; - } - case "SEARCH_CONVERSATION_MATCHES_BY_TEXT": { - const data = await searchConversationMatchesByText(message.payload); - return { ok: true, type: messageType, data }; - } - case "DELETE_CONVERSATION": { - const deleted = await deleteConversation(message.payload.id); - return { ok: true, type: messageType, data: { deleted } }; - } - case "UPDATE_CONVERSATION_TITLE": { - const conversation = await updateConversationTitle( - message.payload.id, - message.payload.title - ); - return { ok: true, type: messageType, data: { updated: true, conversation } }; - } - case "GET_DASHBOARD_STATS": { - const data = await getDashboardStats(); - return { ok: true, type: messageType, data }; - } - case "GET_STORAGE_USAGE": { - const data = await getStorageUsage(); - return { ok: true, type: messageType, data }; - } - case "GET_DATA_OVERVIEW": { - const data = await getDataOverview(); - return { ok: true, type: messageType, data }; - } - case "EXPORT_DATA": { - const data = await exportAllData(message.payload.format); - return { ok: true, type: messageType, data }; - } - case "CLEAR_ALL_DATA": { - const cleared = await clearAllData(); - return { ok: true, type: messageType, data: { cleared } }; - } - case "CLEAR_INSIGHTS_CACHE": { - const cleared = await clearInsightsCache(); - return { ok: true, type: messageType, data: { cleared } }; - } - case "GET_LLM_SETTINGS": { - const settings = await getLlmSettings(); - return { ok: true, type: messageType, data: { settings } }; - } - case "SET_LLM_SETTINGS": { - await setLlmSettings(message.payload.settings); - return { ok: true, type: messageType, data: { saved: true } }; - } - case "TEST_LLM_CONNECTION": { - const settings = requireSettings(await getLlmSettings()); - await callInference(settings, "Reply with OK only.", { - systemPrompt: "You are a connectivity probe. Reply with OK only.", - }); - return { - ok: true, - type: messageType, - data: { ok: true, message: "Connection verified." }, - }; - } - case "GET_CONVERSATION_SUMMARY": { - const data = await getSummary(message.payload.conversationId); - return { ok: true, type: messageType, data }; - } - case "GENERATE_CONVERSATION_SUMMARY": { - const settings = requireSettings(await getLlmSettings()); - const record = await generateConversationSummary( - settings, - message.payload.conversationId - ); - return { ok: true, type: messageType, data: record }; - } - case "GET_WEEKLY_REPORT": { - const data = await getWeeklyReport( - message.payload.rangeStart, - message.payload.rangeEnd - ); - return { ok: true, type: messageType, data }; - } - case "GENERATE_WEEKLY_REPORT": { - const settings = requireSettings(await getLlmSettings()); - const record = await generateWeeklyReport( - settings, - message.payload.rangeStart, - message.payload.rangeEnd - ); - return { ok: true, type: messageType, data: record }; - } - case "CREATE_EXPLORE_SESSION": { - const sessionId = await createExploreSession(message.payload.title); - return { ok: true, type: messageType, data: { sessionId } }; - } - case "LIST_EXPLORE_SESSIONS": { - const sessions = await listExploreSessions(message.payload?.limit); - return { ok: true, type: messageType, data: sessions }; - } - case "GET_EXPLORE_SESSION": { - const session = await getExploreSession(message.payload.sessionId); - return { ok: true, type: messageType, data: session }; - } - case "GET_EXPLORE_MESSAGES": { - const msgs = await getExploreMessages(message.payload.sessionId); - return { ok: true, type: messageType, data: msgs }; - } - case "DELETE_EXPLORE_SESSION": { - await deleteExploreSession(message.payload.sessionId); - return { ok: true, type: messageType, data: { deleted: true } }; - } - case "RENAME_EXPLORE_SESSION": { - await updateExploreSession(message.payload.sessionId, { title: message.payload.title }); - return { ok: true, type: messageType, data: { updated: true } }; - } - case "UPDATE_EXPLORE_MESSAGE_CONTEXT": { - await updateExploreMessageContext( - message.payload.messageId, - message.payload.contextDraft, - message.payload.selectedContextConversationIds - ); - return { ok: true, type: messageType, data: { updated: true } }; - } - default: - return { - ok: false, - type: messageType, - error: `Unsupported message type: ${messageType}`, - }; + error: (error as Error).message || "Unknown error" } - } catch (error) { - logger.error("background", "Request failed", error as Error); - return { - ok: false, - type: messageType, - error: (error as Error).message || "Unknown error", - }; } } if (chrome?.alarms?.create) { - chrome.alarms.create("vectorize-job", { periodInMinutes: 5 }); + chrome.alarms.create("vectorize-job", { periodInMinutes: 5 }) chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === "vectorize-job") { - void runVectorizationTask("alarm"); + void runVectorizationTask("alarm") } - }); + }) } function openSidepanelForTab(tabId: number): void { if (!chrome?.sidePanel?.open) { - logger.warn("background", "sidePanel API not available"); - return; + logger.warn("background", "sidePanel API not available") + return + } + chrome.sidePanel.setOptions( + { tabId, path: "sidepanel.html", enabled: true }, + () => { + chrome.sidePanel.open({ tabId }, () => { + void chrome.runtime.lastError + }) + } + ) +} + +function isInternalStorageBridgeMessage( + message: unknown +): message is InternalStorageBridgeMessage { + if (!message || typeof message !== "object") { + return false + } + + const typed = message as { + type?: string + payload?: { key?: unknown; values?: unknown } + } + + if (typed.type === "VESTI_INTERNAL_STORAGE_GET") { + return typeof typed.payload?.key === "string" } - chrome.sidePanel.setOptions({ tabId, path: "sidepanel.html", enabled: true }, () => { - chrome.sidePanel.open({ tabId }, () => { - void chrome.runtime.lastError; - }); - }); + + if (typed.type === "VESTI_INTERNAL_STORAGE_SET") { + return ( + !!typed.payload?.values && + typeof typed.payload.values === "object" && + !Array.isArray(typed.payload.values) + ) + } + + return false } -chrome.runtime.onMessage.addListener((message: unknown, sender: chrome.runtime.MessageSender, sendResponse: (response?: any) => void) => { - if (!message || typeof message !== "object") return; - const type = (message as { type?: string }).type; - if (type !== "OPEN_SIDEPANEL") return; +async function handleInternalStorageBridgeMessage( + message: InternalStorageBridgeMessage +): Promise { + const storageArea = chrome.storage?.local + if (!storageArea) { + return { ok: false, error: "STORAGE_UNAVAILABLE" } + } + + if (message.type === "VESTI_INTERNAL_STORAGE_GET") { + return new Promise((resolve) => { + storageArea.get([message.payload.key], (result) => { + const error = chrome.runtime?.lastError + if (error) { + resolve({ ok: false, error: error.message }) + return + } - const tabId = sender.tab?.id; - if (typeof tabId === "number") { - openSidepanelForTab(tabId); - sendResponse?.({ ok: true }); - return; + resolve({ + ok: true, + value: result?.[message.payload.key] + }) + }) + }) } - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - const activeId = tabs[0]?.id; - if (typeof activeId === "number") { - openSidepanelForTab(activeId); + return new Promise((resolve) => { + storageArea.set(message.payload.values, () => { + const error = chrome.runtime?.lastError + if (error) { + resolve({ ok: false, error: error.message }) + return + } + + resolve({ ok: true }) + }) + }) +} + +chrome.runtime.onMessage.addListener( + ( + message: unknown, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: any) => void + ) => { + if (!message || typeof message !== "object") return + const type = (message as { type?: string }).type + if (type !== "OPEN_SIDEPANEL") return + + const tabId = sender.tab?.id + if (typeof tabId === "number") { + openSidepanelForTab(tabId) + sendResponse?.({ ok: true }) + return } - sendResponse?.({ ok: true }); - }); - return true; -}); + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + const activeId = tabs[0]?.id + if (typeof activeId === "number") { + openSidepanelForTab(activeId) + } + sendResponse?.({ ok: true }) + }) + + return true + } +) + chrome.runtime.onMessage.addListener( - (message: unknown, _sender: chrome.runtime.MessageSender, sendResponse: (response?: any) => void) => { - if (!isRequestMessage(message)) return; - if (message.target !== "offscreen") return; + ( + message: unknown, + _sender: chrome.runtime.MessageSender, + sendResponse: (response?: any) => void + ) => { + if (!isInternalStorageBridgeMessage(message)) return void (async () => { - const response = await handleOffscreenRequest(message); - sendResponse(response); - })(); + const response = await handleInternalStorageBridgeMessage(message) + sendResponse(response) + })() - return true; + return true } -); +) chrome.runtime.onMessage.addListener( - (message: unknown, _sender: chrome.runtime.MessageSender, sendResponse: (response?: any) => void) => { - if (!isRequestMessage(message)) return; - if (message.target !== "background") return; + ( + message: unknown, + _sender: chrome.runtime.MessageSender, + sendResponse: (response?: any) => void + ) => { + if (!isRequestMessage(message)) return + if (message.target !== "offscreen") return + if ((message as { via?: string }).via === "background") return + + void (async () => { + try { + await setupOffscreenDocument() + const response = await forwardRequestToOffscreen(message) + sendResponse(response) + } catch (error) { + logger.error( + "background", + "Failed to forward request to offscreen", + error as Error + ) + sendResponse({ + ok: false, + type: message.type, + error: (error as Error).message || "OFFSCREEN_FORWARD_FAILED" + } satisfies ResponseMessage) + } + })() + + return true + } +) + +chrome.runtime.onMessage.addListener( + ( + message: unknown, + _sender: chrome.runtime.MessageSender, + sendResponse: (response?: any) => void + ) => { + if (!isRequestMessage(message)) return + if (message.target !== "background") return void (async () => { const response = await handleBackgroundRequest( message as Extract - ); - sendResponse(response); - })(); + ) + sendResponse(response) + })() - return true; + return true } -); +) + +if (chrome.runtime?.onInstalled) { + chrome.runtime.onInstalled.addListener(() => { + ensureOffscreenDocument("onInstalled") + }) +} + +if (chrome.runtime?.onStartup) { + chrome.runtime.onStartup.addListener(() => { + ensureOffscreenDocument("onStartup") + }) +} + +ensureOffscreenDocument("background_init") diff --git a/frontend/src/background/offscreenDocument.ts b/frontend/src/background/offscreenDocument.ts new file mode 100644 index 0000000..855756e --- /dev/null +++ b/frontend/src/background/offscreenDocument.ts @@ -0,0 +1,43 @@ +const OFFSCREEN_DOCUMENT_PATH = "options.html?offscreen=1" + +let creatingOffscreenDocument: Promise | null = null + +async function hasOffscreenDocument(): Promise { + if (!chrome.runtime?.getContexts) { + return false + } + + const offscreenUrl = chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH) + const contexts = await chrome.runtime.getContexts({ + contextTypes: ["OFFSCREEN_DOCUMENT" as chrome.runtime.ContextType], + documentUrls: [offscreenUrl] + }) + + return contexts.length > 0 +} + +export async function setupOffscreenDocument(): Promise { + if (!chrome.offscreen?.createDocument) { + throw new Error("OFFSCREEN_API_UNAVAILABLE") + } + + if (await hasOffscreenDocument()) { + return + } + + if (!creatingOffscreenDocument) { + creatingOffscreenDocument = chrome.offscreen + .createDocument({ + url: OFFSCREEN_DOCUMENT_PATH, + reasons: ["WORKERS" as chrome.offscreen.Reason], + justification: + "Run a single offscreen document and storage worker for the local SQLite read-model." + }) + .then(() => undefined) + .finally(() => { + creatingOffscreenDocument = null + }) + } + + await creatingOffscreenDocument +} diff --git a/frontend/src/lib/db/archiveStore.ts b/frontend/src/lib/db/archiveStore.ts new file mode 100644 index 0000000..840688b --- /dev/null +++ b/frontend/src/lib/db/archiveStore.ts @@ -0,0 +1,85 @@ +import type { + AnnotationRecord, + ConversationRecord, + ExploreMessageRecord, + ExploreSessionRecord, + MessageRecord, + NoteRecord, + SummaryRecordRecord, + TopicRecord, + WeeklyReportRecordRecord +} from "./schema" + +export type KnowledgeConversationRecord = ConversationRecord & { id: number } +export type KnowledgeMessageRecord = MessageRecord & { id: number } +export type KnowledgeTopicRecord = TopicRecord & { id: number } +export type KnowledgeNoteRecord = NoteRecord & { id: number } +export type KnowledgeAnnotationRecord = AnnotationRecord & { id: number } +export type KnowledgeSummaryRecord = SummaryRecordRecord & { id: number } +export type KnowledgeWeeklyReportRecord = WeeklyReportRecordRecord & { + id: number +} + +export interface KnowledgeEmbeddingRecord { + target_type: "conversation" + target_id: number + chunk_id: string + text_hash: string + embedding: Float32Array + updated_at: number +} + +export interface KnowledgeSnapshot { + exportedAt: number + conversations: KnowledgeConversationRecord[] + messages: KnowledgeMessageRecord[] + topics: KnowledgeTopicRecord[] + notes: KnowledgeNoteRecord[] + annotations: KnowledgeAnnotationRecord[] + summaries: KnowledgeSummaryRecord[] + weeklyReports: KnowledgeWeeklyReportRecord[] + exploreSessions: ExploreSessionRecord[] + exploreMessages: ExploreMessageRecord[] + embeddings: KnowledgeEmbeddingRecord[] +} + +export interface KnowledgeConversationDelta { + exportedAt: number + conversationIds: number[] + conversations: KnowledgeConversationRecord[] + messages: KnowledgeMessageRecord[] + annotations: KnowledgeAnnotationRecord[] + summaries: KnowledgeSummaryRecord[] + embeddings: KnowledgeEmbeddingRecord[] +} + +export interface KnowledgeValidationDigest { + counts: { + conversations: number + messages: number + topics: number + notes: number + annotations: number + summaries: number + weeklyReports: number + exploreSessions: number + exploreMessages: number + embeddings: number + } + samples: { + conversations: number[] + messages: number[] + notes: number[] + annotations: number[] + exploreSessions: string[] + exploreMessages: string[] + } +} + +export interface ArchiveStore { + exportKnowledgeSnapshot(): Promise + exportConversationDelta( + conversationIds: number[] + ): Promise + buildValidationDigest(snapshot: KnowledgeSnapshot): KnowledgeValidationDigest +} diff --git a/frontend/src/lib/db/dexieArchiveStore.ts b/frontend/src/lib/db/dexieArchiveStore.ts new file mode 100644 index 0000000..a58556f --- /dev/null +++ b/frontend/src/lib/db/dexieArchiveStore.ts @@ -0,0 +1,190 @@ +import type { + ArchiveStore, + KnowledgeConversationDelta, + KnowledgeSnapshot +} from "./archiveStore" +import { db } from "./schema" + +function toFloat32Array(value: Float32Array | number[]): Float32Array { + return value instanceof Float32Array ? value : Float32Array.from(value) +} + +function hasNumericId( + record: T +): record is T & { id: number } { + return typeof record.id === "number" && Number.isFinite(record.id) +} + +function normalizeConversationIds(conversationIds: number[]): number[] { + return Array.from( + new Set( + conversationIds + .filter((value) => typeof value === "number" && Number.isFinite(value)) + .map((value) => Math.floor(value)) + .filter((value) => value > 0) + ) + ).sort((left, right) => left - right) +} + +async function exportEmbeddings(conversationIds?: number[]) { + const exportedAt = Date.now() + const vectorRecords = + Array.isArray(conversationIds) && conversationIds.length > 0 + ? await db.vectors + .where("conversation_id") + .anyOf(conversationIds) + .toArray() + : await db.vectors.toArray() + + return vectorRecords + .filter( + (record) => + typeof record.conversation_id === "number" && record.conversation_id > 0 + ) + .map((record) => ({ + target_type: "conversation" as const, + target_id: record.conversation_id, + chunk_id: `${record.conversation_id}:0`, + text_hash: record.text_hash, + embedding: toFloat32Array(record.embedding as Float32Array | number[]), + updated_at: exportedAt + })) +} + +function buildValidationDigest(snapshot: KnowledgeSnapshot) { + return { + counts: { + conversations: snapshot.conversations.length, + messages: snapshot.messages.length, + topics: snapshot.topics.length, + notes: snapshot.notes.length, + annotations: snapshot.annotations.length, + summaries: snapshot.summaries.length, + weeklyReports: snapshot.weeklyReports.length, + exploreSessions: snapshot.exploreSessions.length, + exploreMessages: snapshot.exploreMessages.length, + embeddings: snapshot.embeddings.length + }, + samples: { + conversations: snapshot.conversations + .slice(0, 5) + .map((record) => record.id), + messages: snapshot.messages.slice(0, 5).map((record) => record.id), + notes: snapshot.notes.slice(0, 5).map((record) => record.id), + annotations: snapshot.annotations.slice(0, 5).map((record) => record.id), + exploreSessions: snapshot.exploreSessions + .slice(0, 5) + .map((record) => record.id), + exploreMessages: snapshot.exploreMessages + .slice(0, 5) + .map((record) => record.id) + } + } +} + +async function exportKnowledgeSnapshot(): Promise { + const exportedAt = Date.now() + const [ + conversations, + messages, + topics, + notes, + annotations, + summaries, + weeklyReports, + exploreSessions, + exploreMessages, + embeddings + ] = await Promise.all([ + db.conversations.toArray(), + db.messages.toArray(), + db.topics.toArray(), + db.notes.toArray(), + db.annotations.toArray(), + db.summaries.toArray(), + db.weekly_reports.toArray(), + db.explore_sessions.orderBy("id").toArray(), + db.explore_messages.orderBy("id").toArray(), + exportEmbeddings() + ]) + + return { + exportedAt, + conversations: conversations + .filter(hasNumericId) + .sort((left, right) => left.id - right.id), + messages: messages + .filter(hasNumericId) + .sort((left, right) => left.id - right.id), + topics: topics + .filter(hasNumericId) + .sort((left, right) => left.id - right.id), + notes: notes.filter(hasNumericId).sort((left, right) => left.id - right.id), + annotations: annotations + .filter(hasNumericId) + .sort((left, right) => left.id - right.id), + summaries: summaries + .filter(hasNumericId) + .sort((left, right) => left.id - right.id), + weeklyReports: weeklyReports + .filter(hasNumericId) + .sort((left, right) => left.id - right.id), + exploreSessions, + exploreMessages, + embeddings: embeddings.sort( + (left, right) => left.target_id - right.target_id + ) + } +} + +async function exportConversationDelta( + conversationIds: number[] +): Promise { + const normalizedIds = normalizeConversationIds(conversationIds) + if (normalizedIds.length === 0) { + return { + exportedAt: Date.now(), + conversationIds: [], + conversations: [], + messages: [], + annotations: [], + summaries: [], + embeddings: [] + } + } + + const [conversations, messages, annotations, summaries, embeddings] = + await Promise.all([ + db.conversations.bulkGet(normalizedIds), + db.messages.where("conversation_id").anyOf(normalizedIds).toArray(), + db.annotations.where("conversation_id").anyOf(normalizedIds).toArray(), + db.summaries.where("conversationId").anyOf(normalizedIds).toArray(), + exportEmbeddings(normalizedIds) + ]) + + return { + exportedAt: Date.now(), + conversationIds: normalizedIds, + conversations: conversations + .filter(hasNumericId) + .sort((left, right) => left.id - right.id), + messages: messages + .filter(hasNumericId) + .sort((left, right) => left.id - right.id), + annotations: annotations + .filter(hasNumericId) + .sort((left, right) => left.id - right.id), + summaries: summaries + .filter(hasNumericId) + .sort((left, right) => left.id - right.id), + embeddings: embeddings.sort( + (left, right) => left.target_id - right.target_id + ) + } +} + +export const dexieArchiveStore: ArchiveStore = { + exportKnowledgeSnapshot, + exportConversationDelta, + buildValidationDigest +} diff --git a/frontend/src/lib/db/knowledgeQueryStore.ts b/frontend/src/lib/db/knowledgeQueryStore.ts new file mode 100644 index 0000000..94502b2 --- /dev/null +++ b/frontend/src/lib/db/knowledgeQueryStore.ts @@ -0,0 +1,508 @@ +import type { ConversationFilters } from "../messaging/protocol" +import type { + Conversation, + ConversationMatchSummary, + DashboardStats, + RelatedConversation, + SearchConversationMatchesQuery, + Topic +} from "../types" +import { logger } from "../utils/logger" +import type { KnowledgeValidationDigest } from "./archiveStore" +import { dexieArchiveStore } from "./dexieArchiveStore" +import type { + KnowledgeRagRetrievalResult, + KnowledgeWorkerRequest, + KnowledgeWorkerResponse, + KnowledgeWorkerResultMap +} from "./knowledgeWorkerProtocol" +import { + bumpStorageSnapshotWatermark, + getStorageEngineState, + markStorageEngineError, + markStorageEngineReady, + patchStorageEngineState, + type StorageEngineState +} from "./storageEngineState" + +const SQLITE_DB_FILENAME = "/vesti/knowledge-read-model.sqlite" +const SQLITE_VFS_DIRECTORY = "/vesti/opfs-sahpool" +type KnowledgeWorkerRequestType = KnowledgeWorkerRequest["type"] +type KnowledgeWorkerFailureResponse = Extract< + KnowledgeWorkerResponse, + { ok: false } +> +type KnowledgeWorkerSuccessResponse = Extract< + KnowledgeWorkerResponse, + { ok: true } +> +type KnowledgeWorkerPayload = + Extract extends { payload?: infer P } + ? P + : undefined + +function isKnowledgeWorkerFailureResponse( + response: KnowledgeWorkerResponse +): response is KnowledgeWorkerFailureResponse { + return response.ok === false +} + +function isKnowledgeWorkerSuccessResponse( + response: KnowledgeWorkerResponse +): response is KnowledgeWorkerSuccessResponse { + return response.ok === true +} + +class OffscreenKnowledgeQueryStore { + private worker: Worker | null = null + private nextRequestId = 1 + private pendingRequests = new Map< + number, + { + resolve: (value: unknown) => void + reject: (error: Error) => void + } + >() + private initPromise: Promise | null = null + private initializedThisSession = false + private pendingConversationIds = new Set() + private pendingDeletedConversationIds = new Set() + private needsFullSnapshot = false + + private ensureWorker(): Worker { + if (!this.worker) { + this.worker = new Worker( + new URL("../../workers/knowledge-store.worker.ts", import.meta.url), + { type: "module" } + ) + this.worker.addEventListener( + "message", + (event: MessageEvent) => { + const pending = this.pendingRequests.get(event.data.id) + if (!pending) { + return + } + + this.pendingRequests.delete(event.data.id) + if (isKnowledgeWorkerSuccessResponse(event.data)) { + pending.resolve(event.data.result) + return + } + + if (!isKnowledgeWorkerFailureResponse(event.data)) { + pending.reject(new Error("Unknown knowledge worker response")) + return + } + + pending.reject(new Error(event.data.error)) + } + ) + } + + return this.worker + } + + private async callWorker( + type: T, + payload?: KnowledgeWorkerPayload + ): Promise { + const worker = this.ensureWorker() + const requestId = this.nextRequestId + this.nextRequestId += 1 + + return new Promise((resolve, reject) => { + this.pendingRequests.set(requestId, { resolve, reject }) + + const request: KnowledgeWorkerRequest = { + id: requestId, + type, + ...(payload === undefined ? {} : { payload }) + } as KnowledgeWorkerRequest + + worker.postMessage(request) + }) + } + + private validateDigest( + expected: KnowledgeValidationDigest, + actual: KnowledgeValidationDigest + ): void { + const expectedSerialized = JSON.stringify(expected) + const actualSerialized = JSON.stringify(actual) + if (expectedSerialized !== actualSerialized) { + throw new Error("SQLITE_READ_MODEL_VALIDATION_FAILED") + } + } + + private async importFullSnapshot(targetWatermark: number): Promise { + const snapshot = await dexieArchiveStore.exportKnowledgeSnapshot() + const actualDigest = await this.callWorker("IMPORT_FULL_SNAPSHOT", { + snapshot + }) + const expectedDigest = dexieArchiveStore.buildValidationDigest(snapshot) + this.validateDigest(expectedDigest, actualDigest) + + this.pendingConversationIds.clear() + this.pendingDeletedConversationIds.clear() + this.needsFullSnapshot = false + + await patchStorageEngineState({ + activeEngine: "sqlite", + migrationState: "ready", + appliedWatermark: targetWatermark, + lastError: null + }) + } + + private async flushPendingChanges(targetWatermark: number): Promise { + if (this.needsFullSnapshot || this.pendingConversationIds.size === 0) { + await this.importFullSnapshot(targetWatermark) + return + } + + const conversationIds = Array.from(this.pendingConversationIds).sort( + (left, right) => left - right + ) + const deletedConversationIds = Array.from( + this.pendingDeletedConversationIds + ).sort((left, right) => left - right) + const delta = + await dexieArchiveStore.exportConversationDelta(conversationIds) + await this.callWorker("UPSERT_CONVERSATION_DELTA", { + delta, + deletedConversationIds + }) + + this.pendingConversationIds.clear() + this.pendingDeletedConversationIds.clear() + this.needsFullSnapshot = false + + await patchStorageEngineState({ + activeEngine: "sqlite", + migrationState: "ready", + appliedWatermark: targetWatermark, + lastError: null + }) + } + + async initialize(): Promise { + if (this.initPromise) { + return this.initPromise + } + + if (this.initializedThisSession) { + return getStorageEngineState() + } + + this.initPromise = (async () => { + let state = await getStorageEngineState() + if (state.activeEngine === "sqlite" && state.migrationState === "ready") { + this.initializedThisSession = true + await this.callWorker("INIT", { + dbFilename: SQLITE_DB_FILENAME, + directory: SQLITE_VFS_DIRECTORY + }) + return state + } + + await patchStorageEngineState({ + activeEngine: "dexie", + migrationState: "initializing", + lastError: null + }) + + try { + await this.callWorker("INIT", { + dbFilename: SQLITE_DB_FILENAME, + directory: SQLITE_VFS_DIRECTORY + }) + + const watermark = Date.now() + await patchStorageEngineState({ + activeEngine: "dexie", + migrationState: "migrating", + snapshotWatermark: watermark, + lastError: null + }) + + await this.importFullSnapshot(watermark) + state = await markStorageEngineReady(watermark) + this.initializedThisSession = true + return state + } catch (error) { + const message = + (error as Error).message || "SQLITE_READ_MODEL_INIT_FAILED" + logger.warn("db", "SQLite read-model initialization failed", { + error: message + }) + state = await markStorageEngineError(message) + this.initializedThisSession = true + return state + } finally { + this.initPromise = null + } + })() + + return this.initPromise + } + + async prepareForQuery(): Promise { + const initialized = await this.initialize() + if ( + initialized.activeEngine !== "sqlite" || + initialized.migrationState !== "ready" + ) { + return false + } + + const state = await getStorageEngineState() + const snapshotWatermark = state.snapshotWatermark ?? 0 + const appliedWatermark = state.appliedWatermark ?? 0 + if (snapshotWatermark <= appliedWatermark) { + return true + } + + try { + await this.flushPendingChanges(snapshotWatermark) + return true + } catch (error) { + const message = + (error as Error).message || "SQLITE_READ_MODEL_SYNC_FAILED" + await markStorageEngineError(message) + logger.warn( + "db", + "SQLite read-model sync failed; falling back to Dexie", + { + error: message + } + ) + return false + } + } + + async markConversationsDirty(conversationIds: number[]): Promise { + conversationIds.forEach((conversationId) => { + if ( + typeof conversationId !== "number" || + !Number.isFinite(conversationId) + ) { + return + } + const normalized = Math.floor(conversationId) + if (normalized <= 0) { + return + } + this.pendingDeletedConversationIds.delete(normalized) + this.pendingConversationIds.add(normalized) + }) + + await bumpStorageSnapshotWatermark() + } + + async markConversationsDeleted(conversationIds: number[]): Promise { + conversationIds.forEach((conversationId) => { + if ( + typeof conversationId !== "number" || + !Number.isFinite(conversationId) + ) { + return + } + const normalized = Math.floor(conversationId) + if (normalized <= 0) { + return + } + this.pendingConversationIds.delete(normalized) + this.pendingDeletedConversationIds.add(normalized) + }) + + await bumpStorageSnapshotWatermark() + } + + async markGlobalMutation(): Promise { + this.needsFullSnapshot = true + await bumpStorageSnapshotWatermark() + } + + async clearAfterAuthoritativeClear(): Promise { + const state = await getStorageEngineState() + const watermark = Date.now() + + this.pendingConversationIds.clear() + this.pendingDeletedConversationIds.clear() + this.needsFullSnapshot = false + + if (state.activeEngine === "sqlite" && state.migrationState === "ready") { + await this.callWorker("CLEAR_KNOWLEDGE_DATA") + } + + await patchStorageEngineState({ + snapshotWatermark: watermark, + appliedWatermark: watermark, + lastError: null + }) + } + + async searchConversationIdsByText(query: string): Promise { + if (!(await this.prepareForQuery())) { + return null + } + + return this.callWorker("SEARCH_CONVERSATION_IDS_BY_TEXT", { query }) + } + + async searchConversationMatchesByText( + params: SearchConversationMatchesQuery + ): Promise { + if (!(await this.prepareForQuery())) { + return null + } + + return this.callWorker("SEARCH_CONVERSATION_MATCHES_BY_TEXT", { params }) + } + + async getAllEdges(payload: { + threshold: number + conversationIds?: number[] + }): Promise | null> { + if (!(await this.prepareForQuery())) { + return null + } + + return this.callWorker("GET_ALL_EDGES", payload) + } + + async getRelatedConversations( + conversationId: number, + limit: number + ): Promise { + if (!(await this.prepareForQuery())) { + return null + } + + return this.callWorker("GET_RELATED_CONVERSATIONS", { + conversationId, + limit + }) + } + + async listConversations( + filters?: ConversationFilters + ): Promise { + if (!(await this.prepareForQuery())) { + return null + } + + return this.callWorker("LIST_CONVERSATIONS", { filters }) + } + + async getTopicsWithCounts(): Promise { + if (!(await this.prepareForQuery())) { + return null + } + + return this.callWorker("GET_TOPICS_WITH_COUNTS") + } + + async getDashboardStats(): Promise { + if (!(await this.prepareForQuery())) { + return null + } + + return this.callWorker("GET_DASHBOARD_STATS") + } + + async retrieveRagContext(payload: { + queryEmbedding: Float32Array + limit: number + conversationIds?: number[] + }): Promise { + if (!(await this.prepareForQuery())) { + return null + } + + return this.callWorker("RETRIEVE_RAG_CONTEXT", payload) + } +} + +const offscreenKnowledgeQueryStore = new OffscreenKnowledgeQueryStore() + +export async function initializeKnowledgeQueryStore(): Promise { + return offscreenKnowledgeQueryStore.initialize() +} + +export async function markKnowledgeConversationsDirty( + conversationIds: number[] +): Promise { + await offscreenKnowledgeQueryStore.markConversationsDirty(conversationIds) +} + +export async function markKnowledgeConversationsDeleted( + conversationIds: number[] +): Promise { + await offscreenKnowledgeQueryStore.markConversationsDeleted(conversationIds) +} + +export async function markKnowledgeGlobalMutation(): Promise { + await offscreenKnowledgeQueryStore.markGlobalMutation() +} + +export async function clearKnowledgeReadModelAfterAuthoritativeClear(): Promise { + await offscreenKnowledgeQueryStore.clearAfterAuthoritativeClear() +} + +export async function queryConversationIdsFromKnowledgeStore( + query: string +): Promise { + return offscreenKnowledgeQueryStore.searchConversationIdsByText(query) +} + +export async function queryConversationsFromKnowledgeStore( + filters?: ConversationFilters +): Promise { + return offscreenKnowledgeQueryStore.listConversations(filters) +} + +export async function queryTopicsWithCountsFromKnowledgeStore(): Promise< + Topic[] | null +> { + return offscreenKnowledgeQueryStore.getTopicsWithCounts() +} + +export async function queryDashboardStatsFromKnowledgeStore(): Promise { + return offscreenKnowledgeQueryStore.getDashboardStats() +} + +export async function queryConversationMatchesFromKnowledgeStore( + params: SearchConversationMatchesQuery +): Promise { + return offscreenKnowledgeQueryStore.searchConversationMatchesByText(params) +} + +export async function queryAllEdgesFromKnowledgeStore(payload: { + threshold: number + conversationIds?: number[] +}): Promise | null> { + return offscreenKnowledgeQueryStore.getAllEdges(payload) +} + +export async function queryRelatedConversationsFromKnowledgeStore( + conversationId: number, + limit: number +): Promise { + return offscreenKnowledgeQueryStore.getRelatedConversations( + conversationId, + limit + ) +} + +export async function retrieveRagContextFromKnowledgeStore(payload: { + queryEmbedding: Float32Array + limit: number + conversationIds?: number[] +}): Promise { + return offscreenKnowledgeQueryStore.retrieveRagContext(payload) +} diff --git a/frontend/src/lib/db/knowledgeWorkerProtocol.ts b/frontend/src/lib/db/knowledgeWorkerProtocol.ts new file mode 100644 index 0000000..a9db230 --- /dev/null +++ b/frontend/src/lib/db/knowledgeWorkerProtocol.ts @@ -0,0 +1,148 @@ +import type { ConversationFilters } from "../messaging/protocol" +import type { + Conversation, + ConversationMatchSummary, + DashboardStats, + RelatedConversation, + SearchConversationMatchesQuery, + Topic +} from "../types" +import type { + KnowledgeConversationDelta, + KnowledgeSnapshot, + KnowledgeValidationDigest +} from "./archiveStore" + +export interface KnowledgeWorkerInitPayload { + dbFilename: string + directory: string +} + +export interface KnowledgeWorkerEngineInfo { + engine: "opfs-sahpool" + dbFilename: string + sqliteVersion: string +} + +export interface KnowledgeRagRetrievalItem { + source: RelatedConversation + contextBlock: string + excerpt: string +} + +export interface KnowledgeRagRetrievalResult { + sources: RelatedConversation[] + context: string + items: KnowledgeRagRetrievalItem[] +} + +export type KnowledgeWorkerRequest = + | { + id: number + type: "INIT" + payload: KnowledgeWorkerInitPayload + } + | { + id: number + type: "IMPORT_FULL_SNAPSHOT" + payload: { + snapshot: KnowledgeSnapshot + } + } + | { + id: number + type: "UPSERT_CONVERSATION_DELTA" + payload: { + delta: KnowledgeConversationDelta + deletedConversationIds: number[] + } + } + | { + id: number + type: "CLEAR_KNOWLEDGE_DATA" + } + | { + id: number + type: "LIST_CONVERSATIONS" + payload?: { + filters?: ConversationFilters + } + } + | { + id: number + type: "GET_TOPICS_WITH_COUNTS" + } + | { + id: number + type: "GET_DASHBOARD_STATS" + } + | { + id: number + type: "SEARCH_CONVERSATION_IDS_BY_TEXT" + payload: { + query: string + } + } + | { + id: number + type: "SEARCH_CONVERSATION_MATCHES_BY_TEXT" + payload: { + params: SearchConversationMatchesQuery + } + } + | { + id: number + type: "GET_ALL_EDGES" + payload: { + threshold: number + conversationIds?: number[] + } + } + | { + id: number + type: "GET_RELATED_CONVERSATIONS" + payload: { + conversationId: number + limit: number + } + } + | { + id: number + type: "RETRIEVE_RAG_CONTEXT" + payload: { + queryEmbedding: Float32Array + limit: number + conversationIds?: number[] + } + } + +export type KnowledgeWorkerResultMap = { + INIT: KnowledgeWorkerEngineInfo + IMPORT_FULL_SNAPSHOT: KnowledgeValidationDigest + UPSERT_CONVERSATION_DELTA: KnowledgeValidationDigest + CLEAR_KNOWLEDGE_DATA: { cleared: boolean } + LIST_CONVERSATIONS: Conversation[] + GET_TOPICS_WITH_COUNTS: Topic[] + GET_DASHBOARD_STATS: DashboardStats + SEARCH_CONVERSATION_IDS_BY_TEXT: number[] + SEARCH_CONVERSATION_MATCHES_BY_TEXT: ConversationMatchSummary[] + GET_ALL_EDGES: Array<{ source: number; target: number; weight: number }> + GET_RELATED_CONVERSATIONS: RelatedConversation[] + RETRIEVE_RAG_CONTEXT: KnowledgeRagRetrievalResult +} + +export type KnowledgeWorkerResponse< + T extends keyof KnowledgeWorkerResultMap = keyof KnowledgeWorkerResultMap +> = + | { + id: number + ok: true + type: T + result: KnowledgeWorkerResultMap[T] + } + | { + id: number + ok: false + type: T + error: string + } diff --git a/frontend/src/lib/db/repository.ts b/frontend/src/lib/db/repository.ts index ddbc812..ae1983a 100644 --- a/frontend/src/lib/db/repository.ts +++ b/frontend/src/lib/db/repository.ts @@ -1,44 +1,48 @@ +import { + getConversationCaptureFreshnessAt, + getConversationFirstCapturedAt, + getConversationOriginAt +} from "../conversations/timestamps" +import type { ConversationFilters } from "../messaging/protocol" +import { normalizePlatform, SUPPORTED_PLATFORMS } from "../platform" +import { + buildExportJsonV1, + buildExportMdV1, + buildExportTxtV1 +} from "../services/exportSerializers" import type { Annotation, Conversation, ConversationMatchSummary, ConversationSummaryV2, + DashboardStats, DataOverviewSnapshot, ExploreAgentMeta, ExploreMessage, ExploreSession, ExportFormat, ExportPayload, + InsightFormat, + InsightStatus, Message, Note, - RelatedConversation, - Topic, - DashboardStats, Platform, - InsightFormat, - InsightStatus, + RelatedConversation, + SearchConversationMatchesQuery, StorageUsageSnapshot, SummaryRecord, + Topic, WeeklyLiteReportV1, - WeeklyReportRecord, - SearchConversationMatchesQuery, -} from "../types"; -import type { ConversationFilters } from "../messaging/protocol"; + WeeklyReportRecord +} from "../types" import { - buildExportJsonV1, - buildExportMdV1, - buildExportTxtV1, -} from "../services/exportSerializers"; -import { - getConversationCaptureFreshnessAt, - getConversationFirstCapturedAt, - getConversationOriginAt, -} from "../conversations/timestamps"; -import { SUPPORTED_PLATFORMS, normalizePlatform } from "../platform"; -import { normalizeMessageArtifacts } from "../utils/messageArtifacts"; -import { normalizeMessageCitations } from "../utils/messageCitations"; -import { db } from "./schema"; -import { enforceStorageWriteGuard, getStorageUsageSnapshot } from "./storageLimits"; + queryConversationIdsFromKnowledgeStore, + queryConversationMatchesFromKnowledgeStore, + queryConversationsFromKnowledgeStore, + queryDashboardStatsFromKnowledgeStore, + queryTopicsWithCountsFromKnowledgeStore +} from "./knowledgeQueryStore" +import { db } from "./schema" import type { AnnotationRecord, ConversationRecord, @@ -48,38 +52,45 @@ import type { NoteRecord, SummaryRecordRecord, TopicRecord, - WeeklyReportRecordRecord, -} from "./schema"; + WeeklyReportRecordRecord +} from "./schema" +import { getStorageEngineState } from "./storageEngineState" +import { normalizeMessageArtifacts } from "../utils/messageArtifacts" +import { normalizeMessageCitations } from "../utils/messageCitations" +import { + enforceStorageWriteGuard, + getStorageUsageSnapshot +} from "./storageLimits" -type ExploreSourceRecord = RelatedConversation; +type ExploreSourceRecord = RelatedConversation function normalizeExploreSources( sources: ExploreSourceRecord[] | undefined ): ExploreSourceRecord[] | undefined { if (!sources) { - return undefined; + return undefined } return sources.flatMap((source) => { - const platform = normalizePlatform(source.platform); + const platform = normalizePlatform(source.platform) if (!platform) { - return []; + return [] } - return [{ ...source, platform }]; - }); + return [{ ...source, platform }] + }) } function parseExploreSources(raw?: string): ExploreSourceRecord[] | undefined { if (!raw) { - return undefined; + return undefined } try { - const parsed = JSON.parse(raw) as ExploreSourceRecord[]; - return normalizeExploreSources(parsed); + const parsed = JSON.parse(raw) as ExploreSourceRecord[] + return normalizeExploreSources(parsed) } catch { - return undefined; + return undefined } } @@ -87,20 +98,20 @@ function normalizeExploreAgentMeta( meta: ExploreAgentMeta | undefined ): ExploreAgentMeta | undefined { if (!meta) { - return undefined; + return undefined } const normalizedCandidates = Array.isArray(meta.contextCandidates) ? meta.contextCandidates .filter((candidate) => candidate && typeof candidate === "object") .map((candidate) => { - const platform = normalizePlatform(candidate.platform); + const platform = normalizePlatform(candidate.platform) return { ...candidate, - platform: platform ?? candidate.platform, - }; + platform: platform ?? candidate.platform + } }) - : undefined; + : undefined const selectedContextConversationIds = Array.isArray( meta.selectedContextConversationIds @@ -108,7 +119,7 @@ function normalizeExploreAgentMeta( ? meta.selectedContextConversationIds.filter( (id): id is number => typeof id === "number" && Number.isFinite(id) ) - : undefined; + : undefined const toolCalls = Array.isArray(meta.toolCalls) ? meta.toolCalls @@ -116,9 +127,11 @@ function normalizeExploreAgentMeta( .map((toolCall) => ({ ...toolCall, description: - typeof toolCall.description === "string" ? toolCall.description : undefined, + typeof toolCall.description === "string" + ? toolCall.description + : undefined })) - : []; + : [] const searchScope = meta.searchScope && typeof meta.searchScope === "object" @@ -129,14 +142,16 @@ function normalizeExploreAgentMeta( : ("all" as const), conversationIds: Array.isArray(meta.searchScope.conversationIds) ? meta.searchScope.conversationIds.filter( - (id): id is number => typeof id === "number" && Number.isFinite(id) + (id): id is number => + typeof id === "number" && Number.isFinite(id) ) - : undefined, + : undefined } - : undefined; + : undefined const requestedTimeScope = - meta.plan?.requestedTimeScope && typeof meta.plan.requestedTimeScope === "object" + meta.plan?.requestedTimeScope && + typeof meta.plan.requestedTimeScope === "object" ? { preset: meta.plan.requestedTimeScope.preset === "current_week_to_date" || @@ -156,12 +171,13 @@ function normalizeExploreAgentMeta( endDate: typeof meta.plan.requestedTimeScope.endDate === "string" ? meta.plan.requestedTimeScope.endDate - : undefined, + : undefined } - : undefined; + : undefined const resolvedTimeScope = - meta.plan?.resolvedTimeScope && typeof meta.plan.resolvedTimeScope === "object" + meta.plan?.resolvedTimeScope && + typeof meta.plan.resolvedTimeScope === "object" ? { preset: meta.plan.resolvedTimeScope.preset === "current_week_to_date" || @@ -191,9 +207,9 @@ function normalizeExploreAgentMeta( endDate: typeof meta.plan.resolvedTimeScope.endDate === "string" ? meta.plan.resolvedTimeScope.endDate - : "", + : "" } - : undefined; + : undefined const plan = meta.plan && typeof meta.plan === "object" @@ -206,14 +222,17 @@ function normalizeExploreAgentMeta( ? meta.plan.intent : ("fact_lookup" as const), reason: - typeof meta.plan.reason === "string" ? meta.plan.reason : "UNSPECIFIED_REASON", + typeof meta.plan.reason === "string" + ? meta.plan.reason + : "UNSPECIFIED_REASON", preferredPath: meta.plan.preferredPath === "weekly_summary" || meta.plan.preferredPath === "clarify" ? meta.plan.preferredPath : ("rag" as const), sourceLimit: - typeof meta.plan.sourceLimit === "number" && Number.isFinite(meta.plan.sourceLimit) + typeof meta.plan.sourceLimit === "number" && + Number.isFinite(meta.plan.sourceLimit) ? meta.plan.sourceLimit : 5, summaryTargetCount: @@ -222,7 +241,9 @@ function normalizeExploreAgentMeta( ? meta.plan.summaryTargetCount : 0, answerGoal: - typeof meta.plan.answerGoal === "string" ? meta.plan.answerGoal : undefined, + typeof meta.plan.answerGoal === "string" + ? meta.plan.answerGoal + : undefined, needsClarification: typeof meta.plan.needsClarification === "boolean" ? meta.plan.needsClarification @@ -240,12 +261,15 @@ function normalizeExploreAgentMeta( : undefined, toolPlan: Array.isArray(meta.plan.toolPlan) ? meta.plan.toolPlan.filter( - (toolName): toolName is NonNullable["toolPlan"][number] => - typeof toolName === "string" + ( + toolName + ): toolName is NonNullable< + typeof meta.plan + >["toolPlan"][number] => typeof toolName === "string" ) - : undefined, + : undefined } - : undefined; + : undefined return { ...meta, @@ -254,67 +278,72 @@ function normalizeExploreAgentMeta( plan, toolCalls, contextCandidates: normalizedCandidates, - selectedContextConversationIds, - }; + selectedContextConversationIds + } } function parseExploreAgentMeta(raw?: string): ExploreAgentMeta | undefined { if (!raw) { - return undefined; + return undefined } try { - const parsed = JSON.parse(raw) as ExploreAgentMeta; - return normalizeExploreAgentMeta(parsed); + const parsed = JSON.parse(raw) as ExploreAgentMeta + return normalizeExploreAgentMeta(parsed) } catch { - return undefined; + return undefined } } -function serializeExploreAgentMeta(meta?: ExploreAgentMeta): string | undefined { - const normalized = normalizeExploreAgentMeta(meta); +function serializeExploreAgentMeta( + meta?: ExploreAgentMeta +): string | undefined { + const normalized = normalizeExploreAgentMeta(meta) if (!normalized) { - return undefined; + return undefined } try { - return JSON.stringify(normalized); + return JSON.stringify(normalized) } catch { - return undefined; + return undefined } } function toConversation(record: ConversationRecord): Conversation { if (record.id === undefined) { - throw new Error("Conversation record missing id"); + throw new Error("Conversation record missing id") } const messageCount = - typeof record.message_count === "number" && Number.isFinite(record.message_count) + typeof record.message_count === "number" && + Number.isFinite(record.message_count) ? Math.max(0, Math.floor(record.message_count)) - : 0; + : 0 const turnCount = typeof record.turn_count === "number" && Number.isFinite(record.turn_count) ? Math.max(0, Math.floor(record.turn_count)) - : Math.floor(messageCount / 2); - const platform = normalizePlatform((record as { platform?: unknown }).platform); + : Math.floor(messageCount / 2) + const platform = normalizePlatform( + (record as { platform?: unknown }).platform + ) const createdAt = typeof record.created_at === "number" && Number.isFinite(record.created_at) ? record.created_at - : 0; + : 0 const updatedAt = typeof record.updated_at === "number" && Number.isFinite(record.updated_at) ? record.updated_at - : createdAt; + : createdAt const firstCapturedAt = typeof record.first_captured_at === "number" && Number.isFinite(record.first_captured_at) ? record.first_captured_at - : createdAt; + : createdAt const lastCapturedAt = typeof record.last_captured_at === "number" && Number.isFinite(record.last_captured_at) ? record.last_captured_at - : updatedAt; + : updatedAt return { ...(record as Conversation), @@ -328,29 +357,29 @@ function toConversation(record: ConversationRecord): Conversation { updated_at: updatedAt, first_captured_at: firstCapturedAt, last_captured_at: lastCapturedAt, - turn_count: turnCount, - }; + turn_count: turnCount + } } function toTopic(record: TopicRecord): Topic { if (record.id === undefined) { - throw new Error("Topic record missing id"); + throw new Error("Topic record missing id") } return { ...record, - id: record.id, - }; + id: record.id + } } function toMessage(record: MessageRecord): Message { if (record.id === undefined) { - throw new Error("Message record missing id"); + throw new Error("Message record missing id") } const degradedNodesCount = typeof record.degraded_nodes_count === "number" && Number.isFinite(record.degraded_nodes_count) ? Math.max(0, Math.floor(record.degraded_nodes_count)) - : 0; + : 0 return { ...(record as Message), @@ -362,8 +391,8 @@ function toMessage(record: MessageRecord): Message { normalized_html_snapshot: typeof record.normalized_html_snapshot === "string" ? record.normalized_html_snapshot - : null, - }; + : null + } } function toAnnotation(record: AnnotationRecord & { id: number }): Annotation { @@ -373,32 +402,32 @@ function toAnnotation(record: AnnotationRecord & { id: number }): Annotation { message_id: record.message_id, content_text: record.content_text, created_at: record.created_at, - days_after: record.days_after, - }; + days_after: record.days_after + } } export interface AnnotationExportContext { - annotation: Annotation; - conversation: Conversation; - message: Message; - messages: Message[]; + annotation: Annotation + conversation: Conversation + message: Message + messages: Message[] } function toSummary(record: SummaryRecordRecord): SummaryRecord { if (record.id === undefined) { - throw new Error("Summary record missing id"); + throw new Error("Summary record missing id") } - const summary = record as SummaryRecord; + const summary = record as SummaryRecord const format: InsightFormat = - summary.format ?? (summary.structured ? "structured_v1" : "plain_text"); + summary.format ?? (summary.structured ? "structured_v1" : "plain_text") const status: InsightStatus = - summary.status ?? (summary.structured ? "ok" : "fallback"); + summary.status ?? (summary.structured ? "ok" : "fallback") const isV2Structured = (value: unknown): value is ConversationSummaryV2 => { - if (!value || typeof value !== "object") return false; - return "core_question" in value && "thinking_journey" in value; - }; + if (!value || typeof value !== "object") return false + return "core_question" in value && "thinking_journey" in value + } return { ...summary, @@ -411,25 +440,27 @@ function toSummary(record: SummaryRecordRecord): SummaryRecord { ? "conversation_summary.v2" : summary.structured ? "conversation_summary.v1" - : undefined), - }; + : undefined) + } } function toWeeklyReport(record: WeeklyReportRecordRecord): WeeklyReportRecord { if (record.id === undefined) { - throw new Error("Weekly report record missing id"); + throw new Error("Weekly report record missing id") } - const weekly = record as WeeklyReportRecord; + const weekly = record as WeeklyReportRecord const format: InsightFormat = - weekly.format ?? (weekly.structured ? "structured_v1" : "plain_text"); + weekly.format ?? (weekly.structured ? "structured_v1" : "plain_text") const status: InsightStatus = - weekly.status ?? (weekly.structured ? "ok" : "fallback"); + weekly.status ?? (weekly.structured ? "ok" : "fallback") - const isWeeklyLiteStructured = (value: unknown): value is WeeklyLiteReportV1 => { - if (!value || typeof value !== "object") return false; - return "time_range" in value && "highlights" in value; - }; + const isWeeklyLiteStructured = ( + value: unknown + ): value is WeeklyLiteReportV1 => { + if (!value || typeof value !== "object") return false + return "time_range" in value && "highlights" in value + } return { ...weekly, @@ -442,163 +473,226 @@ function toWeeklyReport(record: WeeklyReportRecordRecord): WeeklyReportRecord { ? "weekly_lite.v1" : weekly.structured ? "weekly_report.v1" - : undefined), - }; + : undefined) + } } function normalizeTag(tag: string): string { - return tag.replace(/\s+/g, " ").trim(); + return tag.replace(/\s+/g, " ").trim() } function dedupeTags(tags: string[]): string[] { - const seen = new Set(); - const output: string[] = []; + const seen = new Set() + const output: string[] = [] for (const tag of tags) { - const normalized = normalizeTag(tag); - if (!normalized) continue; - const key = normalized.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - output.push(normalized); + const normalized = normalizeTag(tag) + if (!normalized) continue + const key = normalized.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + output.push(normalized) } - return output; + return output } function dayKey(ts: number): string { - const d = new Date(ts); - const yyyy = d.getFullYear(); - const mm = String(d.getMonth() + 1).padStart(2, "0"); - const dd = String(d.getDate()).padStart(2, "0"); - return `${yyyy}-${mm}-${dd}`; + const d = new Date(ts) + const yyyy = d.getFullYear() + const mm = String(d.getMonth() + 1).padStart(2, "0") + const dd = String(d.getDate()).padStart(2, "0") + return `${yyyy}-${mm}-${dd}` } function initPlatformDistribution(): Record { return Object.fromEntries( SUPPORTED_PLATFORMS.map((platform) => [platform, 0]) - ) as Record; + ) as Record } -const MAX_CONVERSATION_TITLE_LENGTH = 120; +const MAX_CONVERSATION_TITLE_LENGTH = 120 -export async function listConversations( +function normalizeConversationFilterPlatforms( + filters?: ConversationFilters +): Platform[] { + const candidates = + Array.isArray(filters?.platforms) && filters.platforms.length > 0 + ? filters.platforms + : filters?.platform + ? [filters.platform] + : [] + + return Array.from( + new Set( + candidates.flatMap((value) => { + const normalized = normalizePlatform(value) + return normalized ? [normalized] : [] + }) + ) + ) +} + +async function listConversationsFromDexie( filters?: ConversationFilters ): Promise { - let results: ConversationRecord[]; + const platforms = normalizeConversationFilterPlatforms(filters) + let results = await db.conversations.toArray() - if (filters?.platform) { - results = await db.conversations - .where("platform") - .equals(filters.platform) - .toArray(); - } else { - results = await db.conversations.toArray(); + if (platforms.length > 0) { + const platformSet = new Set(platforms) + results = results.filter((record) => platformSet.has(record.platform)) } - if (filters?.search) { - const q = filters.search.toLowerCase(); - results = results.filter( - (c) => - c.title.toLowerCase().includes(q) || - c.snippet.toLowerCase().includes(q) - ); + if (filters?.includeTrash === false) { + results = results.filter((record) => !record.is_trash) } - if (filters?.dateRange) { + if (filters?.includeArchived === false) { + results = results.filter((record) => !record.is_archived) + } + + const normalizedSearch = filters?.search?.trim().toLowerCase() ?? "" + if (normalizedSearch) { results = results.filter( - (c) => { - const originAt = getConversationOriginAt(toConversation(c)); - return originAt >= filters.dateRange!.start && originAt <= filters.dateRange!.end; - } - ); + (record) => + record.title.toLowerCase().includes(normalizedSearch) || + record.snippet.toLowerCase().includes(normalizedSearch) + ) + } + + const rangeStart = + typeof filters?.dateRange?.start === "number" && + Number.isFinite(filters.dateRange.start) + ? filters.dateRange.start + : null + const rangeEnd = + typeof filters?.dateRange?.end === "number" && + Number.isFinite(filters.dateRange.end) + ? filters.dateRange.end + : null + if (rangeStart !== null && rangeEnd !== null && rangeEnd >= rangeStart) { + results = results.filter((record) => { + const originAt = getConversationOriginAt(toConversation(record)) + return originAt >= rangeStart && originAt <= rangeEnd + }) } return results .sort( - (a, b) => - getConversationOriginAt(toConversation(b)) - - getConversationOriginAt(toConversation(a)) + (left, right) => + getConversationOriginAt(toConversation(right)) - + getConversationOriginAt(toConversation(left)) ) - .map(toConversation); -} - -export async function getConversationById(id: number): Promise { - const record = await db.conversations.get(id); - return record ? toConversation(record) : null; + .map(toConversation) } -export async function getTopics(): Promise { - const [topicRecords, conversations] = await Promise.all([ - db.topics.toArray(), - db.conversations.toArray(), - ]); - - const directCounts = new Map(); - for (const convo of conversations) { - if (convo.is_archived || convo.is_trash) continue; - const topicId = convo.topic_id ?? null; - if (topicId === null) continue; - directCounts.set(topicId, (directCounts.get(topicId) ?? 0) + 1); - } - - const nodeById = new Map(); - for (const record of topicRecords) { - if (record.id === undefined) { - continue; - } - nodeById.set(record.id, { - ...toTopic(record), - count: directCounts.get(record.id) ?? 0, - children: [], - }); +function buildTopicTree(topicRows: Topic[]): Topic[] { + const nodeById = new Map() + for (const row of topicRows) { + nodeById.set(row.id, { + ...row, + count: row.count ?? 0, + children: [] + }) } - const roots: Topic[] = []; + const roots: Topic[] = [] for (const node of nodeById.values()) { - const parentId = node.parent_id; + const parentId = node.parent_id if (parentId !== null && nodeById.has(parentId)) { - nodeById.get(parentId)!.children!.push(node); + nodeById.get(parentId)!.children!.push(node) } else { - roots.push(node); + roots.push(node) } } const sortByName = (items: Topic[]) => { - items.sort((a, b) => a.name.localeCompare(b.name)); - }; + items.sort((left, right) => left.name.localeCompare(right.name)) + } const aggregateCounts = (node: Topic): number => { if (!node.children || node.children.length === 0) { - return node.count ?? 0; + return node.count ?? 0 } - sortByName(node.children); + + sortByName(node.children) const childTotal = node.children.reduce( (sum, child) => sum + aggregateCounts(child), 0 - ); - node.count = (node.count ?? 0) + childTotal; - return node.count ?? 0; - }; + ) + node.count = (node.count ?? 0) + childTotal + return node.count ?? 0 + } - sortByName(roots); - roots.forEach((root) => aggregateCounts(root)); + sortByName(roots) + roots.forEach((root) => aggregateCounts(root)) - return roots; + return roots +} + +export async function listConversations( + filters?: ConversationFilters +): Promise { + const sqliteResult = await queryConversationsFromKnowledgeStore(filters) + if (sqliteResult !== null) { + return sqliteResult + } + + return listConversationsFromDexie(filters) +} + +export async function getConversationById( + id: number +): Promise { + const record = await db.conversations.get(id) + return record ? toConversation(record) : null +} + +export async function getTopics(): Promise { + const sqliteResult = await queryTopicsWithCountsFromKnowledgeStore() + if (sqliteResult !== null) { + return buildTopicTree(sqliteResult) + } + + const [topicRecords, conversations] = await Promise.all([ + db.topics.toArray(), + db.conversations.toArray() + ]) + + const directCounts = new Map() + for (const conversation of conversations) { + if (conversation.is_archived || conversation.is_trash) continue + const topicId = conversation.topic_id ?? null + if (topicId === null) continue + directCounts.set(topicId, (directCounts.get(topicId) ?? 0) + 1) + } + + return buildTopicTree( + topicRecords + .filter( + (record): record is TopicRecord & { id: number } => + typeof record.id === "number" + ) + .map((record) => ({ + ...toTopic(record), + count: directCounts.get(record.id) ?? 0 + })) + ) } export async function createTopic(payload: { - name: string; - parent_id?: number | null; + name: string + parent_id?: number | null }): Promise { - const normalizedName = payload.name.trim(); + const normalizedName = payload.name.trim() if (!normalizedName) { - throw new Error("TOPIC_NAME_EMPTY"); + throw new Error("TOPIC_NAME_EMPTY") } - const parentId = payload.parent_id ?? null; + const parentId = payload.parent_id ?? null if (parentId !== null) { - const parent = await db.topics.get(parentId); + const parent = await db.topics.get(parentId) if (!parent) { - throw new Error("PARENT_TOPIC_NOT_FOUND"); + throw new Error("PARENT_TOPIC_NOT_FOUND") } } @@ -612,19 +706,19 @@ export async function createTopic(payload: { : await db.topics .where("[parent_id+name]") .equals([parentId, normalizedName]) - .first(); + .first() if (existing) { - throw new Error("TOPIC_ALREADY_EXISTS"); + throw new Error("TOPIC_ALREADY_EXISTS") } - await enforceStorageWriteGuard(); - const now = Date.now(); + await enforceStorageWriteGuard() + const now = Date.now() const id = await db.topics.add({ name: normalizedName, parent_id: parentId, created_at: now, - updated_at: now, - }); + updated_at: now + }) return { id, @@ -633,513 +727,562 @@ export async function createTopic(payload: { created_at: now, updated_at: now, count: 0, - children: [], - }; + children: [] + } } export async function updateConversationTopic( id: number, topic_id: number | null ): Promise { - const existing = await db.conversations.get(id); + const existing = await db.conversations.get(id) if (!existing) { - throw new Error("CONVERSATION_NOT_FOUND"); + throw new Error("CONVERSATION_NOT_FOUND") } if (existing.id === undefined) { - throw new Error("Conversation record missing id"); + throw new Error("Conversation record missing id") } if (topic_id !== null) { - const topic = await db.topics.get(topic_id); + const topic = await db.topics.get(topic_id) if (!topic) { - throw new Error("TOPIC_NOT_FOUND"); + throw new Error("TOPIC_NOT_FOUND") } } - const updatedAt = Date.now(); + const updatedAt = Date.now() await db.conversations.update(id, { topic_id, - updated_at: updatedAt, - }); + updated_at: updatedAt + }) return toConversation({ ...existing, topic_id, updated_at: updatedAt, - id: existing.id, - }); + id: existing.id + }) } export async function updateConversation( id: number, changes: { topic_id?: number | null; is_starred?: boolean; tags?: string[] } ): Promise<{ updated: boolean; conversation: Conversation }> { - const existing = await db.conversations.get(id); + const existing = await db.conversations.get(id) if (!existing) { - throw new Error("CONVERSATION_NOT_FOUND"); + throw new Error("CONVERSATION_NOT_FOUND") } if (existing.id === undefined) { - throw new Error("Conversation record missing id"); + throw new Error("Conversation record missing id") } - const updates: Partial = {}; - let updated = false; + const updates: Partial = {} + let updated = false - if (changes.topic_id !== undefined && changes.topic_id !== existing.topic_id) { + if ( + changes.topic_id !== undefined && + changes.topic_id !== existing.topic_id + ) { if (changes.topic_id !== null) { - const topic = await db.topics.get(changes.topic_id); + const topic = await db.topics.get(changes.topic_id) if (!topic) { - throw new Error("TOPIC_NOT_FOUND"); + throw new Error("TOPIC_NOT_FOUND") } } - updates.topic_id = changes.topic_id ?? null; - updated = true; + updates.topic_id = changes.topic_id ?? null + updated = true } if ( changes.is_starred !== undefined && changes.is_starred !== existing.is_starred ) { - updates.is_starred = changes.is_starred; - updated = true; + updates.is_starred = changes.is_starred + updated = true } if (changes.tags !== undefined) { - const nextTags = dedupeTags(changes.tags).slice(0, 6); - const current = existing.tags ?? []; + const nextTags = dedupeTags(changes.tags).slice(0, 6) + const current = existing.tags ?? [] const same = nextTags.length === current.length && - nextTags.every((tag, index) => tag === current[index]); + nextTags.every((tag, index) => tag === current[index]) if (!same) { - updates.tags = nextTags; - updated = true; + updates.tags = nextTags + updated = true } } if (!updated) { - return { updated: false, conversation: toConversation(existing) }; + return { updated: false, conversation: toConversation(existing) } } - updates.updated_at = Date.now(); - await db.conversations.update(existing.id, updates); + updates.updated_at = Date.now() + await db.conversations.update(existing.id, updates) return { updated: true, - conversation: toConversation({ ...existing, ...updates, id: existing.id }), - }; + conversation: toConversation({ ...existing, ...updates, id: existing.id }) + } } export async function applyGardenerResult( id: number, changes: { topic_id?: number | null; tags?: string[] } ): Promise<{ updated: boolean; conversation: Conversation }> { - return updateConversation(id, changes); + return updateConversation(id, changes) } export async function replaceTagAcrossConversations( from: string, to?: string | null ): Promise { - const normalizedFrom = from.trim(); + const normalizedFrom = from.trim() if (!normalizedFrom) { - throw new Error("TAG_EMPTY"); + throw new Error("TAG_EMPTY") } const normalizedTo = - typeof to === "string" ? to.trim() : to === null ? null : undefined; + typeof to === "string" ? to.trim() : to === null ? null : undefined if (to !== undefined && to !== null && !normalizedTo) { - throw new Error("TAG_EMPTY"); + throw new Error("TAG_EMPTY") } - if (normalizedTo && normalizedTo.toLowerCase() === normalizedFrom.toLowerCase()) { - return 0; + if ( + normalizedTo && + normalizedTo.toLowerCase() === normalizedFrom.toLowerCase() + ) { + return 0 } - await enforceStorageWriteGuard(); - const now = Date.now(); - let updated = 0; + await enforceStorageWriteGuard() + const now = Date.now() + let updated = 0 - await db.conversations.toCollection().modify((record: Partial) => { - const tags = Array.isArray(record.tags) ? record.tags : []; - if (!tags.includes(normalizedFrom)) { - return; - } + await db.conversations + .toCollection() + .modify((record: Partial) => { + const tags = Array.isArray(record.tags) ? record.tags : [] + if (!tags.includes(normalizedFrom)) { + return + } - let nextTags = tags.filter((tag) => tag !== normalizedFrom); - if (normalizedTo) { - nextTags = dedupeTags([...nextTags, normalizedTo]).slice(0, 6); - } + let nextTags = tags.filter((tag) => tag !== normalizedFrom) + if (normalizedTo) { + nextTags = dedupeTags([...nextTags, normalizedTo]).slice(0, 6) + } - const same = - nextTags.length === tags.length && - nextTags.every((tag, index) => tag === tags[index]); - if (same) { - return; - } + const same = + nextTags.length === tags.length && + nextTags.every((tag, index) => tag === tags[index]) + if (same) { + return + } - record.tags = nextTags; - record.updated_at = now; - updated += 1; - }); + record.tags = nextTags + record.updated_at = now + updated += 1 + }) - return updated; + return updated } export async function renameTagAcrossConversations( from: string, to: string ): Promise { - return replaceTagAcrossConversations(from, to); + return replaceTagAcrossConversations(from, to) } export async function moveTagAcrossConversations( from: string, to: string ): Promise { - return replaceTagAcrossConversations(from, to); + return replaceTagAcrossConversations(from, to) } export async function removeTagFromConversations(tag: string): Promise { - return replaceTagAcrossConversations(tag, null); + return replaceTagAcrossConversations(tag, null) } export async function listConversationsByRange( rangeStart: number, rangeEnd: number ): Promise { - const records = await db.conversations.toArray(); - return records - .map(toConversation) - .filter((conversation) => { - const originAt = getConversationOriginAt(conversation); - return originAt >= rangeStart && originAt <= rangeEnd; - }) - .sort((a, b) => getConversationOriginAt(b) - getConversationOriginAt(a)); + return listConversations({ + dateRange: { + start: rangeStart, + end: rangeEnd + } + }) } -export async function listMessages( - conversationId: number -): Promise { +export async function listMessages(conversationId: number): Promise { const records = await db.messages .where("conversation_id") .equals(conversationId) - .sortBy("created_at"); + .sortBy("created_at") - return records.map(toMessage); + return records.map(toMessage) } -export async function listAnnotations(conversationId: number): Promise { +export async function listAnnotations( + conversationId: number +): Promise { const records = await db.annotations .where("conversation_id") .equals(conversationId) - .sortBy("created_at"); + .sortBy("created_at") return records - .filter((record): record is AnnotationRecord & { id: number } => - typeof record.id === "number" + .filter( + (record): record is AnnotationRecord & { id: number } => + typeof record.id === "number" ) - .map(toAnnotation); + .map(toAnnotation) } export async function saveAnnotation(payload: { - conversationId: number; - messageId: number; - contentText: string; + conversationId: number + messageId: number + contentText: string }): Promise { - const trimmed = payload.contentText.trim(); + const trimmed = payload.contentText.trim() if (!trimmed) { - throw new Error("ANNOTATION_EMPTY"); + throw new Error("ANNOTATION_EMPTY") } - const conversation = await db.conversations.get(payload.conversationId); + const conversation = await db.conversations.get(payload.conversationId) if (!conversation) { - throw new Error("CONVERSATION_NOT_FOUND"); + throw new Error("CONVERSATION_NOT_FOUND") } - const message = await db.messages.get(payload.messageId); + const message = await db.messages.get(payload.messageId) if (!message || message.conversation_id !== payload.conversationId) { - throw new Error("MESSAGE_NOT_FOUND"); + throw new Error("MESSAGE_NOT_FOUND") } - await enforceStorageWriteGuard(); - const now = Date.now(); + await enforceStorageWriteGuard() + const now = Date.now() const daysAfter = Math.max( 0, Math.floor((now - conversation.created_at) / (24 * 60 * 60 * 1000)) - ); + ) const id = await db.annotations.add({ conversation_id: payload.conversationId, message_id: payload.messageId, content_text: trimmed, created_at: now, - days_after: daysAfter, - }); - const record = await db.annotations.get(id); + days_after: daysAfter + }) + const record = await db.annotations.get(id) if (!record || record.id === undefined) { - throw new Error("ANNOTATION_NOT_FOUND"); + throw new Error("ANNOTATION_NOT_FOUND") } - return toAnnotation(record as AnnotationRecord & { id: number }); + return toAnnotation(record as AnnotationRecord & { id: number }) } export async function deleteAnnotation(annotationId: number): Promise { - const existing = await db.annotations.get(annotationId); + const existing = await db.annotations.get(annotationId) if (!existing || existing.id === undefined) { - return false; + return false } - await db.annotations.delete(annotationId); - return true; + await db.annotations.delete(annotationId) + return true } export async function getAnnotationExportContext( annotationId: number ): Promise { - const annotationRecord = await db.annotations.get(annotationId); + const annotationRecord = await db.annotations.get(annotationId) if (!annotationRecord || annotationRecord.id === undefined) { - throw new Error("ANNOTATION_NOT_FOUND"); + throw new Error("ANNOTATION_NOT_FOUND") } - const conversation = await db.conversations.get(annotationRecord.conversation_id); + const conversation = await db.conversations.get( + annotationRecord.conversation_id + ) if (!conversation || conversation.id === undefined) { - throw new Error("CONVERSATION_NOT_FOUND"); + throw new Error("CONVERSATION_NOT_FOUND") } - const message = await db.messages.get(annotationRecord.message_id); + const message = await db.messages.get(annotationRecord.message_id) if (!message || message.id === undefined) { - throw new Error("MESSAGE_NOT_FOUND"); + throw new Error("MESSAGE_NOT_FOUND") } const messages = await db.messages .where("conversation_id") .equals(annotationRecord.conversation_id) - .sortBy("created_at"); + .sortBy("created_at") return { - annotation: toAnnotation(annotationRecord as AnnotationRecord & { id: number }), + annotation: toAnnotation( + annotationRecord as AnnotationRecord & { id: number } + ), conversation: toConversation(conversation), message: toMessage(message), - messages: messages.map(toMessage), - }; + messages: messages.map(toMessage) + } } -export async function searchConversationIdsByText(query: string): Promise { - const normalizedQuery = query.trim().toLowerCase(); +export async function searchConversationIdsByText( + query: string +): Promise { + const sqliteResult = await queryConversationIdsFromKnowledgeStore(query) + if (sqliteResult !== null) { + return sqliteResult + } + + const normalizedQuery = query.trim().toLowerCase() if (normalizedQuery.length < 2) { - return []; + return [] } - const conversationIds = new Set(); + const conversationIds = new Set() await db.messages.toCollection().each((record) => { - const conversationId = record.conversation_id; - if (typeof conversationId !== "number" || conversationIds.has(conversationId)) { - return; + const conversationId = record.conversation_id + if ( + typeof conversationId !== "number" || + conversationIds.has(conversationId) + ) { + return } - const content = record.content_text; + const content = record.content_text if (typeof content !== "string") { - return; + return } if (content.toLowerCase().includes(normalizedQuery)) { - conversationIds.add(conversationId); + conversationIds.add(conversationId) } - }); + }) await db.annotations.toCollection().each((record) => { - const conversationId = (record as AnnotationRecord).conversation_id; - if (typeof conversationId !== "number" || conversationIds.has(conversationId)) { - return; + const conversationId = (record as AnnotationRecord).conversation_id + if ( + typeof conversationId !== "number" || + conversationIds.has(conversationId) + ) { + return } - const content = (record as AnnotationRecord).content_text; + const content = (record as AnnotationRecord).content_text if (typeof content !== "string") { - return; + return } if (content.toLowerCase().includes(normalizedQuery)) { - conversationIds.add(conversationId); + conversationIds.add(conversationId) } - }); + }) - return Array.from(conversationIds); + return Array.from(conversationIds) } function buildExcerpt(text: string, normalizedQuery: string): string { - const lower = text.toLowerCase(); - const idx = lower.indexOf(normalizedQuery); + const lower = text.toLowerCase() + const idx = lower.indexOf(normalizedQuery) if (idx < 0) { - return ""; + return "" } - const start = Math.max(0, idx - 30); - const end = Math.min(text.length, idx + normalizedQuery.length + 60); - const prefix = start > 0 ? "..." : ""; - const suffix = end < text.length ? "..." : ""; - return `${prefix}${text.slice(start, end)}${suffix}`; + const start = Math.max(0, idx - 30) + const end = Math.min(text.length, idx + normalizedQuery.length + 60) + const prefix = start > 0 ? "..." : "" + const suffix = end < text.length ? "..." : "" + return `${prefix}${text.slice(start, end)}${suffix}` } export async function searchConversationMatchesByText( params: SearchConversationMatchesQuery ): Promise { - const normalizedQuery = params.query.trim().toLowerCase(); + const sqliteResult = await queryConversationMatchesFromKnowledgeStore(params) + if (sqliteResult !== null) { + return sqliteResult + } + + const normalizedQuery = params.query.trim().toLowerCase() if (normalizedQuery.length < 2) { - return []; + return [] } const candidateIds = params.conversationIds ? Array.from( - new Set(params.conversationIds.filter((value) => Number.isFinite(value))) + new Set( + params.conversationIds.filter((value) => Number.isFinite(value)) + ) ) - : null; + : null if (candidateIds && candidateIds.length === 0) { - return []; + return [] } const matchMap = new Map< number, { messageId: number; createdAt: number; excerpt: string } - >(); + >() const collection = candidateIds ? db.messages.where("conversation_id").anyOf(candidateIds) - : db.messages.toCollection(); + : db.messages.toCollection() await collection.each((record) => { - const conversationId = record.conversation_id; + const conversationId = record.conversation_id if (typeof conversationId !== "number") { - return; + return } - const content = record.content_text; + const content = record.content_text if (typeof content !== "string") { - return; + return } if (!content.toLowerCase().includes(normalizedQuery)) { - return; + return } - const messageId = record.id; + const messageId = record.id if (typeof messageId !== "number") { - return; + return } - const createdAt = record.created_at ?? 0; - const existing = matchMap.get(conversationId); + const createdAt = record.created_at ?? 0 + const existing = matchMap.get(conversationId) const shouldReplace = !existing || createdAt < existing.createdAt || - (createdAt === existing.createdAt && messageId < existing.messageId); + (createdAt === existing.createdAt && messageId < existing.messageId) if (shouldReplace) { matchMap.set(conversationId, { messageId, createdAt, - excerpt: buildExcerpt(content, normalizedQuery), - }); + excerpt: buildExcerpt(content, normalizedQuery) + }) } - }); + }) return Array.from(matchMap.entries()).map(([conversationId, match]) => ({ conversationId, firstMatchedMessageId: match.messageId, - bestExcerpt: match.excerpt, - })); + bestExcerpt: match.excerpt + })) } export async function deleteConversation(id: number): Promise { - await db.transaction("rw", db.conversations, db.messages, db.annotations, async () => { - await db.messages.where("conversation_id").equals(id).delete(); - await db.annotations.where("conversation_id").equals(id).delete(); - await db.conversations.delete(id); - }); - return true; + await db.transaction( + "rw", + db.conversations, + db.messages, + db.annotations, + async () => { + await db.messages.where("conversation_id").equals(id).delete() + await db.annotations.where("conversation_id").equals(id).delete() + await db.conversations.delete(id) + } + ) + return true } export async function updateConversationTitle( id: number, title: string ): Promise { - const normalizedTitle = title.trim(); + const normalizedTitle = title.trim() if (!normalizedTitle) { - throw new Error("TITLE_EMPTY"); + throw new Error("TITLE_EMPTY") } if (normalizedTitle.length > MAX_CONVERSATION_TITLE_LENGTH) { - throw new Error("TITLE_TOO_LONG"); + throw new Error("TITLE_TOO_LONG") } - const existing = await db.conversations.get(id); + const existing = await db.conversations.get(id) if (!existing) { - throw new Error("CONVERSATION_NOT_FOUND"); + throw new Error("CONVERSATION_NOT_FOUND") } if (existing.id === undefined) { - throw new Error("Conversation record missing id"); + throw new Error("Conversation record missing id") } if (existing.title === normalizedTitle) { - return toConversation(existing); + return toConversation(existing) } - await db.conversations.update(id, { title: normalizedTitle }); - return toConversation({ ...existing, title: normalizedTitle, id: existing.id }); + await db.conversations.update(id, { title: normalizedTitle }) + return toConversation({ + ...existing, + title: normalizedTitle, + id: existing.id + }) } export async function clearAllData(): Promise { await db.transaction( "rw", - [db.conversations, db.messages, db.summaries, db.weekly_reports, db.annotations], + [ + db.conversations, + db.messages, + db.summaries, + db.weekly_reports, + db.annotations + ], async () => { - await db.messages.clear(); - await db.conversations.clear(); - await db.summaries.clear(); - await db.weekly_reports.clear(); - await db.annotations.clear(); + await db.messages.clear() + await db.conversations.clear() + await db.summaries.clear() + await db.weekly_reports.clear() + await db.annotations.clear() } - ); - return true; + ) + return true } export async function clearInsightsCache(): Promise { - await db.transaction( - "rw", - db.summaries, - db.weekly_reports, - async () => { - await db.summaries.clear(); - await db.weekly_reports.clear(); - } - ); - return true; + await db.transaction("rw", db.summaries, db.weekly_reports, async () => { + await db.summaries.clear() + await db.weekly_reports.clear() + }) + return true } async function collectExportDataset() { - const conversations = (await db.conversations.toArray()).map(toConversation); - const messages = (await db.messages.toArray()).map(toMessage); - const summaries = (await db.summaries.toArray()).map(toSummary); - const weeklyReports = (await db.weekly_reports.toArray()).map(toWeeklyReport); + const conversations = (await db.conversations.toArray()).map(toConversation) + const messages = (await db.messages.toArray()).map(toMessage) + const summaries = (await db.summaries.toArray()).map(toSummary) + const weeklyReports = (await db.weekly_reports.toArray()).map(toWeeklyReport) const annotations = (await db.annotations.toArray()) - .filter((record): record is AnnotationRecord & { id: number } => - typeof record.id === "number" + .filter( + (record): record is AnnotationRecord & { id: number } => + typeof record.id === "number" ) - .map(toAnnotation); - return { conversations, messages, summaries, weeklyReports, annotations }; + .map(toAnnotation) + return { conversations, messages, summaries, weeklyReports, annotations } } export async function getStorageUsage(): Promise { - return getStorageUsageSnapshot(); + return getStorageUsageSnapshot() } export async function getDataOverview(): Promise { - const [storage, totalConversations, summaryRecordCount, weeklyReportCount] = - await Promise.all([ - getStorageUsageSnapshot(), - db.conversations.count(), - db.summaries.count(), - db.weekly_reports.count(), - ]); + const [ + storage, + totalConversations, + summaryRecordCount, + weeklyReportCount, + storageEngine + ] = await Promise.all([ + getStorageUsageSnapshot(), + db.conversations.count(), + db.summaries.count(), + db.weekly_reports.count(), + getStorageEngineState() + ]) const [uniqueSummaryConversationIds, lastSummary] = await Promise.all([ db.summaries.orderBy("conversationId").uniqueKeys(), - db.summaries.orderBy("createdAt").last(), - ]); + db.summaries.orderBy("createdAt").last() + ]) return { storage, @@ -1150,23 +1293,32 @@ export async function getDataOverview(): Promise { lastCompactionAt: typeof lastSummary?.createdAt === "number" ? lastSummary.createdAt : null, indexedDbName: db.name, - }; + storageEngine: { + activeEngine: storageEngine.activeEngine, + migrationState: storageEngine.migrationState, + snapshotWatermark: storageEngine.snapshotWatermark, + appliedWatermark: storageEngine.appliedWatermark, + lastError: storageEngine.lastError + } + } } -export async function exportAllData(format: ExportFormat): Promise { - const dataset = await collectExportDataset(); +export async function exportAllData( + format: ExportFormat +): Promise { + const dataset = await collectExportDataset() if (format === "txt") { - return buildExportTxtV1(dataset); + return buildExportTxtV1(dataset) } if (format === "md") { - return buildExportMdV1(dataset); + return buildExportMdV1(dataset) } - return buildExportJsonV1(dataset); + return buildExportJsonV1(dataset) } export async function exportAllDataAsJson(): Promise { - const payload = await exportAllData("json"); - return payload.content; + const payload = await exportAllData("json") + return payload.content } export async function getSummary( @@ -1175,27 +1327,27 @@ export async function getSummary( const record = await db.summaries .where("conversationId") .equals(conversationId) - .last(); - return record ? toSummary(record) : null; + .last() + return record ? toSummary(record) : null } export async function saveSummary( record: Omit ): Promise { - await enforceStorageWriteGuard(); + await enforceStorageWriteGuard() const existing = await db.summaries .where("conversationId") .equals(record.conversationId) - .first(); + .first() if (existing?.id !== undefined) { - await db.summaries.update(existing.id, record); - return toSummary({ ...existing, ...record, id: existing.id }); + await db.summaries.update(existing.id, record) + return toSummary({ ...existing, ...record, id: existing.id }) } - const id = await db.summaries.add(record); - return toSummary({ ...record, id }); + const id = await db.summaries.add(record) + return toSummary({ ...record, id }) } export async function getWeeklyReport( @@ -1206,58 +1358,67 @@ export async function getWeeklyReport( .where("rangeStart") .equals(rangeStart) .and((item) => item.rangeEnd === rangeEnd) - .first(); - return record ? toWeeklyReport(record) : null; + .first() + return record ? toWeeklyReport(record) : null } export async function saveWeeklyReport( record: Omit ): Promise { - await enforceStorageWriteGuard(); + await enforceStorageWriteGuard() const existing = await db.weekly_reports .where("rangeStart") .equals(record.rangeStart) .and((item) => item.rangeEnd === record.rangeEnd) - .first(); + .first() if (existing?.id !== undefined) { - await db.weekly_reports.update(existing.id, record); - return toWeeklyReport({ ...existing, ...record, id: existing.id }); + await db.weekly_reports.update(existing.id, record) + return toWeeklyReport({ ...existing, ...record, id: existing.id }) } - const id = await db.weekly_reports.add(record); - return toWeeklyReport({ ...record, id }); + const id = await db.weekly_reports.add(record) + return toWeeklyReport({ ...record, id }) } export async function getDashboardStats(): Promise { - const conversations = (await db.conversations.toArray()).map(toConversation); - const distribution = initPlatformDistribution(); + const sqliteResult = await queryDashboardStatsFromKnowledgeStore() + if (sqliteResult !== null) { + return sqliteResult + } + + const conversations = (await db.conversations.toArray()).map(toConversation) + const distribution = initPlatformDistribution() for (const c of conversations) { - distribution[c.platform] += 1; + distribution[c.platform] += 1 } - const today = dayKey(Date.now()); + const today = dayKey(Date.now()) const firstCapturedTodayCount = conversations.filter( (c) => dayKey(getConversationFirstCapturedAt(c)) === today - ).length; + ).length const daysWithConversations = new Set( conversations.map((c) => dayKey(getConversationFirstCapturedAt(c))) - ); + ) - let firstCaptureStreak = 0; - let cursor = new Date(); + let firstCaptureStreak = 0 + let cursor = new Date() while (daysWithConversations.has(dayKey(cursor.getTime()))) { - firstCaptureStreak += 1; - cursor.setDate(cursor.getDate() - 1); + firstCaptureStreak += 1 + cursor.setDate(cursor.getDate() - 1) } - const firstCaptureHeatmapData = Array.from(daysWithConversations).map((d) => ({ - date: d, - count: conversations.filter((c) => dayKey(getConversationFirstCapturedAt(c)) === d).length, - })); + const firstCaptureHeatmapData = Array.from(daysWithConversations).map( + (d) => ({ + date: d, + count: conversations.filter( + (c) => dayKey(getConversationFirstCapturedAt(c)) === d + ).length + }) + ) return { totalConversations: conversations.length, @@ -1265,8 +1426,8 @@ export async function getDashboardStats(): Promise { firstCaptureStreak, firstCapturedTodayCount, platformDistribution: distribution, - firstCaptureHeatmapData, - }; + firstCaptureHeatmapData + } } function toNote(record: NoteRecord & { id: number }): Note { @@ -1276,114 +1437,122 @@ function toNote(record: NoteRecord & { id: number }): Note { content: record.content, created_at: record.created_at, updated_at: record.updated_at, - linked_conversation_ids: record.linked_conversation_ids ?? [], - }; + linked_conversation_ids: record.linked_conversation_ids ?? [] + } } export async function listNotes(): Promise { - const records = await db.notes.orderBy("updated_at").reverse().toArray(); + const records = await db.notes.orderBy("updated_at").reverse().toArray() return records - .filter((record): record is NoteRecord & { id: number } => record.id !== undefined) - .map(toNote); + .filter( + (record): record is NoteRecord & { id: number } => record.id !== undefined + ) + .map(toNote) } export async function createNote( data: Omit ): Promise { - const now = Date.now(); + const now = Date.now() const id = await db.notes.add({ title: data.title, content: data.content, linked_conversation_ids: data.linked_conversation_ids, created_at: now, - updated_at: now, - }); - const record = await db.notes.get(id); + updated_at: now + }) + const record = await db.notes.get(id) if (!record || record.id === undefined) { - throw new Error("Failed to create note"); + throw new Error("Failed to create note") } - return toNote(record as NoteRecord & { id: number }); + return toNote(record as NoteRecord & { id: number }) } export async function updateNote( id: number, changes: Partial> ): Promise { - await db.notes.update(id, { ...changes, updated_at: Date.now() }); - const record = await db.notes.get(id); + await db.notes.update(id, { ...changes, updated_at: Date.now() }) + const record = await db.notes.get(id) if (!record || record.id === undefined) { - throw new Error("Note not found"); + throw new Error("Note not found") } - return toNote(record as NoteRecord & { id: number }); + return toNote(record as NoteRecord & { id: number }) } export async function deleteNote(id: number): Promise { - await db.notes.delete(id); + await db.notes.delete(id) } // ===== Explore (RAG Chat) Operations ===== -const MAX_SESSIONS = 50; -const MAX_MESSAGES_PER_SESSION = 100; -const MAX_TOTAL_MESSAGES = 1000; +const MAX_SESSIONS = 50 +const MAX_MESSAGES_PER_SESSION = 100 +const MAX_TOTAL_MESSAGES = 1000 function generateId(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` } // Session CRUD export async function createExploreSession(title: string): Promise { - await enforceStorageWriteGuard(); - - const now = Date.now(); + await enforceStorageWriteGuard() + + const now = Date.now() const session: ExploreSessionRecord = { id: generateId("sess"), title: title.slice(0, 100), // Limit title length preview: "", messageCount: 0, createdAt: now, - updatedAt: now, - }; - - await db.explore_sessions.add(session); - + updatedAt: now + } + + await db.explore_sessions.add(session) + // Cleanup old sessions if needed - await cleanupExploreSessionsIfNeeded(); - - return session.id; + await cleanupExploreSessionsIfNeeded() + + return session.id } -export async function getExploreSession(id: string): Promise { - const record = await db.explore_sessions.get(id); - if (!record) return null; - return record as ExploreSession; +export async function getExploreSession( + id: string +): Promise { + const record = await db.explore_sessions.get(id) + if (!record) return null + return record as ExploreSession } -export async function listExploreSessions(limit = 50): Promise { +export async function listExploreSessions( + limit = 50 +): Promise { const records = await db.explore_sessions .orderBy("updatedAt") .reverse() .limit(limit) - .toArray(); - return records as ExploreSession[]; + .toArray() + return records as ExploreSession[] } export async function updateExploreSession( id: string, - changes: Partial> + changes: Partial< + Pick + > ): Promise { await db.explore_sessions.update(id, { ...changes, - updatedAt: Date.now(), - }); + updatedAt: Date.now() + }) } export async function deleteExploreSession(id: string): Promise { // Delete all messages first - await db.explore_messages.where("sessionId").equals(id).delete(); + await db.explore_messages.where("sessionId").equals(id).delete() // Delete session - await db.explore_sessions.delete(id); + await db.explore_sessions.delete(id) } // Message CRUD @@ -1392,10 +1561,12 @@ export async function addExploreMessage( sessionId: string, message: Omit ): Promise { - await enforceStorageWriteGuard(); - const normalizedSources = normalizeExploreSources(message.sources as ExploreSourceRecord[] | undefined); - const serializedAgentMeta = serializeExploreAgentMeta(message.agentMeta); - + await enforceStorageWriteGuard() + const normalizedSources = normalizeExploreSources( + message.sources as ExploreSourceRecord[] | undefined + ) + const serializedAgentMeta = serializeExploreAgentMeta(message.agentMeta) + const record: ExploreMessageRecord = { id: generateId("msg"), sessionId, @@ -1403,33 +1574,34 @@ export async function addExploreMessage( content: message.content, sources: normalizedSources ? JSON.stringify(normalizedSources) : undefined, agentMeta: serializedAgentMeta, - timestamp: message.timestamp, - }; - - await db.explore_messages.add(record); - + timestamp: message.timestamp + } + + await db.explore_messages.add(record) + // Update session message count - const session = await db.explore_sessions.get(sessionId); + const session = await db.explore_sessions.get(sessionId) if (session) { - const newCount = session.messageCount + 1; - const preview = message.role === "assistant" - ? message.content.slice(0, 100) - : session.preview; - + const newCount = session.messageCount + 1 + const preview = + message.role === "assistant" + ? message.content.slice(0, 100) + : session.preview + await db.explore_sessions.update(sessionId, { messageCount: newCount, preview, - updatedAt: Date.now(), - }); - + updatedAt: Date.now() + }) + // Cleanup old messages if needed if (newCount > MAX_MESSAGES_PER_SESSION) { - await cleanupSessionMessages(sessionId); + await cleanupSessionMessages(sessionId) } } - - await cleanupExploreMessagesIfNeeded(); - + + await cleanupExploreMessagesIfNeeded() + return { id: record.id, sessionId, @@ -1437,16 +1609,18 @@ export async function addExploreMessage( content: message.content, sources: normalizedSources, agentMeta: parseExploreAgentMeta(serializedAgentMeta), - timestamp: message.timestamp, - }; + timestamp: message.timestamp + } } -export async function getExploreMessages(sessionId: string): Promise { +export async function getExploreMessages( + sessionId: string +): Promise { const records = await db.explore_messages .where("sessionId") .equals(sessionId) - .sortBy("timestamp"); - + .sortBy("timestamp") + return records.map((record) => ({ id: record.id, sessionId: record.sessionId, @@ -1454,8 +1628,8 @@ export async function getExploreMessages(sessionId: string): Promise ({ - id: record.id, - sessionId: record.sessionId, - role: record.role, - content: record.content, - sources: parseExploreSources(record.sources), - agentMeta: parseExploreAgentMeta(record.agentMeta), - timestamp: record.timestamp, - })); + return records.reverse().map((record) => ({ + id: record.id, + sessionId: record.sessionId, + role: record.role, + content: record.content, + sources: parseExploreSources(record.sources), + agentMeta: parseExploreAgentMeta(record.agentMeta), + timestamp: record.timestamp + })) } export async function updateExploreMessageContext( @@ -1488,41 +1660,41 @@ export async function updateExploreMessageContext( contextDraft: string, selectedContextConversationIds: number[] ): Promise { - await enforceStorageWriteGuard(); + await enforceStorageWriteGuard() - const record = await db.explore_messages.get(messageId); + const record = await db.explore_messages.get(messageId) if (!record) { - throw new Error("EXPLORE_MESSAGE_NOT_FOUND"); + throw new Error("EXPLORE_MESSAGE_NOT_FOUND") } - const existingMeta = parseExploreAgentMeta(record.agentMeta); + const existingMeta = parseExploreAgentMeta(record.agentMeta) const nextMeta = normalizeExploreAgentMeta({ mode: existingMeta?.mode ?? "agent", toolCalls: existingMeta?.toolCalls ?? [], contextCandidates: existingMeta?.contextCandidates ?? [], ...existingMeta, contextDraft, - selectedContextConversationIds, - }); + selectedContextConversationIds + }) await db.explore_messages.update(messageId, { - agentMeta: serializeExploreAgentMeta(nextMeta), - }); + agentMeta: serializeExploreAgentMeta(nextMeta) + }) } // Cleanup functions async function cleanupExploreSessionsIfNeeded(): Promise { - const count = await db.explore_sessions.count(); - if (count <= MAX_SESSIONS) return; - + const count = await db.explore_sessions.count() + if (count <= MAX_SESSIONS) return + const toDelete = await db.explore_sessions .orderBy("updatedAt") .limit(count - MAX_SESSIONS) - .toArray(); - + .toArray() + for (const session of toDelete) { - await deleteExploreSession(session.id); + await deleteExploreSession(session.id) } } @@ -1530,56 +1702,59 @@ async function cleanupSessionMessages(sessionId: string): Promise { const messages = await db.explore_messages .where("sessionId") .equals(sessionId) - .sortBy("timestamp"); - - if (messages.length <= MAX_MESSAGES_PER_SESSION) return; - - const toDelete = messages.slice(0, messages.length - MAX_MESSAGES_PER_SESSION); + .sortBy("timestamp") + + if (messages.length <= MAX_MESSAGES_PER_SESSION) return + + const toDelete = messages.slice(0, messages.length - MAX_MESSAGES_PER_SESSION) for (const msg of toDelete) { - await db.explore_messages.delete(msg.id); + await db.explore_messages.delete(msg.id) } - + // Update count await db.explore_sessions.update(sessionId, { - messageCount: MAX_MESSAGES_PER_SESSION, - }); + messageCount: MAX_MESSAGES_PER_SESSION + }) } async function cleanupExploreMessagesIfNeeded(): Promise { - const count = await db.explore_messages.count(); - if (count <= MAX_TOTAL_MESSAGES) return; - + const count = await db.explore_messages.count() + if (count <= MAX_TOTAL_MESSAGES) return + // Get oldest messages across all sessions const toDelete = await db.explore_messages .orderBy("timestamp") .limit(count - MAX_TOTAL_MESSAGES) - .toArray(); - - const sessionCounts = new Map(); - + .toArray() + + const sessionCounts = new Map() + for (const msg of toDelete) { - await db.explore_messages.delete(msg.id); - sessionCounts.set(msg.sessionId, (sessionCounts.get(msg.sessionId) || 0) + 1); + await db.explore_messages.delete(msg.id) + sessionCounts.set( + msg.sessionId, + (sessionCounts.get(msg.sessionId) || 0) + 1 + ) } - + // Update session counts for (const [sessionId, deletedCount] of sessionCounts) { - const session = await db.explore_sessions.get(sessionId); + const session = await db.explore_sessions.get(sessionId) if (session) { await db.explore_sessions.update(sessionId, { - messageCount: Math.max(0, session.messageCount - deletedCount), - }); + messageCount: Math.max(0, session.messageCount - deletedCount) + }) } } } // For export functionality export async function getAllExploreSessions(): Promise { - return db.explore_sessions.toArray() as Promise; + return db.explore_sessions.toArray() as Promise } export async function getAllExploreMessages(): Promise { - const records = await db.explore_messages.toArray(); + const records = await db.explore_messages.toArray() return records.map((record) => ({ id: record.id, sessionId: record.sessionId, @@ -1587,6 +1762,6 @@ export async function getAllExploreMessages(): Promise { content: record.content, sources: parseExploreSources(record.sources), agentMeta: parseExploreAgentMeta(record.agentMeta), - timestamp: record.timestamp, - })); + timestamp: record.timestamp + })) } diff --git a/frontend/src/lib/db/storageEngineState.ts b/frontend/src/lib/db/storageEngineState.ts new file mode 100644 index 0000000..4c1371b --- /dev/null +++ b/frontend/src/lib/db/storageEngineState.ts @@ -0,0 +1,138 @@ +import { + getLocalStorageValue, + setLocalStorageValue +} from "../utils/chromeStorageBridge" + +export type StorageEngineKind = "dexie" | "sqlite" + +export type StorageMigrationState = + | "idle" + | "initializing" + | "migrating" + | "validating" + | "ready" + | "error" + +export interface StorageEngineState { + activeEngine: StorageEngineKind + migrationState: StorageMigrationState + snapshotWatermark: number | null + appliedWatermark: number | null + lastError: string | null + updatedAt: number +} + +const STORAGE_ENGINE_STATE_KEY = "vesti_storage_engine_state" + +const DEFAULT_STORAGE_ENGINE_STATE: StorageEngineState = { + activeEngine: "dexie", + migrationState: "idle", + snapshotWatermark: null, + appliedWatermark: null, + lastError: null, + updatedAt: 0 +} + +let memoryStorageEngineState: StorageEngineState = { + ...DEFAULT_STORAGE_ENGINE_STATE +} + +function normalizeStorageEngineState(raw: unknown): StorageEngineState { + if (!raw || typeof raw !== "object") { + return { ...DEFAULT_STORAGE_ENGINE_STATE } + } + + const state = raw as Partial + const activeEngine = state.activeEngine === "sqlite" ? "sqlite" : "dexie" + const migrationState: StorageMigrationState = + state.migrationState === "initializing" || + state.migrationState === "migrating" || + state.migrationState === "validating" || + state.migrationState === "ready" || + state.migrationState === "error" + ? state.migrationState + : "idle" + + return { + activeEngine, + migrationState, + snapshotWatermark: + typeof state.snapshotWatermark === "number" && + Number.isFinite(state.snapshotWatermark) + ? state.snapshotWatermark + : null, + appliedWatermark: + typeof state.appliedWatermark === "number" && + Number.isFinite(state.appliedWatermark) + ? state.appliedWatermark + : null, + lastError: typeof state.lastError === "string" ? state.lastError : null, + updatedAt: + typeof state.updatedAt === "number" && Number.isFinite(state.updatedAt) + ? state.updatedAt + : 0 + } +} + +export async function getStorageEngineState(): Promise { + try { + const raw = await getLocalStorageValue(STORAGE_ENGINE_STATE_KEY) + const normalized = normalizeStorageEngineState(raw) + memoryStorageEngineState = normalized + return { ...normalized } + } catch { + return { ...memoryStorageEngineState } + } +} + +export async function patchStorageEngineState( + patch: Partial +): Promise { + const current = await getStorageEngineState() + + const next = normalizeStorageEngineState({ + ...current, + ...patch, + updatedAt: Date.now() + }) + + memoryStorageEngineState = next + + try { + await setLocalStorageValue(STORAGE_ENGINE_STATE_KEY, next) + } catch { + return { ...next } + } + + return { ...next } +} + +export async function bumpStorageSnapshotWatermark( + nextWatermark: number = Date.now() +): Promise { + return patchStorageEngineState({ + snapshotWatermark: nextWatermark + }) +} + +export async function markStorageEngineReady( + watermark: number +): Promise { + return patchStorageEngineState({ + activeEngine: "sqlite", + migrationState: "ready", + snapshotWatermark: watermark, + appliedWatermark: watermark, + lastError: null + }) +} + +export async function markStorageEngineError( + error: string +): Promise { + return patchStorageEngineState({ + activeEngine: "dexie", + migrationState: "error", + lastError: error + }) +} diff --git a/frontend/src/lib/messaging/protocol.ts b/frontend/src/lib/messaging/protocol.ts index eeaca6a..4d73772 100644 --- a/frontend/src/lib/messaging/protocol.ts +++ b/frontend/src/lib/messaging/protocol.ts @@ -4,93 +4,96 @@ import type { CaptureDecisionMeta, Conversation, ConversationMatchSummary, - DataOverviewSnapshot, - Message, DashboardStats, + DataOverviewSnapshot, + ExploreAskOptions, + ExploreMessage, + ExploreMode, + ExploreSession, ExportFormat, ExportPayload, - Platform, - Topic, + ForceArchiveTransientResult, GardenerResult, LlmConfig, - ForceArchiveTransientResult, + Message, + Note, + Platform, + RagResponse, + RelatedConversation, + SearchConversationMatchesQuery, StorageUsageSnapshot, SummaryRecord, WeeklyReportRecord, - RelatedConversation, - ExploreSession, - ExploreMessage, - RagResponse, - ExploreMode, - ExploreAskOptions, - Note, - SearchConversationMatchesQuery, + Topic, MessageCitation, - MessageArtifact, -} from "../types"; -import type { AstRoot, AstVersion } from "../types/ast"; + MessageArtifact +} from "../types" +import type { AstRoot, AstVersion } from "../types/ast" export interface DateRange { - start: number; - end: number; + start: number + end: number } export interface ConversationFilters { - platform?: Platform; - search?: string; - dateRange?: DateRange; + platform?: Platform + platforms?: Platform[] + search?: string + dateRange?: DateRange + includeTrash?: boolean + includeArchived?: boolean } export interface ConversationUpdateChanges { - topic_id?: number | null; - is_starred?: boolean; - tags?: string[]; + topic_id?: number | null + is_starred?: boolean + tags?: string[] } export interface AnnotationSavePayload { - conversationId: number; - messageId: number; - contentText: string; + conversationId: number + messageId: number + contentText: string } export interface AnnotationActionPayload { - annotationId: number; + annotationId: number } export interface ConversationDraft { - uuid: string; - platform: Platform; - title: string; - snippet: string; - url: string; - source_created_at: number | null; - first_captured_at: number; - last_captured_at: number; - created_at: number; - updated_at: number; - message_count: number; - turn_count: number; - is_archived: boolean; - is_trash: boolean; - tags: string[]; - topic_id: number | null; - is_starred: boolean; + uuid: string + platform: Platform + title: string + snippet: string + url: string + source_created_at: number | null + first_captured_at: number + last_captured_at: number + created_at: number + updated_at: number + message_count: number + turn_count: number + is_archived: boolean + is_trash: boolean + tags: string[] + topic_id: number | null + is_starred: boolean } export interface ParsedMessage { - role: "user" | "ai"; - textContent: string; - contentAst?: AstRoot | null; - contentAstVersion?: AstVersion | null; - degradedNodesCount?: number; - citations?: MessageCitation[]; - artifacts?: MessageArtifact[]; - normalizedHtmlSnapshot?: string | null; - htmlContent?: string; - timestamp?: number; + role: "user" | "ai" + textContent: string + contentAst?: AstRoot | null + contentAstVersion?: AstVersion | null + degradedNodesCount?: number + citations?: MessageCitation[] + artifacts?: MessageArtifact[] + normalizedHtmlSnapshot?: string | null + htmlContent?: string + timestamp?: number } -export type InsightPipelineScope = "summary" | "weekly"; +export type InsightPipelineScope = "summary" | "weekly" export type InsightPipelineStage = | "initiating_pipeline" @@ -99,482 +102,499 @@ export type InsightPipelineStage = | "aggregating_weekly_digest" | "persisting_result" | "completed" - | "degraded_fallback"; + | "degraded_fallback" export type InsightPipelineStatus = | "in_progress" | "completed" - | "degraded_fallback"; + | "degraded_fallback" -export type InsightPipelineRoute = "proxy" | "modelscope" | "unknown"; +export type InsightPipelineRoute = "proxy" | "modelscope" | "unknown" export interface InsightPipelineProgressPayload { - pipelineId: string; - scope: InsightPipelineScope; - targetId: string; - stage: InsightPipelineStage; - status: InsightPipelineStatus; - attempt: number; - startedAt: number; - updatedAt: number; - route: InsightPipelineRoute; - modelId: string; - promptVersion: string; - seq: number; + pipelineId: string + scope: InsightPipelineScope + targetId: string + stage: InsightPipelineStage + status: InsightPipelineStatus + attempt: number + startedAt: number + updatedAt: number + route: InsightPipelineRoute + modelId: string + promptVersion: string + seq: number } export interface InsightPipelineProgressMessage { - type: "INSIGHT_PIPELINE_PROGRESS"; - payload: InsightPipelineProgressPayload; + type: "INSIGHT_PIPELINE_PROGRESS" + payload: InsightPipelineProgressPayload } export type RequestMessage = | { - type: "CAPTURE_CONVERSATION"; - target?: "offscreen"; - via?: "background"; - requestId?: string; + type: "CAPTURE_CONVERSATION" + target?: "offscreen" + via?: "background" + requestId?: string payload: { - conversation: ConversationDraft; - messages: ParsedMessage[]; - forceFlag?: boolean; - }; + conversation: ConversationDraft + messages: ParsedMessage[] + forceFlag?: boolean + } } | { - type: "GET_CONVERSATIONS"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload?: ConversationFilters; + type: "GET_CONVERSATIONS" + target?: "offscreen" + via?: "background" + requestId?: string + payload?: ConversationFilters } | { - type: "GET_TOPICS"; - target?: "offscreen"; - via?: "background"; - requestId?: string; + type: "GET_TOPICS" + target?: "offscreen" + via?: "background" + requestId?: string } | { - type: "CREATE_TOPIC"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { name: string; parent_id?: number | null }; + type: "CREATE_TOPIC" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { name: string; parent_id?: number | null } } | { - type: "UPDATE_CONVERSATION_TOPIC"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { id: number; topic_id: number | null }; + type: "UPDATE_CONVERSATION_TOPIC" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { id: number; topic_id: number | null } } | { - type: "UPDATE_CONVERSATION"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { id: number; changes: ConversationUpdateChanges }; + type: "UPDATE_CONVERSATION" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { id: number; changes: ConversationUpdateChanges } } | { - type: "RUN_GARDENER"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { conversationId: number }; + type: "RUN_GARDENER" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { conversationId: number } } | { - type: "GET_RELATED_CONVERSATIONS"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { conversationId: number; limit?: number }; + type: "GET_RELATED_CONVERSATIONS" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { conversationId: number; limit?: number } } | { - type: "GET_ALL_EDGES"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload?: { threshold?: number; conversationIds?: number[] }; + type: "GET_ALL_EDGES" + target?: "offscreen" + via?: "background" + requestId?: string + payload?: { threshold?: number; conversationIds?: number[] } } | { - type: "RENAME_FOLDER_TAG"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { from: string; to: string }; + type: "RENAME_FOLDER_TAG" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { from: string; to: string } } | { - type: "MOVE_FOLDER_TAG"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { from: string; to: string }; + type: "MOVE_FOLDER_TAG" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { from: string; to: string } } | { - type: "REMOVE_FOLDER_TAG"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { tag: string }; + type: "REMOVE_FOLDER_TAG" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { tag: string } } | { - type: "ASK_KNOWLEDGE_BASE"; - target?: "offscreen"; - via?: "background"; - requestId?: string; + type: "ASK_KNOWLEDGE_BASE" + target?: "offscreen" + via?: "background" + requestId?: string payload: { - query: string; - limit?: number; - sessionId?: string; - mode?: ExploreMode; - options?: ExploreAskOptions; - }; + query: string + limit?: number + sessionId?: string + mode?: ExploreMode + options?: ExploreAskOptions + } } | { - type: "CREATE_EXPLORE_SESSION"; - target?: "offscreen"; - requestId?: string; - payload: { title: string }; + type: "CREATE_EXPLORE_SESSION" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { title: string } } | { - type: "LIST_EXPLORE_SESSIONS"; - target?: "offscreen"; - requestId?: string; - payload?: { limit?: number }; + type: "LIST_EXPLORE_SESSIONS" + target?: "offscreen" + via?: "background" + requestId?: string + payload?: { limit?: number } } | { - type: "GET_EXPLORE_SESSION"; - target?: "offscreen"; - requestId?: string; - payload: { sessionId: string }; + type: "GET_EXPLORE_SESSION" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { sessionId: string } } | { - type: "GET_EXPLORE_MESSAGES"; - target?: "offscreen"; - requestId?: string; - payload: { sessionId: string }; + type: "GET_EXPLORE_MESSAGES" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { sessionId: string } } | { - type: "DELETE_EXPLORE_SESSION"; - target?: "offscreen"; - requestId?: string; - payload: { sessionId: string }; + type: "DELETE_EXPLORE_SESSION" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { sessionId: string } } | { - type: "RENAME_EXPLORE_SESSION"; - target?: "offscreen"; - requestId?: string; - payload: { sessionId: string; title: string }; + type: "RENAME_EXPLORE_SESSION" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { sessionId: string; title: string } } | { - type: "UPDATE_EXPLORE_MESSAGE_CONTEXT"; - target?: "offscreen"; - requestId?: string; + type: "UPDATE_EXPLORE_MESSAGE_CONTEXT" + target?: "offscreen" + via?: "background" + requestId?: string payload: { - messageId: string; - contextDraft: string; - selectedContextConversationIds: number[]; - }; + messageId: string + contextDraft: string + selectedContextConversationIds: number[] + } } | { - type: "GET_MESSAGES"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { conversationId: number }; + type: "GET_MESSAGES" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { conversationId: number } } | { - type: "GET_ANNOTATIONS_BY_CONVERSATION"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { conversationId: number }; + type: "GET_ANNOTATIONS_BY_CONVERSATION" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { conversationId: number } } | { - type: "SAVE_ANNOTATION"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: AnnotationSavePayload; + type: "SAVE_ANNOTATION" + target?: "offscreen" + via?: "background" + requestId?: string + payload: AnnotationSavePayload } | { - type: "DELETE_ANNOTATION"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: AnnotationActionPayload; + type: "DELETE_ANNOTATION" + target?: "offscreen" + via?: "background" + requestId?: string + payload: AnnotationActionPayload } | { - type: "EXPORT_ANNOTATION_TO_NOTE"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: AnnotationActionPayload; + type: "EXPORT_ANNOTATION_TO_NOTE" + target?: "offscreen" + via?: "background" + requestId?: string + payload: AnnotationActionPayload } | { - type: "EXPORT_ANNOTATION_TO_NOTION"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: AnnotationActionPayload; + type: "EXPORT_ANNOTATION_TO_NOTION" + target?: "offscreen" + via?: "background" + requestId?: string + payload: AnnotationActionPayload } | { - type: "GET_NOTES"; - target: "offscreen"; - via?: "background"; - requestId?: string; + type: "GET_NOTES" + target: "offscreen" + via?: "background" + requestId?: string } | { - type: "CREATE_NOTE"; - target: "offscreen"; - via?: "background"; - requestId?: string; - payload: { title: string; content: string; linked_conversation_ids: number[] }; + type: "CREATE_NOTE" + target: "offscreen" + via?: "background" + requestId?: string + payload: { + title: string + content: string + linked_conversation_ids: number[] + } } | { - type: "UPDATE_NOTE"; - target: "offscreen"; - via?: "background"; - requestId?: string; - payload: { id: number; changes: { title?: string; content?: string } }; + type: "UPDATE_NOTE" + target: "offscreen" + via?: "background" + requestId?: string + payload: { id: number; changes: { title?: string; content?: string } } } | { - type: "DELETE_NOTE"; - target: "offscreen"; - via?: "background"; - requestId?: string; - payload: { id: number }; + type: "DELETE_NOTE" + target: "offscreen" + via?: "background" + requestId?: string + payload: { id: number } } | { - type: "SEARCH_CONVERSATION_IDS_BY_TEXT"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { query: string }; + type: "SEARCH_CONVERSATION_IDS_BY_TEXT" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { query: string } } | { - type: "SEARCH_CONVERSATION_MATCHES_BY_TEXT"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: SearchConversationMatchesQuery; + type: "SEARCH_CONVERSATION_MATCHES_BY_TEXT" + target?: "offscreen" + via?: "background" + requestId?: string + payload: SearchConversationMatchesQuery } | { - type: "DELETE_CONVERSATION"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { id: number }; + type: "DELETE_CONVERSATION" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { id: number } } | { - type: "UPDATE_CONVERSATION_TITLE"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { id: number; title: string }; + type: "UPDATE_CONVERSATION_TITLE" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { id: number; title: string } } | { - type: "GET_DASHBOARD_STATS"; - target?: "offscreen"; - via?: "background"; - requestId?: string; + type: "GET_DASHBOARD_STATS" + target?: "offscreen" + via?: "background" + requestId?: string } | { - type: "GET_STORAGE_USAGE"; - target?: "offscreen"; - via?: "background"; - requestId?: string; + type: "GET_STORAGE_USAGE" + target?: "offscreen" + via?: "background" + requestId?: string } | { - type: "GET_DATA_OVERVIEW"; - target?: "offscreen"; - via?: "background"; - requestId?: string; + type: "GET_DATA_OVERVIEW" + target?: "offscreen" + via?: "background" + requestId?: string } | { - type: "EXPORT_DATA"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { format: ExportFormat }; + type: "EXPORT_DATA" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { format: ExportFormat } } | { - type: "CLEAR_ALL_DATA"; - target?: "offscreen"; - via?: "background"; - requestId?: string; + type: "CLEAR_ALL_DATA" + target?: "offscreen" + via?: "background" + requestId?: string } | { - type: "CLEAR_INSIGHTS_CACHE"; - target?: "offscreen"; - via?: "background"; - requestId?: string; + type: "CLEAR_INSIGHTS_CACHE" + target?: "offscreen" + via?: "background" + requestId?: string } | { - type: "GET_LLM_SETTINGS"; - target?: "offscreen"; - via?: "background"; - requestId?: string; + type: "GET_LLM_SETTINGS" + target?: "offscreen" + via?: "background" + requestId?: string } | { - type: "SET_LLM_SETTINGS"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { settings: LlmConfig }; + type: "SET_LLM_SETTINGS" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { settings: LlmConfig } } | { - type: "TEST_LLM_CONNECTION"; - target?: "offscreen"; - via?: "background"; - requestId?: string; + type: "TEST_LLM_CONNECTION" + target?: "offscreen" + via?: "background" + requestId?: string } | { - type: "GET_CONVERSATION_SUMMARY"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { conversationId: number }; + type: "GET_CONVERSATION_SUMMARY" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { conversationId: number } } | { - type: "GENERATE_CONVERSATION_SUMMARY"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { conversationId: number }; + type: "GENERATE_CONVERSATION_SUMMARY" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { conversationId: number } } | { - type: "GET_WEEKLY_REPORT"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { rangeStart: number; rangeEnd: number }; + type: "GET_WEEKLY_REPORT" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { rangeStart: number; rangeEnd: number } } | { - type: "GENERATE_WEEKLY_REPORT"; - target?: "offscreen"; - via?: "background"; - requestId?: string; - payload: { rangeStart: number; rangeEnd: number }; + type: "GENERATE_WEEKLY_REPORT" + target?: "offscreen" + via?: "background" + requestId?: string + payload: { rangeStart: number; rangeEnd: number } } | { - type: "GET_ACTIVE_CAPTURE_STATUS"; - target?: "background"; - requestId?: string; + type: "GET_ACTIVE_CAPTURE_STATUS" + target?: "background" + requestId?: string } | { - type: "FORCE_ARCHIVE_TRANSIENT"; - target?: "background"; - requestId?: string; + type: "FORCE_ARCHIVE_TRANSIENT" + target?: "background" + requestId?: string } | { - type: "RUN_VECTORIZATION"; - target?: "background"; - requestId?: string; - }; + type: "RUN_VECTORIZATION" + target?: "background" + requestId?: string + } export type ResponseDataMap = { CAPTURE_CONVERSATION: { - saved: boolean; - newMessages: number; - conversationId?: number; - decision: CaptureDecisionMeta; - }; - GET_CONVERSATIONS: Conversation[]; - GET_TOPICS: Topic[]; - CREATE_TOPIC: { topic: Topic }; - UPDATE_CONVERSATION_TOPIC: { updated: boolean; conversation: Conversation }; - UPDATE_CONVERSATION: { updated: boolean; conversation: Conversation }; - RUN_GARDENER: { updated: boolean; conversation: Conversation; result: GardenerResult }; - GET_RELATED_CONVERSATIONS: RelatedConversation[]; - GET_ALL_EDGES: Array<{ source: number; target: number; weight: number }>; - RENAME_FOLDER_TAG: { updated: number }; - MOVE_FOLDER_TAG: { updated: number }; - REMOVE_FOLDER_TAG: { updated: number }; - ASK_KNOWLEDGE_BASE: RagResponse & { sessionId: string }; - CREATE_EXPLORE_SESSION: { sessionId: string }; - LIST_EXPLORE_SESSIONS: ExploreSession[]; - GET_EXPLORE_SESSION: ExploreSession | null; - GET_EXPLORE_MESSAGES: ExploreMessage[]; - DELETE_EXPLORE_SESSION: { deleted: boolean }; - RENAME_EXPLORE_SESSION: { updated: boolean }; - UPDATE_EXPLORE_MESSAGE_CONTEXT: { updated: boolean }; - GET_MESSAGES: Message[]; - GET_ANNOTATIONS_BY_CONVERSATION: Annotation[]; - SAVE_ANNOTATION: { annotation: Annotation }; - DELETE_ANNOTATION: { deleted: boolean }; - EXPORT_ANNOTATION_TO_NOTE: { note: Note }; - EXPORT_ANNOTATION_TO_NOTION: { pageId: string; url?: string }; - GET_NOTES: Note[]; - CREATE_NOTE: { note: Note }; - UPDATE_NOTE: { note: Note }; - DELETE_NOTE: { deleted: boolean }; - SEARCH_CONVERSATION_IDS_BY_TEXT: number[]; - SEARCH_CONVERSATION_MATCHES_BY_TEXT: ConversationMatchSummary[]; - DELETE_CONVERSATION: { deleted: boolean }; - UPDATE_CONVERSATION_TITLE: { updated: boolean; conversation: Conversation }; - GET_DASHBOARD_STATS: DashboardStats; - GET_STORAGE_USAGE: StorageUsageSnapshot; - GET_DATA_OVERVIEW: DataOverviewSnapshot; - EXPORT_DATA: ExportPayload; - CLEAR_ALL_DATA: { cleared: boolean }; - CLEAR_INSIGHTS_CACHE: { cleared: boolean }; - GET_LLM_SETTINGS: { settings: LlmConfig | null }; - SET_LLM_SETTINGS: { saved: boolean }; + saved: boolean + newMessages: number + conversationId?: number + decision: CaptureDecisionMeta + } + GET_CONVERSATIONS: Conversation[] + GET_TOPICS: Topic[] + CREATE_TOPIC: { topic: Topic } + UPDATE_CONVERSATION_TOPIC: { updated: boolean; conversation: Conversation } + UPDATE_CONVERSATION: { updated: boolean; conversation: Conversation } + RUN_GARDENER: { + updated: boolean + conversation: Conversation + result: GardenerResult + } + GET_RELATED_CONVERSATIONS: RelatedConversation[] + GET_ALL_EDGES: Array<{ source: number; target: number; weight: number }> + RENAME_FOLDER_TAG: { updated: number } + MOVE_FOLDER_TAG: { updated: number } + REMOVE_FOLDER_TAG: { updated: number } + ASK_KNOWLEDGE_BASE: RagResponse & { sessionId: string } + CREATE_EXPLORE_SESSION: { sessionId: string } + LIST_EXPLORE_SESSIONS: ExploreSession[] + GET_EXPLORE_SESSION: ExploreSession | null + GET_EXPLORE_MESSAGES: ExploreMessage[] + DELETE_EXPLORE_SESSION: { deleted: boolean } + RENAME_EXPLORE_SESSION: { updated: boolean } + UPDATE_EXPLORE_MESSAGE_CONTEXT: { updated: boolean } + GET_MESSAGES: Message[] + GET_ANNOTATIONS_BY_CONVERSATION: Annotation[] + SAVE_ANNOTATION: { annotation: Annotation } + DELETE_ANNOTATION: { deleted: boolean } + EXPORT_ANNOTATION_TO_NOTE: { note: Note } + EXPORT_ANNOTATION_TO_NOTION: { pageId: string; url?: string } + GET_NOTES: Note[] + CREATE_NOTE: { note: Note } + UPDATE_NOTE: { note: Note } + DELETE_NOTE: { deleted: boolean } + SEARCH_CONVERSATION_IDS_BY_TEXT: number[] + SEARCH_CONVERSATION_MATCHES_BY_TEXT: ConversationMatchSummary[] + DELETE_CONVERSATION: { deleted: boolean } + UPDATE_CONVERSATION_TITLE: { updated: boolean; conversation: Conversation } + GET_DASHBOARD_STATS: DashboardStats + GET_STORAGE_USAGE: StorageUsageSnapshot + GET_DATA_OVERVIEW: DataOverviewSnapshot + EXPORT_DATA: ExportPayload + CLEAR_ALL_DATA: { cleared: boolean } + CLEAR_INSIGHTS_CACHE: { cleared: boolean } + GET_LLM_SETTINGS: { settings: LlmConfig | null } + SET_LLM_SETTINGS: { saved: boolean } TEST_LLM_CONNECTION: { - ok: boolean; - message?: string; + ok: boolean + message?: string diagnostic?: { - code: string; - route: string; - status: number | null; - requestId: string | null; - rawMessage: string; - userMessage: string; - technicalSummary: string; - }; - }; - GET_CONVERSATION_SUMMARY: SummaryRecord | null; - GENERATE_CONVERSATION_SUMMARY: SummaryRecord; - GET_WEEKLY_REPORT: WeeklyReportRecord | null; - GENERATE_WEEKLY_REPORT: WeeklyReportRecord; - GET_ACTIVE_CAPTURE_STATUS: ActiveCaptureStatus; - FORCE_ARCHIVE_TRANSIENT: ForceArchiveTransientResult; - RUN_VECTORIZATION: { queued: boolean }; -}; + code: string + route: string + status: number | null + requestId: string | null + rawMessage: string + userMessage: string + technicalSummary: string + } + } + GET_CONVERSATION_SUMMARY: SummaryRecord | null + GENERATE_CONVERSATION_SUMMARY: SummaryRecord + GET_WEEKLY_REPORT: WeeklyReportRecord | null + GENERATE_WEEKLY_REPORT: WeeklyReportRecord + GET_ACTIVE_CAPTURE_STATUS: ActiveCaptureStatus + FORCE_ARCHIVE_TRANSIENT: ForceArchiveTransientResult + RUN_VECTORIZATION: { queued: boolean } +} -export type ResponseMessage = +export type ResponseMessage< + T extends keyof ResponseDataMap = keyof ResponseDataMap +> = | { - ok: true; - type: T; - requestId?: string; - data: ResponseDataMap[T]; + ok: true + type: T + requestId?: string + data: ResponseDataMap[T] } | { - ok: false; - type: T; - requestId?: string; - error: string; - }; + ok: false + type: T + requestId?: string + error: string + } export function isRequestMessage(value: unknown): value is RequestMessage { - if (!value || typeof value !== "object") return false; - const msg = value as { type?: unknown }; - return typeof msg.type === "string"; + if (!value || typeof value !== "object") return false + const msg = value as { type?: unknown } + return typeof msg.type === "string" } export function isInsightPipelineProgressMessage( value: unknown ): value is InsightPipelineProgressMessage { - if (!value || typeof value !== "object") return false; + if (!value || typeof value !== "object") return false const message = value as { - type?: unknown; - payload?: Partial; - }; - if (message.type !== "INSIGHT_PIPELINE_PROGRESS") return false; - if (!message.payload || typeof message.payload !== "object") return false; + type?: unknown + payload?: Partial + } + if (message.type !== "INSIGHT_PIPELINE_PROGRESS") return false + if (!message.payload || typeof message.payload !== "object") return false - const payload = message.payload; + const payload = message.payload return ( typeof payload.pipelineId === "string" && typeof payload.scope === "string" && @@ -588,5 +608,5 @@ export function isInsightPipelineProgressMessage( typeof payload.modelId === "string" && typeof payload.promptVersion === "string" && typeof payload.seq === "number" - ); + ) } diff --git a/frontend/src/lib/services/captureSettingsService.ts b/frontend/src/lib/services/captureSettingsService.ts index 372087e..4d86cdf 100644 --- a/frontend/src/lib/services/captureSettingsService.ts +++ b/frontend/src/lib/services/captureSettingsService.ts @@ -1,9 +1,13 @@ -import type { CaptureMode, CaptureSettings } from "../types"; +import type { CaptureMode, CaptureSettings } from "../types" +import { + getLocalStorageValue, + setLocalStorageValue +} from "../utils/chromeStorageBridge" -const STORAGE_KEY = "vesti_capture_settings"; -const DEFAULT_MIN_TURNS = 3; -const MIN_TURNS_LIMIT = 1; -const MAX_TURNS_LIMIT = 20; +const STORAGE_KEY = "vesti_capture_settings" +const DEFAULT_MIN_TURNS = 3 +const MIN_TURNS_LIMIT = 1 +const MAX_TURNS_LIMIT = 20 const LEGACY_MODE_MAP: Record = { full_mirror: "mirror", @@ -11,37 +15,32 @@ const LEGACY_MODE_MAP: Record = { curator: "manual", mirror: "mirror", smart: "smart", - manual: "manual", -}; + manual: "manual" +} export const DEFAULT_CAPTURE_SETTINGS: CaptureSettings = { mode: "mirror", smartConfig: { minTurns: DEFAULT_MIN_TURNS, - blacklistKeywords: [], - }, -}; - -function getStorage() { - if (!chrome?.storage?.local) { - throw new Error("STORAGE_UNAVAILABLE"); + blacklistKeywords: [] } - return chrome.storage.local; } function normalizeMode(value: unknown): CaptureMode { if (typeof value !== "string") { - return DEFAULT_CAPTURE_SETTINGS.mode; + return DEFAULT_CAPTURE_SETTINGS.mode } - return LEGACY_MODE_MAP[value.trim().toLowerCase()] ?? DEFAULT_CAPTURE_SETTINGS.mode; + return ( + LEGACY_MODE_MAP[value.trim().toLowerCase()] ?? DEFAULT_CAPTURE_SETTINGS.mode + ) } function normalizeMinTurns(value: unknown): number { - const num = Number(value); + const num = Number(value) if (!Number.isFinite(num)) { - return DEFAULT_MIN_TURNS; + return DEFAULT_MIN_TURNS } - return Math.min(MAX_TURNS_LIMIT, Math.max(MIN_TURNS_LIMIT, Math.round(num))); + return Math.min(MAX_TURNS_LIMIT, Math.max(MIN_TURNS_LIMIT, Math.round(num))) } function normalizeBlacklistKeywords(value: unknown): string[] { @@ -49,65 +48,50 @@ function normalizeBlacklistKeywords(value: unknown): string[] { ? value : typeof value === "string" ? value.split(",") - : []; + : [] - const result: string[] = []; - const seen = new Set(); + const result: string[] = [] + const seen = new Set() for (const raw of rawItems) { - const keyword = String(raw).trim(); - if (!keyword || seen.has(keyword)) continue; - seen.add(keyword); - result.push(keyword); + const keyword = String(raw).trim() + if (!keyword || seen.has(keyword)) continue + seen.add(keyword) + result.push(keyword) } - return result; + return result } export function normalizeCaptureSettings(input: unknown): CaptureSettings { if (!input || typeof input !== "object") { - return DEFAULT_CAPTURE_SETTINGS; + return DEFAULT_CAPTURE_SETTINGS } const raw = input as { - mode?: unknown; - smartConfig?: { minTurns?: unknown; blacklistKeywords?: unknown }; - }; + mode?: unknown + smartConfig?: { minTurns?: unknown; blacklistKeywords?: unknown } + } return { mode: normalizeMode(raw.mode), smartConfig: { minTurns: normalizeMinTurns(raw.smartConfig?.minTurns), - blacklistKeywords: normalizeBlacklistKeywords(raw.smartConfig?.blacklistKeywords), - }, - }; + blacklistKeywords: normalizeBlacklistKeywords( + raw.smartConfig?.blacklistKeywords + ) + } + } } export async function getCaptureSettings(): Promise { - const storage = getStorage(); - return new Promise((resolve, reject) => { - storage.get([STORAGE_KEY], (result: Record) => { - const err = chrome.runtime?.lastError; - if (err) { - reject(new Error(err.message)); - return; - } - resolve(normalizeCaptureSettings(result[STORAGE_KEY])); - }); - }); + const raw = await getLocalStorageValue(STORAGE_KEY) + return normalizeCaptureSettings(raw) } -export async function setCaptureSettings(settings: CaptureSettings): Promise { - const storage = getStorage(); - const normalized = normalizeCaptureSettings(settings); - return new Promise((resolve, reject) => { - storage.set({ [STORAGE_KEY]: normalized }, () => { - const err = chrome.runtime?.lastError; - if (err) { - reject(new Error(err.message)); - return; - } - resolve(); - }); - }); +export async function setCaptureSettings( + settings: CaptureSettings +): Promise { + const normalized = normalizeCaptureSettings(settings) + await setLocalStorageValue(STORAGE_KEY, normalized) } diff --git a/frontend/src/lib/services/llmSettingsService.ts b/frontend/src/lib/services/llmSettingsService.ts index 072ab8c..763dc46 100644 --- a/frontend/src/lib/services/llmSettingsService.ts +++ b/frontend/src/lib/services/llmSettingsService.ts @@ -1,57 +1,32 @@ -import type { LlmConfig } from "../types"; +import type { LlmConfig } from "../types" +import { + getLocalStorageValue, + setLocalStorageValue +} from "../utils/chromeStorageBridge" import { buildDefaultLlmSettings, needsProxySettingsBackfill, - normalizeLlmSettings, -} from "./llmConfig"; - -const STORAGE_KEY = "vesti_llm_settings"; + normalizeLlmSettings +} from "./llmConfig" -function getStorage() { - if (!chrome?.storage?.local) { - throw new Error("STORAGE_UNAVAILABLE"); - } - return chrome.storage.local; -} +const STORAGE_KEY = "vesti_llm_settings" export async function getLlmSettings(): Promise { - const storage = getStorage(); - return new Promise((resolve, reject) => { - storage.get([STORAGE_KEY], (result: Record) => { - const err = chrome.runtime?.lastError; - if (err) { - reject(new Error(err.message)); - return; - } + const raw = + (await getLocalStorageValue(STORAGE_KEY)) ?? null + if (!raw) { + return buildDefaultLlmSettings() + } - const raw = (result[STORAGE_KEY] as LlmConfig | undefined) ?? null; - if (!raw) { - resolve(buildDefaultLlmSettings()); - return; - } + const normalized = normalizeLlmSettings(raw) + if (needsProxySettingsBackfill(raw)) { + void setLocalStorageValue(STORAGE_KEY, normalized).catch(() => {}) + } - const normalized = normalizeLlmSettings(raw); - if (needsProxySettingsBackfill(raw)) { - storage.set({ [STORAGE_KEY]: normalized }, () => { - void chrome.runtime?.lastError; - }); - } - resolve(normalized); - }); - }); + return normalized } export async function setLlmSettings(settings: LlmConfig): Promise { - const storage = getStorage(); - const normalized = normalizeLlmSettings(settings); - return new Promise((resolve, reject) => { - storage.set({ [STORAGE_KEY]: normalized }, () => { - const err = chrome.runtime?.lastError; - if (err) { - reject(new Error(err.message)); - return; - } - resolve(); - }); - }); + const normalized = normalizeLlmSettings(settings) + await setLocalStorageValue(STORAGE_KEY, normalized) } diff --git a/frontend/src/lib/services/searchService.ts b/frontend/src/lib/services/searchService.ts index 5a470d5..ff5c1e6 100644 --- a/frontend/src/lib/services/searchService.ts +++ b/frontend/src/lib/services/searchService.ts @@ -1,90 +1,97 @@ -import type { +import { + getConversationCaptureFreshnessAt, + getConversationOriginAt +} from "../conversations/timestamps" +import { + markKnowledgeConversationsDirty, + queryAllEdgesFromKnowledgeStore, + queryRelatedConversationsFromKnowledgeStore, + retrieveRagContextFromKnowledgeStore +} from "../db/knowledgeQueryStore" +import { + addExploreMessage, + createExploreSession, + getExploreMessages, + getSummary, + getWeeklyReport, + listConversationsByRange, + updateExploreSession +} from "../db/repository" +import { db } from "../db/schema" +import type { Annotation, Conversation, - ExploreAskOptions, - ExploreAgentPlan, ExploreAgentMeta, + ExploreAgentPlan, + ExploreAskOptions, ExploreContextCandidate, ExploreIntentType, ExploreMode, - ExploreResolvedTimeScope, ExploreRequestedTimeScope, + ExploreResolvedTimeScope, ExploreSearchScope, ExploreToolCall, ExploreToolName, LlmConfig, RagResponse, - RelatedConversation, -} from "../types"; -import { - getConversationCaptureFreshnessAt, - getConversationOriginAt, -} from "../conversations/timestamps"; -import { db } from "../db/schema"; -import { - addExploreMessage, - createExploreSession, - getExploreMessages, - getSummary, - getWeeklyReport, - listConversationsByRange, - updateExploreSession, -} from "../db/repository"; -import { embedText } from "./embeddingService"; + RelatedConversation +} from "../types" +import { logger } from "../utils/logger" +import { embedText } from "./embeddingService" import { generateConversationSummary, - generateWeeklyReport, -} from "./insightGenerationService"; -import type { - CallModelScopeOptions, - InferenceCallResult, -} from "./llmService"; -import { callInference } from "./llmService"; -import { getEffectiveModelId, getLlmAccessMode } from "./llmConfig"; -import { getLlmSettings } from "./llmSettingsService"; -import { logger } from "../utils/logger"; - -const MAX_MESSAGE_COUNT = 12; -const MAX_TEXT_LENGTH = 4000; -const MAX_RAG_SOURCES = 5; -const MAX_EMBEDDING_CHARS = 2048; -const AGENT_SUMMARY_SOURCE_LIMIT = 3; -const MAX_WEEKLY_CANDIDATES = 12; -const MAX_WEEKLY_SOURCE_CHIPS = 8; -const EXPLORE_CONTINUATION_MAX_ROUNDS = 2; -const EXPLORE_CONTINUATION_TAIL_CHARS = 1200; -const EXPLORE_CONTINUATION_MIN_EXTENSION = 24; + generateWeeklyReport +} from "./insightGenerationService" +import { getEffectiveModelId, getLlmAccessMode } from "./llmConfig" +import type { CallModelScopeOptions, InferenceCallResult } from "./llmService" +import { callInference } from "./llmService" +import { getLlmSettings } from "./llmSettingsService" + +const MAX_MESSAGE_COUNT = 12 +const MAX_TEXT_LENGTH = 4000 +const MAX_RAG_SOURCES = 5 +const MAX_EMBEDDING_CHARS = 2048 +const AGENT_SUMMARY_SOURCE_LIMIT = 3 +const MAX_WEEKLY_CANDIDATES = 12 +const MAX_WEEKLY_SOURCE_CHIPS = 8 +const EXPLORE_CONTINUATION_MAX_ROUNDS = 2 +const EXPLORE_CONTINUATION_TAIL_CHARS = 1200 +const EXPLORE_CONTINUATION_MIN_EXTENSION = 24 type SummaryToolResult = { - snippets: Map; - cacheHits: number; - generated: number; - failed: number; -}; + snippets: Map + cacheHits: number + generated: number + failed: number +} type WeeklySummaryToolResult = { - summaryText: string; - sourceOrigin: "cached_report" | "generated_report" | "custom_summary" | "local_only"; - conversations: Conversation[]; - sources: RelatedConversation[]; -}; + summaryText: string + sourceOrigin: + | "cached_report" + | "generated_report" + | "custom_summary" + | "local_only" + conversations: Conversation[] + sources: RelatedConversation[] +} type RagRetrievalItem = { - source: RelatedConversation; - contextBlock: string; - excerpt: string; -}; + source: RelatedConversation + contextBlock: string + excerpt: string +} type RagRetrievalResult = { - sources: RelatedConversation[]; - context: string; - items: RagRetrievalItem[]; -}; + sources: RelatedConversation[] + context: string + items: RagRetrievalItem[] +} type ExploreCompletionResult = { - content: string; - continuationCount: number; -}; + content: string + continuationCount: number +} const TOOL_DESCRIPTIONS: Record = { intent_planner: @@ -102,39 +109,61 @@ const TOOL_DESCRIPTIONS: Record = { context_compiler: "Builds the editable context draft and source list so the reasoning chain stays inspectable.", answer_synthesizer: - "Writes the final answer from the collected evidence and points the user to concrete sources when evidence is partial.", -}; + "Writes the final answer from the collected evidence and points the user to concrete sources when evidence is partial." +} function getToolDescription(name: ExploreToolName): string { - return TOOL_DESCRIPTIONS[name]; + return TOOL_DESCRIPTIONS[name] } -function hasUsableLlmSettings(settings: LlmConfig | null | undefined): settings is LlmConfig { - if (!settings) return false; +function hasUsableLlmSettings( + settings: LlmConfig | null | undefined +): settings is LlmConfig { + if (!settings) return false - const mode = getLlmAccessMode(settings); - const modelId = getEffectiveModelId(settings); + const mode = getLlmAccessMode(settings) + const modelId = getEffectiveModelId(settings) if (mode === "demo_proxy") { - return Boolean((settings.proxyBaseUrl || settings.proxyUrl || "").trim() && modelId); + return Boolean( + (settings.proxyBaseUrl || settings.proxyUrl || "").trim() && modelId + ) } - return Boolean((settings.baseUrl || "").trim() && (settings.apiKey || "").trim() && modelId); + return Boolean( + (settings.baseUrl || "").trim() && (settings.apiKey || "").trim() && modelId + ) } function formatLocalIsoDate(date: Date): string { - const year = date.getFullYear(); - const month = `${date.getMonth() + 1}`.padStart(2, "0"); - const day = `${date.getDate()}`.padStart(2, "0"); - return `${year}-${month}-${day}`; + const year = date.getFullYear() + const month = `${date.getMonth() + 1}`.padStart(2, "0") + const day = `${date.getDate()}`.padStart(2, "0") + return `${year}-${month}-${day}` } function getStartOfDay(date: Date): number { - return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0).getTime(); + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + 0, + 0, + 0, + 0 + ).getTime() } function getEndOfDay(date: Date): number { - return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999).getTime(); + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + 23, + 59, + 59, + 999 + ).getTime() } function buildResolvedTimeScope( @@ -149,93 +178,107 @@ function buildResolvedTimeScope( rangeStart: getStartOfDay(start), rangeEnd: getEndOfDay(end), startDate: formatLocalIsoDate(start), - endDate: formatLocalIsoDate(end), - }; -} - -function getCurrentWeekToDateRange(reference = new Date()): ExploreResolvedTimeScope { - const now = new Date(reference); - const weekDay = now.getDay(); - const daysSinceMonday = (weekDay + 6) % 7; - const start = new Date(now); - start.setDate(now.getDate() - daysSinceMonday); - return buildResolvedTimeScope("current_week_to_date", "Current week to date", start, now); + endDate: formatLocalIsoDate(end) + } } -function getLastSevenDaysRange(reference = new Date()): ExploreResolvedTimeScope { - const end = new Date(reference); - const start = new Date(reference); - start.setDate(end.getDate() - 6); - return buildResolvedTimeScope("last_7_days", "Last 7 days", start, end); +function getCurrentWeekToDateRange( + reference = new Date() +): ExploreResolvedTimeScope { + const now = new Date(reference) + const weekDay = now.getDay() + const daysSinceMonday = (weekDay + 6) % 7 + const start = new Date(now) + start.setDate(now.getDate() - daysSinceMonday) + return buildResolvedTimeScope( + "current_week_to_date", + "Current week to date", + start, + now + ) +} + +function getLastSevenDaysRange( + reference = new Date() +): ExploreResolvedTimeScope { + const end = new Date(reference) + const start = new Date(reference) + start.setDate(end.getDate() - 6) + return buildResolvedTimeScope("last_7_days", "Last 7 days", start, end) } -function getLastFullWeekRange(reference = new Date()): ExploreResolvedTimeScope { - const now = new Date(reference); - const weekDay = now.getDay(); - const daysSinceMonday = (weekDay + 6) % 7; - const startOfThisWeek = new Date(now); - startOfThisWeek.setDate(now.getDate() - daysSinceMonday); +function getLastFullWeekRange( + reference = new Date() +): ExploreResolvedTimeScope { + const now = new Date(reference) + const weekDay = now.getDay() + const daysSinceMonday = (weekDay + 6) % 7 + const startOfThisWeek = new Date(now) + startOfThisWeek.setDate(now.getDate() - daysSinceMonday) - const end = new Date(startOfThisWeek); - end.setDate(startOfThisWeek.getDate() - 1); + const end = new Date(startOfThisWeek) + end.setDate(startOfThisWeek.getDate() - 1) - const start = new Date(end); - start.setDate(end.getDate() - 6); + const start = new Date(end) + start.setDate(end.getDate() - 6) - return buildResolvedTimeScope("last_full_week", "Last full week", start, end); + return buildResolvedTimeScope("last_full_week", "Last full week", start, end) } function parseDateInput(value?: string): Date | null { - if (!value?.trim()) return null; - const match = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/); - if (!match) return null; - const year = Number(match[1]); - const month = Number(match[2]) - 1; - const day = Number(match[3]); - const parsed = new Date(year, month, day); + if (!value?.trim()) return null + const match = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/) + if (!match) return null + const year = Number(match[1]) + const month = Number(match[2]) - 1 + const day = Number(match[3]) + const parsed = new Date(year, month, day) if ( parsed.getFullYear() !== year || parsed.getMonth() !== month || parsed.getDate() !== day ) { - return null; + return null } - return parsed; + return parsed } function resolveRequestedTimeScope( requested?: ExploreRequestedTimeScope ): ExploreResolvedTimeScope | undefined { if (!requested || requested.preset === "none") { - return undefined; + return undefined } switch (requested.preset) { case "current_week_to_date": - return getCurrentWeekToDateRange(); + return getCurrentWeekToDateRange() case "last_7_days": - return getLastSevenDaysRange(); + return getLastSevenDaysRange() case "last_full_week": - return getLastFullWeekRange(); + return getLastFullWeekRange() case "custom": { - const start = parseDateInput(requested.startDate); - const end = parseDateInput(requested.endDate); + const start = parseDateInput(requested.startDate) + const end = parseDateInput(requested.endDate) if (!start || !end || start.getTime() > end.getTime()) { - return undefined; + return undefined } return buildResolvedTimeScope( "custom", - requested.label?.trim() || `${requested.startDate} to ${requested.endDate}`, + requested.label?.trim() || + `${requested.startDate} to ${requested.endDate}`, start, end - ); + ) } } } -function buildToolPlan(preferredPath: ExploreAgentPlan["preferredPath"]): ExploreToolName[] { +function buildToolPlan( + preferredPath: ExploreAgentPlan["preferredPath"] +): ExploreToolName[] { if (preferredPath === "clarify") { - return ["intent_planner"]; + return ["intent_planner"] } if (preferredPath === "weekly_summary") { @@ -244,8 +287,8 @@ function buildToolPlan(preferredPath: ExploreAgentPlan["preferredPath"]): Explor "time_scope_resolver", "weekly_summary_tool", "context_compiler", - "answer_synthesizer", - ]; + "answer_synthesizer" + ] } return [ @@ -253,18 +296,18 @@ function buildToolPlan(preferredPath: ExploreAgentPlan["preferredPath"]): Explor "search_rag", "summary_tool", "context_compiler", - "answer_synthesizer", - ]; + "answer_synthesizer" + ] } function normalizeRequestedTimeScope( value: unknown ): ExploreRequestedTimeScope | undefined { if (!value || typeof value !== "object") { - return undefined; + return undefined } - const candidate = value as Record; + const candidate = value as Record const preset = candidate.preset === "current_week_to_date" || candidate.preset === "last_7_days" || @@ -273,23 +316,29 @@ function normalizeRequestedTimeScope( ? candidate.preset : candidate.preset === "none" ? "none" - : undefined; + : undefined if (!preset) { - return undefined; + return undefined } return { preset, - label: typeof candidate.label === "string" ? candidate.label.trim() : undefined, + label: + typeof candidate.label === "string" ? candidate.label.trim() : undefined, startDate: - typeof candidate.startDate === "string" ? candidate.startDate.trim() : undefined, - endDate: typeof candidate.endDate === "string" ? candidate.endDate.trim() : undefined, - }; + typeof candidate.startDate === "string" + ? candidate.startDate.trim() + : undefined, + endDate: + typeof candidate.endDate === "string" + ? candidate.endDate.trim() + : undefined + } } function hasExplicitWeeklySignal(query: string): boolean { - const lowered = query.toLowerCase(); + const lowered = query.toLowerCase() return ( /this week|current week|last week|previous week|last 7 days|past 7 days|recent week/.test( lowered @@ -297,11 +346,11 @@ function hasExplicitWeeklySignal(query: string): boolean { /\u672c\u5468|\u8fd9\u5468|\u8fd9\u4e00\u5468|\u4e0a\u5468|\u6700\u8fd1\u4e03\u5929|\u8fc7\u53bb\u4e03\u5929/.test( query ) - ); + ) } function hasSummaryStyleSignal(query: string): boolean { - const lowered = query.toLowerCase(); + const lowered = query.toLowerCase() return ( /summary|summarize|overview|recap|review|what did i do|what have i done|timeline|chronological/.test( lowered @@ -309,25 +358,28 @@ function hasSummaryStyleSignal(query: string): boolean { /\u603b\u7ed3|\u6982\u89c8|\u56de\u987e|\u6c47\u603b|\u65f6\u95f4\u7ebf|\u505a\u4e86\u4ec0\u4e48/.test( query ) - ); + ) } -function applyPlannerGuardrails(query: string, plan: ExploreAgentPlan): ExploreAgentPlan { +function applyPlannerGuardrails( + query: string, + plan: ExploreAgentPlan +): ExploreAgentPlan { if (plan.preferredPath !== "weekly_summary") { - return plan; + return plan } if (hasExplicitWeeklySignal(query)) { - return plan; + return plan } const downgradedIntent: ExploreIntentType = hasSummaryStyleSignal(query) ? "cross_conversation_summary" - : "fact_lookup"; + : "fact_lookup" const downgradedSummaryTarget = downgradedIntent === "cross_conversation_summary" ? clamp(plan.sourceLimit, 1, AGENT_SUMMARY_SOURCE_LIMIT) - : clamp(Math.min(plan.sourceLimit, 2), 1, AGENT_SUMMARY_SOURCE_LIMIT); + : clamp(Math.min(plan.sourceLimit, 2), 1, AGENT_SUMMARY_SOURCE_LIMIT) return { ...plan, @@ -337,8 +389,8 @@ function applyPlannerGuardrails(query: string, plan: ExploreAgentPlan): ExploreA requestedTimeScope: undefined, resolvedTimeScope: undefined, toolPlan: buildToolPlan("rag"), - reason: `${plan.reason} | guardrail: weekly_summary requires an explicit weekly time signal in the query`, - }; + reason: `${plan.reason} | guardrail: weekly_summary requires an explicit weekly time signal in the query` + } } function buildFallbackPlan( @@ -346,23 +398,23 @@ function buildFallbackPlan( requestedLimit: number, fallbackReason: string ): ExploreAgentPlan { - const lowered = query.toLowerCase(); + const lowered = query.toLowerCase() const currentWeekIntent = /this week|current week/.test(lowered) || - /\u672c\u5468|\u8fd9\u5468|\u8fd9\u4e00\u5468/.test(query); + /\u672c\u5468|\u8fd9\u5468|\u8fd9\u4e00\u5468/.test(query) const lastWeekIntent = - /last week|previous week/.test(lowered) || /\u4e0a\u5468/.test(query); + /last week|previous week/.test(lowered) || /\u4e0a\u5468/.test(query) const trailingWeekIntent = /last 7 days|past 7 days|recent week/.test(lowered) || - /\u8fc7\u53bb\u4e03\u5929|\u6700\u8fd1\u4e03\u5929/.test(query); - const weeklyIntent = currentWeekIntent || lastWeekIntent || trailingWeekIntent; + /\u8fc7\u53bb\u4e03\u5929|\u6700\u8fd1\u4e03\u5929/.test(query) + const weeklyIntent = currentWeekIntent || lastWeekIntent || trailingWeekIntent const summaryIntent = weeklyIntent || /summary|summarize|overview|recap|review/.test(lowered) || - /\u603b\u7ed3|\u6982\u89c8|\u56de\u987e|\u6c47\u603b/.test(query); + /\u603b\u7ed3|\u6982\u89c8|\u56de\u987e|\u6c47\u603b/.test(query) const timelineIntent = /timeline|chronological/.test(lowered) || - /\u65f6\u95f4\u7ebf|\u6309\u65f6\u95f4/.test(query); + /\u65f6\u95f4\u7ebf|\u6309\u65f6\u95f4/.test(query) const requestedTimeScope = weeklyIntent ? { @@ -375,25 +427,25 @@ function buildFallbackPlan( ? "Current week to date" : lastWeekIntent ? "Last full week" - : "Last 7 days", + : "Last 7 days" } - : undefined; + : undefined - const preferredPath = weeklyIntent ? "weekly_summary" : "rag"; - const sourceLimit = clamp(requestedLimit || MAX_RAG_SOURCES, 1, 8); + const preferredPath = weeklyIntent ? "weekly_summary" : "rag" + const sourceLimit = clamp(requestedLimit || MAX_RAG_SOURCES, 1, 8) const summaryTargetCount = preferredPath === "weekly_summary" ? 0 : summaryIntent ? clamp(sourceLimit, 1, AGENT_SUMMARY_SOURCE_LIMIT) - : clamp(Math.min(sourceLimit, 2), 1, AGENT_SUMMARY_SOURCE_LIMIT); + : clamp(Math.min(sourceLimit, 2), 1, AGENT_SUMMARY_SOURCE_LIMIT) const intent: ExploreIntentType = weeklyIntent ? "weekly_review" : timelineIntent ? "timeline" : summaryIntent ? "cross_conversation_summary" - : "fact_lookup"; + : "fact_lookup" return applyPlannerGuardrails(query, { intent, @@ -406,8 +458,8 @@ function buildFallbackPlan( : "Answer the user's question with source-grounded evidence from conversation history.", requestedTimeScope, resolvedTimeScope: resolveRequestedTimeScope(requestedTimeScope), - toolPlan: buildToolPlan(preferredPath), - }); + toolPlan: buildToolPlan(preferredPath) + }) } function normalizeAgentPlan( @@ -416,36 +468,38 @@ function normalizeAgentPlan( query: string ): ExploreAgentPlan { if (!raw || typeof raw !== "object") { - return buildFallbackPlan(query, requestedLimit, "PLANNER_OUTPUT_INVALID"); + return buildFallbackPlan(query, requestedLimit, "PLANNER_OUTPUT_INVALID") } - const candidate = raw as Record; + const candidate = raw as Record const intent: ExploreIntentType = candidate.intent === "cross_conversation_summary" || candidate.intent === "weekly_review" || candidate.intent === "timeline" || candidate.intent === "clarification_needed" ? candidate.intent - : "fact_lookup"; + : "fact_lookup" const preferredPath: ExploreAgentPlan["preferredPath"] = candidate.preferredPath === "weekly_summary" || candidate.preferredPath === "clarify" ? candidate.preferredPath - : "rag"; + : "rag" const sourceLimit = clamp( - typeof candidate.sourceLimit === "number" ? candidate.sourceLimit : requestedLimit || 5, + typeof candidate.sourceLimit === "number" + ? candidate.sourceLimit + : requestedLimit || 5, 1, 8 - ); + ) const normalizedRequestedTimeScope = normalizeRequestedTimeScope( candidate.requestedTimeScope - ); + ) const defaultSummaryTarget = preferredPath === "weekly_summary" ? 0 : intent === "cross_conversation_summary" || intent === "timeline" ? clamp(sourceLimit, 1, AGENT_SUMMARY_SOURCE_LIMIT) - : clamp(Math.min(sourceLimit, 2), 1, AGENT_SUMMARY_SOURCE_LIMIT); + : clamp(Math.min(sourceLimit, 2), 1, AGENT_SUMMARY_SOURCE_LIMIT) const summaryTargetCount = preferredPath === "weekly_summary" ? 0 @@ -455,7 +509,7 @@ function normalizeAgentPlan( : defaultSummaryTarget, 0, AGENT_SUMMARY_SOURCE_LIMIT - ); + ) const plan: ExploreAgentPlan = { intent, @@ -467,7 +521,9 @@ function normalizeAgentPlan( sourceLimit, summaryTargetCount, answerGoal: - typeof candidate.answerGoal === "string" ? candidate.answerGoal.trim() : undefined, + typeof candidate.answerGoal === "string" + ? candidate.answerGoal.trim() + : undefined, needsClarification: typeof candidate.needsClarification === "boolean" ? candidate.needsClarification @@ -480,30 +536,35 @@ function normalizeAgentPlan( preferredPath === "weekly_summary" ? normalizedRequestedTimeScope ?? { preset: "current_week_to_date", - label: "Current week to date", + label: "Current week to date" } - : normalizedRequestedTimeScope, - }; + : normalizedRequestedTimeScope + } - plan.resolvedTimeScope = resolveRequestedTimeScope(plan.requestedTimeScope); - plan.toolPlan = buildToolPlan(plan.preferredPath); + plan.resolvedTimeScope = resolveRequestedTimeScope(plan.requestedTimeScope) + plan.toolPlan = buildToolPlan(plan.preferredPath) - if ((plan.needsClarification || plan.preferredPath === "clarify") && !plan.clarifyingQuestion) { + if ( + (plan.needsClarification || plan.preferredPath === "clarify") && + !plan.clarifyingQuestion + ) { plan.clarifyingQuestion = - "I can answer this, but I need one more constraint first. Which conversations or time window should I use?"; + "I can answer this, but I need one more constraint first. Which conversations or time window should I use?" } - return applyPlannerGuardrails(query, plan); + return applyPlannerGuardrails(query, plan) } function buildPlannerPrompt(params: { - query: string; - historyContext: string; - requestedLimit: number; - searchScope?: ExploreSearchScope; + query: string + historyContext: string + requestedLimit: number + searchScope?: ExploreSearchScope }): string { - const today = formatLocalIsoDate(new Date()); - const history = params.historyContext ? truncateInline(params.historyContext, 700) : "(none)"; + const today = formatLocalIsoDate(new Date()) + const history = params.historyContext + ? truncateInline(params.historyContext, 700) + : "(none)" return [ `Today: ${today}`, @@ -545,20 +606,21 @@ function buildPlannerPrompt(params: { ' "startDate": "YYYY-MM-DD when preset=custom",', ' "endDate": "YYYY-MM-DD when preset=custom"', " }", - "}", - ].join("\n"); + "}" + ].join("\n") } async function planAgentIntent(params: { - query: string; - historyContext: string; - requestedLimit: number; - searchScope?: ExploreSearchScope; - settings: LlmConfig | null; + query: string + historyContext: string + requestedLimit: number + searchScope?: ExploreSearchScope + settings: LlmConfig | null }): Promise { - const { query, historyContext, requestedLimit, searchScope, settings } = params; + const { query, historyContext, requestedLimit, searchScope, settings } = + params if (!hasUsableLlmSettings(settings)) { - return buildFallbackPlan(query, requestedLimit, "LLM_PLANNER_UNAVAILABLE"); + return buildFallbackPlan(query, requestedLimit, "LLM_PLANNER_UNAVAILABLE") } try { @@ -566,51 +628,53 @@ async function planAgentIntent(params: { query, historyContext, requestedLimit, - searchScope, - }); + searchScope + }) const result = await callInference(settings, plannerPrompt, { responseFormat: "json_object", systemPrompt: - "You are the planning layer for Vesti Explore. Output only strict JSON that matches the requested schema.", - }); - const parsed = JSON.parse(result.content) as unknown; - return normalizeAgentPlan(parsed, requestedLimit, query); + "You are the planning layer for Vesti Explore. Output only strict JSON that matches the requested schema." + }) + const parsed = JSON.parse(result.content) as unknown + return normalizeAgentPlan(parsed, requestedLimit, query) } catch { - return buildFallbackPlan(query, requestedLimit, "LLM_PLANNER_FALLBACK"); + return buildFallbackPlan(query, requestedLimit, "LLM_PLANNER_FALLBACK") } } -function getScopedConversationIds(searchScope?: ExploreSearchScope): number[] | undefined { +function getScopedConversationIds( + searchScope?: ExploreSearchScope +): number[] | undefined { if (searchScope?.mode !== "selected") { - return undefined; + return undefined } const ids = Array.isArray(searchScope.conversationIds) ? searchScope.conversationIds.filter( (id): id is number => typeof id === "number" && Number.isFinite(id) ) - : []; + : [] - return ids.length > 0 ? ids : undefined; + return ids.length > 0 ? ids : undefined } function describeSearchScope(searchScope?: ExploreSearchScope): string { - const scopedIds = getScopedConversationIds(searchScope); + const scopedIds = getScopedConversationIds(searchScope) if (!scopedIds) { - return "all conversations"; + return "all conversations" } - return `${scopedIds.length} selected conversation${scopedIds.length === 1 ? "" : "s"}`; + return `${scopedIds.length} selected conversation${scopedIds.length === 1 ? "" : "s"}` } function normalizeEmbeddingInput(text: string): string { - const trimmed = text.trim(); - if (!trimmed) return ""; - if (trimmed.length <= MAX_EMBEDDING_CHARS) return trimmed; - return trimmed.slice(0, MAX_EMBEDDING_CHARS); + const trimmed = text.trim() + if (!trimmed) return "" + if (trimmed.length <= MAX_EMBEDDING_CHARS) return trimmed + return trimmed.slice(0, MAX_EMBEDDING_CHARS) } function toFloat32Array(value: Float32Array | number[]): Float32Array { - return value instanceof Float32Array ? value : new Float32Array(value); + return value instanceof Float32Array ? value : new Float32Array(value) } function buildConversationText( @@ -618,11 +682,18 @@ function buildConversationText( messageTexts: string[], annotations: Annotation[] ): string { - const annotationText = annotations.map((item) => `【批注】${item.content_text}`); - const chunks = [conversation.title, conversation.snippet, ...messageTexts, ...annotationText]; - const combined = chunks.filter(Boolean).join("\n"); - if (combined.length <= MAX_TEXT_LENGTH) return combined; - return combined.slice(0, MAX_TEXT_LENGTH); + const annotationText = annotations.map( + (item) => `【批注】${item.content_text}` + ) + const chunks = [ + conversation.title, + conversation.snippet, + ...messageTexts, + ...annotationText + ] + const combined = chunks.filter(Boolean).join("\n") + if (combined.length <= MAX_TEXT_LENGTH) return combined + return combined.slice(0, MAX_TEXT_LENGTH) } function buildConversationContext( @@ -630,40 +701,45 @@ function buildConversationContext( messages: Array<{ role: "user" | "ai"; content_text: string }>, annotations: Annotation[] ): string { - const lines = messages - .slice(0, MAX_MESSAGE_COUNT) - .map((msg) => { - const role = msg.role === "user" ? "User" : "AI"; - return `[${role}] ${msg.content_text}`; - }); + const lines = messages.slice(0, MAX_MESSAGE_COUNT).map((msg) => { + const role = msg.role === "user" ? "User" : "AI" + return `[${role}] ${msg.content_text}` + }) - const annotationLines = annotations.map((item) => `[Note] ${item.content_text}`); + const annotationLines = annotations.map( + (item) => `[Note] ${item.content_text}` + ) return [ `[Title] ${conversation.title}`, `[Platform] ${conversation.platform}`, "[Content]", ...lines, - ...(annotationLines.length > 0 ? ["【批注】", ...annotationLines] : []), - ].join("\n"); + ...(annotationLines.length > 0 ? ["【批注】", ...annotationLines] : []) + ].join("\n") } function truncateInline(text: string, max = 200): string { - const normalized = text.replace(/\s+/g, " ").trim(); - if (normalized.length <= max) return normalized; - return `${normalized.slice(0, max)}...`; + const normalized = text.replace(/\s+/g, " ").trim() + if (normalized.length <= max) return normalized + return `${normalized.slice(0, max)}...` } function clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, value)); + return Math.max(min, Math.min(max, value)) } -function buildHistoryContext(messages: Array<{ role: string; content: string }>): string { - if (messages.length === 0) return ""; +function buildHistoryContext( + messages: Array<{ role: string; content: string }> +): string { + if (messages.length === 0) return "" return `\n\nPrevious conversation context:\n${messages - .map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content.slice(0, 200)}`) - .join("\n")}\n\nConsider the above context when answering the new question.`; + .map( + (m) => + `${m.role === "user" ? "User" : "Assistant"}: ${m.content.slice(0, 200)}` + ) + .join("\n")}\n\nConsider the above context when answering the new question.` } function buildContextualRagPrompt( @@ -672,10 +748,10 @@ function buildContextualRagPrompt( summaryHints?: string ): string { const basePrompt = - "You are Vesti's knowledge base assistant. Answer based primarily on the retrieved conversations below."; + "You are Vesti's knowledge base assistant. Answer based primarily on the retrieved conversations below." const summarySection = summaryHints?.trim() ? `\nSummary Hints:\n${summaryHints.trim()}\n` - : ""; + : "" return `${basePrompt}${historyContext}${summarySection} @@ -688,27 +764,27 @@ Instructions: 3. If information is insufficient, say so clearly. 4. Cite specific conversations when possible. 5. Prefer a complete answer over an ultra-short answer. -6. Use short sections or bullets when they improve clarity.`; +6. Use short sections or bullets when they improve clarity.` } function normalizeFinishReason(value: string | null | undefined): string { - return (value || "").trim().toLowerCase(); + return (value || "").trim().toLowerCase() } function isLengthFinishReason(result: InferenceCallResult): boolean { - const finishReason = normalizeFinishReason(result.finishReason); - return finishReason.includes("length") || finishReason.includes("max"); + const finishReason = normalizeFinishReason(result.finishReason) + return finishReason.includes("length") || finishReason.includes("max") } function isNearTokenLimit( result: InferenceCallResult, settings: LlmConfig ): boolean { - const completionTokens = result.usage?.completionTokens; + const completionTokens = result.usage?.completionTokens const effectiveMaxTokens = result.proxyTokenMetrics?.effectiveMaxTokens ?? result.proxyTokenMetrics?.requestedMaxTokens ?? - settings.maxTokens; + settings.maxTokens if ( typeof completionTokens !== "number" || @@ -716,17 +792,20 @@ function isNearTokenLimit( typeof effectiveMaxTokens !== "number" || !Number.isFinite(effectiveMaxTokens) ) { - return false; + return false } - return completionTokens >= Math.max(64, effectiveMaxTokens - 32); + return completionTokens >= Math.max(64, effectiveMaxTokens - 32) } -function buildContinuationPrompt(originalPrompt: string, partialAnswer: string): string { +function buildContinuationPrompt( + originalPrompt: string, + partialAnswer: string +): string { const answerTail = partialAnswer.length <= EXPLORE_CONTINUATION_TAIL_CHARS ? partialAnswer - : partialAnswer.slice(-EXPLORE_CONTINUATION_TAIL_CHARS); + : partialAnswer.slice(-EXPLORE_CONTINUATION_TAIL_CHARS) return [ "The previous answer was cut off by the output limit.", @@ -741,47 +820,47 @@ function buildContinuationPrompt(originalPrompt: string, partialAnswer: string): originalPrompt, "", "Already returned (tail):", - answerTail, - ].join("\n"); + answerTail + ].join("\n") } function findOverlapSize(previous: string, next: string): number { - const maxOverlap = Math.min(previous.length, next.length, 220); + const maxOverlap = Math.min(previous.length, next.length, 220) for (let size = maxOverlap; size >= 24; size -= 1) { if (previous.slice(-size) === next.slice(0, size)) { - return size; + return size } } - return 0; + return 0 } function mergeContinuationText(previous: string, next: string): string { - const trimmedNext = next.trimStart(); + const trimmedNext = next.trimStart() if (!trimmedNext) { - return previous; + return previous } - const overlapSize = findOverlapSize(previous, trimmedNext); + const overlapSize = findOverlapSize(previous, trimmedNext) if (overlapSize > 0) { - return `${previous}${trimmedNext.slice(overlapSize)}`; + return `${previous}${trimmedNext.slice(overlapSize)}` } if (!previous) { - return trimmedNext; + return trimmedNext } if (previous.endsWith("\n") || trimmedNext.startsWith("\n")) { - return `${previous}${trimmedNext}`; + return `${previous}${trimmedNext}` } if ( /[A-Za-z0-9\u3400-\u9FFF]$/.test(previous) && /^[A-Za-z0-9\u3400-\u9FFF]/.test(trimmedNext) ) { - return `${previous}\n${trimmedNext}`; + return `${previous}\n${trimmedNext}` } - return `${previous}${trimmedNext}`; + return `${previous}${trimmedNext}` } async function callExploreInference( @@ -789,38 +868,38 @@ async function callExploreInference( prompt: string, options: CallModelScopeOptions ): Promise { - let combined = ""; - let continuationCount = 0; - let currentPrompt = prompt; - let result = await callInference(settings, currentPrompt, options); - let currentContent = result.content?.trim() || ""; + let combined = "" + let continuationCount = 0 + let currentPrompt = prompt + let result = await callInference(settings, currentPrompt, options) + let currentContent = result.content?.trim() || "" if (!currentContent) { return { content: "", - continuationCount: 0, - }; + continuationCount: 0 + } } - combined = currentContent; + combined = currentContent while ( (isLengthFinishReason(result) || isNearTokenLimit(result, settings)) && continuationCount < EXPLORE_CONTINUATION_MAX_ROUNDS ) { - continuationCount += 1; - currentPrompt = buildContinuationPrompt(prompt, combined); - result = await callInference(settings, currentPrompt, options); - currentContent = result.content?.trim() || ""; + continuationCount += 1 + currentPrompt = buildContinuationPrompt(prompt, combined) + result = await callInference(settings, currentPrompt, options) + currentContent = result.content?.trim() || "" if (!currentContent) { - break; + break } - const previousLength = combined.length; - combined = mergeContinuationText(combined, currentContent); + const previousLength = combined.length + combined = mergeContinuationText(combined, currentContent) if (combined.length - previousLength < EXPLORE_CONTINUATION_MIN_EXTENSION) { - break; + break } } @@ -828,14 +907,14 @@ async function callExploreInference( logger.info("service", "Explore answer extended with continuation", { continuationCount, finishReason: result.finishReason ?? null, - modelId: getEffectiveModelId(settings), - }); + modelId: getEffectiveModelId(settings) + }) } return { content: combined, - continuationCount, - }; + continuationCount + } } function extractExcerpt(messages: Array<{ content_text: string }>): string { @@ -843,18 +922,21 @@ function extractExcerpt(messages: Array<{ content_text: string }>): string { .slice(0, 4) .map((message) => message.content_text) .filter(Boolean) - .join("\n"); + .join("\n") - return truncateInline(text, 260); + return truncateInline(text, 260) } -function buildLocalFallbackAnswer(query: string, sources: RelatedConversation[]): string { +function buildLocalFallbackAnswer( + query: string, + sources: RelatedConversation[] +): string { if (sources.length === 0) { return [ `I could not find highly similar conversations for: "${truncateInline(query, 120)}".`, "Try rephrasing the query or selecting a broader topic.", - "Tip: configure an LLM in Settings for richer synthesis.", - ].join("\n"); + "Tip: configure an LLM in Settings for richer synthesis." + ].join("\n") } const lines = sources @@ -862,17 +944,20 @@ function buildLocalFallbackAnswer(query: string, sources: RelatedConversation[]) .map( (source, index) => `${index + 1}. ${source.title} [${source.platform}] (${source.similarity}% match)` - ); + ) return [ "Model synthesis is unavailable, but these local conversations are most relevant:", ...lines, - "Open a source to inspect details, then ask a narrower follow-up.", - ].join("\n"); + "Open a source to inspect details, then ask a narrower follow-up." + ].join("\n") } -function createToolCall(name: ExploreToolName, inputSummary: string): ExploreToolCall { - const now = Date.now(); +function createToolCall( + name: ExploreToolName, + inputSummary: string +): ExploreToolCall { + const now = Date.now() return { id: `tool_${now}_${Math.random().toString(36).slice(2, 9)}`, name, @@ -881,24 +966,24 @@ function createToolCall(name: ExploreToolName, inputSummary: string): ExploreToo endedAt: now, durationMs: 0, description: getToolDescription(name), - inputSummary, - }; + inputSummary + } } function completeToolCall(call: ExploreToolCall, outputSummary: string): void { - const endedAt = Date.now(); - call.status = "completed"; - call.endedAt = endedAt; - call.durationMs = Math.max(0, endedAt - call.startedAt); - call.outputSummary = outputSummary; + const endedAt = Date.now() + call.status = "completed" + call.endedAt = endedAt + call.durationMs = Math.max(0, endedAt - call.startedAt) + call.outputSummary = outputSummary } function failToolCall(call: ExploreToolCall, error: unknown): void { - const endedAt = Date.now(); - call.status = "failed"; - call.endedAt = endedAt; - call.durationMs = Math.max(0, endedAt - call.startedAt); - call.error = (error as Error)?.message ?? "UNKNOWN_ERROR"; + const endedAt = Date.now() + call.status = "failed" + call.endedAt = endedAt + call.durationMs = Math.max(0, endedAt - call.startedAt) + call.error = (error as Error)?.message ?? "UNKNOWN_ERROR" } async function runToolStep( @@ -908,16 +993,16 @@ async function runToolStep( executor: () => Promise, outputSummaryBuilder: (value: T) => string ): Promise { - const call = createToolCall(name, inputSummary); + const call = createToolCall(name, inputSummary) try { - const value = await executor(); - completeToolCall(call, outputSummaryBuilder(value)); - toolCalls.push(call); - return value; + const value = await executor() + completeToolCall(call, outputSummaryBuilder(value)) + toolCalls.push(call) + return value } catch (error) { - failToolCall(call, error); - toolCalls.push(call); - throw error; + failToolCall(call, error) + toolCalls.push(call) + throw error } } @@ -925,25 +1010,26 @@ function buildSummaryHintsText( sources: RelatedConversation[], snippets: Map ): string { - const lines: string[] = []; + const lines: string[] = [] for (const source of sources) { - const snippet = snippets.get(source.id); - if (!snippet) continue; - lines.push(`- ${source.title}: ${truncateInline(snippet, 240)}`); + const snippet = snippets.get(source.id) + if (!snippet) continue + lines.push(`- ${source.title}: ${truncateInline(snippet, 240)}`) } - return lines.join("\n"); + return lines.join("\n") } function buildContextDraft(params: { - query: string; - sources: RelatedConversation[]; - candidates: ExploreContextCandidate[]; - searchScope?: ExploreSearchScope; - plan?: ExploreAgentPlan; - weeklySummaryText?: string; + query: string + sources: RelatedConversation[] + candidates: ExploreContextCandidate[] + searchScope?: ExploreSearchScope + plan?: ExploreAgentPlan + weeklySummaryText?: string }): string { - const { query, sources, candidates, searchScope, plan, weeklySummaryText } = params; - const selectedIds = candidates.map((candidate) => candidate.conversationId); + const { query, sources, candidates, searchScope, plan, weeklySummaryText } = + params + const selectedIds = candidates.map((candidate) => candidate.conversationId) const lines: string[] = [ "# Explore Context Draft", "", @@ -952,19 +1038,19 @@ function buildContextDraft(params: { `Intent: ${plan?.intent ?? "unknown"}`, `Route: ${plan?.preferredPath ?? "rag"}`, `Generated At: ${new Date().toISOString()}`, - "", - ]; + "" + ] if (plan?.resolvedTimeScope) { lines.push( "## Time Scope", `${plan.resolvedTimeScope.label} (${plan.resolvedTimeScope.startDate} to ${plan.resolvedTimeScope.endDate})`, "" - ); + ) } if (weeklySummaryText?.trim()) { - lines.push("## Weekly Summary", weeklySummaryText.trim(), ""); + lines.push("## Weekly Summary", weeklySummaryText.trim(), "") } lines.push( @@ -975,22 +1061,26 @@ function buildContextDraft(params: { selectedIds.length ? selectedIds.join(", ") : "(none)", "", "## Source Notes" - ); + ) if (!sources.length) { - lines.push("- No relevant conversations were retrieved."); + lines.push("- No relevant conversations were retrieved.") } else { for (const source of sources) { - const candidate = candidates.find((item) => item.conversationId === source.id); + const candidate = candidates.find( + (item) => item.conversationId === source.id + ) const matchLabel = - candidate?.matchType === "time_scope" ? "in range" : `${source.similarity}% match`; + candidate?.matchType === "time_scope" + ? "in range" + : `${source.similarity}% match` lines.push( `- ${source.title} [${source.platform}] (${matchLabel})`, ` Match Type: ${candidate?.matchType ?? "semantic"}`, ` Selection Reason: ${candidate?.selectionReason || "(not available)"}`, ` Summary: ${candidate?.summarySnippet || "(not available)"}`, ` Excerpt: ${candidate?.excerpt || "(not available)"}` - ); + ) } } @@ -998,41 +1088,47 @@ function buildContextDraft(params: { "", "## Instruction", "Use this draft as a transparent context package for a new conversation. Edit freely before sending." - ); + ) - return lines.join("\n"); + return lines.join("\n") } function filterConversationsBySearchScope( conversations: Conversation[], searchScope?: ExploreSearchScope ): Conversation[] { - const scopedIds = getScopedConversationIds(searchScope); + const scopedIds = getScopedConversationIds(searchScope) if (!scopedIds) { - return conversations; + return conversations } - const scopedIdSet = new Set(scopedIds); - return conversations.filter((conversation) => scopedIdSet.has(conversation.id)); + const scopedIdSet = new Set(scopedIds) + return conversations.filter((conversation) => + scopedIdSet.has(conversation.id) + ) } -function buildWeeklySources(conversations: Conversation[]): RelatedConversation[] { - return conversations.slice(0, MAX_WEEKLY_SOURCE_CHIPS).map((conversation) => ({ - id: conversation.id, - title: conversation.title, - platform: conversation.platform, - similarity: 100, - })); +function buildWeeklySources( + conversations: Conversation[] +): RelatedConversation[] { + return conversations + .slice(0, MAX_WEEKLY_SOURCE_CHIPS) + .map((conversation) => ({ + id: conversation.id, + title: conversation.title, + platform: conversation.platform, + similarity: 100 + })) } async function buildWeeklyContextCandidates( conversations: Conversation[], timeScope: ExploreResolvedTimeScope ): Promise { - const candidates: ExploreContextCandidate[] = []; + const candidates: ExploreContextCandidate[] = [] for (const conversation of conversations.slice(0, MAX_WEEKLY_CANDIDATES)) { - const summary = await getSummary(conversation.id); + const summary = await getSummary(conversation.id) candidates.push({ conversationId: conversation.id, title: conversation.title, @@ -1043,21 +1139,25 @@ async function buildWeeklyContextCandidates( summarySnippet: summary?.content?.trim() ? truncateInline(summary.content, 260) : undefined, - excerpt: conversation.snippet ? truncateInline(conversation.snippet, 260) : undefined, - }); + excerpt: conversation.snippet + ? truncateInline(conversation.snippet, 260) + : undefined + }) } - return candidates; + return candidates } -async function buildWeeklyEvidenceText(conversations: Conversation[]): Promise { +async function buildWeeklyEvidenceText( + conversations: Conversation[] +): Promise { if (!conversations.length) { - return "(no conversations in the selected time range)"; + return "(no conversations in the selected time range)" } - const lines: string[] = []; + const lines: string[] = [] for (const conversation of conversations.slice(0, 8)) { - const summary = await getSummary(conversation.id); + const summary = await getSummary(conversation.id) lines.push( `- ${conversation.title} [${conversation.platform}]`, ` Started: ${formatLocalIsoDate( @@ -1072,54 +1172,56 @@ async function buildWeeklyEvidenceText(conversations: Conversation[]): Promise { - const evidence = await buildWeeklyEvidenceText(params.conversations); + const evidence = await buildWeeklyEvidenceText(params.conversations) const systemPrompt = [ "You are Vesti's weekly exploration summarizer.", "Summarize what the user worked on, discussed, decided, or explored during the requested time window.", "If evidence is thin, state that clearly and point to the listed conversations for manual inspection.", - "Keep the answer concise but concrete.", - ].join(" "); + "Keep the answer concise but concrete." + ].join(" ") const userPrompt = [ `User query: ${params.query}`, `Time scope: ${params.timeScope.label} (${params.timeScope.startDate} to ${params.timeScope.endDate})`, "", "Evidence:", - evidence, - ].join("\n"); + evidence + ].join("\n") - const result = await callExploreInference(params.settings, userPrompt, { systemPrompt }); - return result.content.trim(); + const result = await callExploreInference(params.settings, userPrompt, { + systemPrompt + }) + return result.content.trim() } function buildWeeklyLocalFallbackAnswer(params: { - query: string; - timeScope: ExploreResolvedTimeScope; - sources: RelatedConversation[]; - summaryText?: string; - scoped: boolean; + query: string + timeScope: ExploreResolvedTimeScope + sources: RelatedConversation[] + summaryText?: string + scoped: boolean }): string { - const lines: string[] = []; + const lines: string[] = [] if (params.summaryText?.trim()) { - lines.push(params.summaryText.trim(), ""); + lines.push(params.summaryText.trim(), "") } else { lines.push( `I could not synthesize a full answer for "${truncateInline(params.query, 120)}" without model assistance.`, `The relevant window is ${params.timeScope.label} (${params.timeScope.startDate} to ${params.timeScope.endDate}).`, "" - ); + ) } if (params.sources.length === 0) { @@ -1128,51 +1230,59 @@ function buildWeeklyLocalFallbackAnswer(params: { ? "No conversations were found in that time window within the selected scope." : "No conversations were found in that time window.", "Try broadening the scope or asking for a different period." - ); - return lines.join("\n"); + ) + return lines.join("\n") } - lines.push("You can inspect these conversations to verify the answer:"); + lines.push("You can inspect these conversations to verify the answer:") params.sources.forEach((source, index) => { - lines.push(`${index + 1}. ${source.title} [${source.platform}]`); - }); - lines.push("Open the source chips or switch to Library to inspect them directly."); + lines.push(`${index + 1}. ${source.title} [${source.platform}]`) + }) + lines.push( + "Open the source chips or switch to Library to inspect them directly." + ) - return lines.join("\n"); + return lines.join("\n") } async function resolveWeeklySummary(params: { - query: string; - timeScope: ExploreResolvedTimeScope; - searchScope?: ExploreSearchScope; - settings: LlmConfig | null; + query: string + timeScope: ExploreResolvedTimeScope + searchScope?: ExploreSearchScope + settings: LlmConfig | null }): Promise { const allConversations = await listConversationsByRange( params.timeScope.rangeStart, params.timeScope.rangeEnd - ); - const conversations = filterConversationsBySearchScope(allConversations, params.searchScope); - const sources = buildWeeklySources(conversations); - const scoped = Boolean(getScopedConversationIds(params.searchScope)); + ) + const conversations = filterConversationsBySearchScope( + allConversations, + params.searchScope + ) + const sources = buildWeeklySources(conversations) + const scoped = Boolean(getScopedConversationIds(params.searchScope)) if (!conversations.length) { return { summaryText: "", sourceOrigin: "local_only", conversations, - sources, - }; + sources + } } if (!scoped) { - const cached = await getWeeklyReport(params.timeScope.rangeStart, params.timeScope.rangeEnd); + const cached = await getWeeklyReport( + params.timeScope.rangeStart, + params.timeScope.rangeEnd + ) if (cached?.content?.trim()) { return { summaryText: cached.content.trim(), sourceOrigin: "cached_report", conversations, - sources, - }; + sources + } } } @@ -1181,8 +1291,8 @@ async function resolveWeeklySummary(params: { summaryText: "", sourceOrigin: "local_only", conversations, - sources, - }; + sources + } } if (!scoped) { @@ -1191,14 +1301,14 @@ async function resolveWeeklySummary(params: { params.settings, params.timeScope.rangeStart, params.timeScope.rangeEnd - ); + ) if (generated.content?.trim()) { return { summaryText: generated.content.trim(), sourceOrigin: "generated_report", conversations, - sources, - }; + sources + } } } catch { // Fall through to custom weekly synthesis. @@ -1209,15 +1319,15 @@ async function resolveWeeklySummary(params: { settings: params.settings, query: params.query, timeScope: params.timeScope, - conversations, - }); + conversations + }) return { summaryText, sourceOrigin: summaryText ? "custom_summary" : "local_only", conversations, - sources, - }; + sources + } } async function retrieveRagContext( @@ -1225,102 +1335,123 @@ async function retrieveRagContext( limit: number, searchScope?: ExploreSearchScope ): Promise { - const preparedQuery = normalizeEmbeddingInput(query); + const preparedQuery = normalizeEmbeddingInput(query) if (!preparedQuery) { - throw new Error("QUERY_EMPTY"); + throw new Error("QUERY_EMPTY") + } + + const queryVector = toFloat32Array(await embedText(preparedQuery)) + const scopedConversationIds = getScopedConversationIds(searchScope) + const sqliteResult = await retrieveRagContextFromKnowledgeStore({ + queryEmbedding: queryVector, + limit, + conversationIds: scopedConversationIds + }) + if (sqliteResult !== null) { + return sqliteResult } - const queryVector = toFloat32Array(await embedText(preparedQuery)); - const vectors = await db.vectors.toArray(); - const scored: Array<{ id: number; similarity: number }> = []; - const scopedConversationIds = getScopedConversationIds(searchScope); + const vectors = await db.vectors.toArray() + const scored: Array<{ id: number; similarity: number }> = [] const scopedConversationIdSet = scopedConversationIds ? new Set(scopedConversationIds) - : undefined; + : undefined for (const vector of vectors) { if ( scopedConversationIdSet && !scopedConversationIdSet.has(vector.conversation_id) ) { - continue; + continue } - const embedding = toFloat32Array(vector.embedding as Float32Array | number[]); - if (embedding.length !== queryVector.length || embedding.length === 0) continue; - const similarity = cosineSimilarity(queryVector, embedding); - if (similarity < 0.15) continue; - scored.push({ id: vector.conversation_id, similarity }); + const embedding = toFloat32Array( + vector.embedding as Float32Array | number[] + ) + if (embedding.length !== queryVector.length || embedding.length === 0) + continue + const similarity = cosineSimilarity(queryVector, embedding) + if (similarity < 0.15) continue + scored.push({ id: vector.conversation_id, similarity }) } - const safeLimit = Math.max(1, limit); - const top = scored.sort((a, b) => b.similarity - a.similarity).slice(0, safeLimit); + const safeLimit = Math.max(1, limit) + const top = scored + .sort((a, b) => b.similarity - a.similarity) + .slice(0, safeLimit) if (scopedConversationIds?.length) { - const topIds = new Set(top.map((item) => item.id)); + const topIds = new Set(top.map((item) => item.id)) for (const conversationId of scopedConversationIds) { - if (top.length >= safeLimit) break; - if (topIds.has(conversationId)) continue; - top.push({ id: conversationId, similarity: 0 }); - topIds.add(conversationId); + if (top.length >= safeLimit) break + if (topIds.has(conversationId)) continue + top.push({ id: conversationId, similarity: 0 }) + topIds.add(conversationId) } } const conversations = top.length ? await db.conversations.bulkGet(top.map((item) => item.id)) - : []; - const byId = new Map(); + : [] + const byId = new Map() for (const conversation of conversations) { if (conversation?.id !== undefined) { - byId.set(conversation.id, conversation as Conversation); + byId.set(conversation.id, conversation as Conversation) } } - const sources: RelatedConversation[] = []; - const contextBlocks: string[] = []; - const items: RagRetrievalItem[] = []; + const sources: RelatedConversation[] = [] + const contextBlocks: string[] = [] + const items: RagRetrievalItem[] = [] for (const topItem of top) { - const conversation = byId.get(topItem.id); - if (!conversation) continue; + const conversation = byId.get(topItem.id) + if (!conversation) continue const messages = await db.messages .where("conversation_id") .equals(conversation.id) - .sortBy("created_at"); + .sortBy("created_at") const source: RelatedConversation = { id: conversation.id, title: conversation.title, platform: conversation.platform, - similarity: Math.round(topItem.similarity * 100), - }; + similarity: Math.round(topItem.similarity * 100) + } const annotationRecords = await db.annotations .where("conversation_id") .equals(conversation.id) - .toArray(); + .toArray() const annotations = annotationRecords - .filter((record): record is Annotation => typeof record?.content_text === "string") + .filter( + (record): record is Annotation => + typeof record?.content_text === "string" + ) .map((record) => ({ id: record.id as number, conversation_id: record.conversation_id, message_id: record.message_id, content_text: record.content_text, created_at: record.created_at, - days_after: record.days_after, - })); + days_after: record.days_after + })) - const contextBlock = buildConversationContext(conversation, messages, annotations); - const excerpt = extractExcerpt(messages); + const contextBlock = buildConversationContext( + conversation, + messages, + annotations + ) + const excerpt = extractExcerpt(messages) - sources.push(source); - contextBlocks.push(contextBlock); - items.push({ source, contextBlock, excerpt }); + sources.push(source) + contextBlocks.push(contextBlock) + items.push({ source, contextBlock, excerpt }) } return { sources, context: contextBlocks.join("\n\n---\n\n"), - items, - }; + items + } } async function resolveSummarySnippets( @@ -1328,34 +1459,34 @@ async function resolveSummarySnippets( sources: RelatedConversation[], targetCount: number ): Promise { - const snippets = new Map(); - let cacheHits = 0; - let generated = 0; - let failed = 0; + const snippets = new Map() + let cacheHits = 0 + let generated = 0 + let failed = 0 for (const source of sources.slice(0, targetCount)) { try { - const existing = await getSummary(source.id); + const existing = await getSummary(source.id) if (existing?.content?.trim()) { - snippets.set(source.id, truncateInline(existing.content, 320)); - cacheHits += 1; - continue; + snippets.set(source.id, truncateInline(existing.content, 320)) + cacheHits += 1 + continue } if (!hasUsableLlmSettings(settings)) { - failed += 1; - continue; + failed += 1 + continue } - const synthesized = await generateConversationSummary(settings, source.id); + const synthesized = await generateConversationSummary(settings, source.id) if (synthesized?.content?.trim()) { - snippets.set(source.id, truncateInline(synthesized.content, 320)); - generated += 1; + snippets.set(source.id, truncateInline(synthesized.content, 320)) + generated += 1 } else { - failed += 1; + failed += 1 } } catch { - failed += 1; + failed += 1 } } @@ -1363,8 +1494,8 @@ async function resolveSummarySnippets( snippets, cacheHits, generated, - failed, - }; + failed + } } function buildContextCandidates( @@ -1377,10 +1508,11 @@ function buildContextCandidates( platform: item.source.platform, similarity: item.source.similarity, matchType: "semantic", - selectionReason: "Retrieved by semantic similarity against the user's query.", + selectionReason: + "Retrieved by semantic similarity against the user's query.", summarySnippet: summarySnippets.get(item.source.id), - excerpt: item.excerpt, - })); + excerpt: item.excerpt + })) } async function runClassicKnowledgeBase( @@ -1390,84 +1522,94 @@ async function runClassicKnowledgeBase( searchScope?: ExploreSearchScope, existingRetrieval?: RagRetrievalResult ): Promise { - let retrieval = existingRetrieval; + let retrieval = existingRetrieval if (!retrieval) { try { - retrieval = await retrieveRagContext(query, limit, searchScope); + retrieval = await retrieveRagContext(query, limit, searchScope) } catch { retrieval = { sources: [], context: "", - items: [], - }; + items: [] + } } } - const settings = await getLlmSettings(); + const settings = await getLlmSettings() if (!hasUsableLlmSettings(settings)) { return { answer: buildLocalFallbackAnswer(query, retrieval.sources), - sources: retrieval.sources, - }; + sources: retrieval.sources + } } try { - const systemPrompt = buildContextualRagPrompt(retrieval.context, historyContext); - const result = await callExploreInference(settings, query, { systemPrompt }); - const answer = result.content.trim(); + const systemPrompt = buildContextualRagPrompt( + retrieval.context, + historyContext + ) + const result = await callExploreInference(settings, query, { systemPrompt }) + const answer = result.content.trim() return { answer: answer || buildLocalFallbackAnswer(query, retrieval.sources), - sources: retrieval.sources, - }; + sources: retrieval.sources + } } catch { return { answer: buildLocalFallbackAnswer(query, retrieval.sources), - sources: retrieval.sources, - }; + sources: retrieval.sources + } } } async function synthesizeAgentAnswer(params: { - query: string; - historyContext: string; - retrieval: RagRetrievalResult; - summaryHints: string; - settings: Awaited>; + query: string + historyContext: string + retrieval: RagRetrievalResult + summaryHints: string + settings: Awaited> }): Promise { - const { query, historyContext, retrieval, summaryHints, settings } = params; + const { query, historyContext, retrieval, summaryHints, settings } = params if (!hasUsableLlmSettings(settings)) { - return buildLocalFallbackAnswer(query, retrieval.sources); + return buildLocalFallbackAnswer(query, retrieval.sources) } const systemPrompt = buildContextualRagPrompt( retrieval.context, historyContext, summaryHints - ); + ) try { - const result = await callExploreInference(settings, query, { systemPrompt }); - const answer = result.content.trim(); + const result = await callExploreInference(settings, query, { systemPrompt }) + const answer = result.content.trim() if (!answer) { - return buildLocalFallbackAnswer(query, retrieval.sources); + return buildLocalFallbackAnswer(query, retrieval.sources) } - return answer; + return answer } catch { - return buildLocalFallbackAnswer(query, retrieval.sources); + return buildLocalFallbackAnswer(query, retrieval.sources) } } async function synthesizeWeeklyAnswer(params: { - query: string; - historyContext: string; - timeScope: ExploreResolvedTimeScope; - weeklySummaryText: string; - sources: RelatedConversation[]; - settings: Awaited>; - scoped: boolean; + query: string + historyContext: string + timeScope: ExploreResolvedTimeScope + weeklySummaryText: string + sources: RelatedConversation[] + settings: Awaited> + scoped: boolean }): Promise { - const { query, historyContext, timeScope, weeklySummaryText, sources, settings, scoped } = - params; + const { + query, + historyContext, + timeScope, + weeklySummaryText, + sources, + settings, + scoped + } = params if (!hasUsableLlmSettings(settings)) { return buildWeeklyLocalFallbackAnswer({ @@ -1475,14 +1617,16 @@ async function synthesizeWeeklyAnswer(params: { timeScope, sources, summaryText: weeklySummaryText, - scoped, - }); + scoped + }) } const sourceLines = sources.length > 0 - ? sources.map((source) => `- ${source.title} [${source.platform}]`).join("\n") - : "- No conversations were found in this time scope."; + ? sources + .map((source) => `- ${source.title} [${source.platform}]`) + .join("\n") + : "- No conversations were found in this time scope." const systemPrompt = [ "You are Vesti's transparent Explore answer synthesizer.", @@ -1499,30 +1643,30 @@ async function synthesizeWeeklyAnswer(params: { "2. If evidence is partial, say that clearly.", "3. Tell the user which source conversations to open when deeper verification is needed.", "4. Keep the answer grounded in the selected time window.", - "5. Prefer a complete answer over an ultra-short one.", - ].join("\n"); + "5. Prefer a complete answer over an ultra-short one." + ].join("\n") try { - const result = await callExploreInference(settings, query, { systemPrompt }); - const answer = result.content.trim(); + const result = await callExploreInference(settings, query, { systemPrompt }) + const answer = result.content.trim() if (!answer) { return buildWeeklyLocalFallbackAnswer({ query, timeScope, sources, summaryText: weeklySummaryText, - scoped, - }); + scoped + }) } - return answer; + return answer } catch { return buildWeeklyLocalFallbackAnswer({ query, timeScope, sources, summaryText: weeklySummaryText, - scoped, - }); + scoped + }) } } @@ -1532,16 +1676,16 @@ async function runAgentKnowledgeBase( limit: number, options?: ExploreAskOptions ): Promise { - const toolCalls: ExploreToolCall[] = []; - const startedAt = Date.now(); - let retrieval: RagRetrievalResult | undefined; - let plan: ExploreAgentPlan | undefined; - let weeklyResult: WeeklySummaryToolResult | undefined; - let contextDraft = ""; - let contextCandidates: ExploreContextCandidate[] = []; - let selectedContextConversationIds: number[] = []; - const searchScope = options?.searchScope; - const settings = await getLlmSettings(); + const toolCalls: ExploreToolCall[] = [] + const startedAt = Date.now() + let retrieval: RagRetrievalResult | undefined + let plan: ExploreAgentPlan | undefined + let weeklyResult: WeeklySummaryToolResult | undefined + let contextDraft = "" + let contextCandidates: ExploreContextCandidate[] = [] + let selectedContextConversationIds: number[] = [] + const searchScope = options?.searchScope + const settings = await getLlmSettings() try { plan = await runToolStep( @@ -1554,11 +1698,11 @@ async function runAgentKnowledgeBase( historyContext, requestedLimit: limit, searchScope, - settings, + settings }), (value) => `intent=${value.intent}, route=${value.preferredPath}, sourceLimit=${value.sourceLimit}, reason=${truncateInline(value.reason, 100)}` - ); + ) if (plan.needsClarification || plan.preferredPath === "clarify") { contextDraft = buildContextDraft({ @@ -1566,8 +1710,8 @@ async function runAgentKnowledgeBase( sources: [], candidates: [], searchScope, - plan, - }); + plan + }) const agentMeta: ExploreAgentMeta = { mode: "agent", @@ -1578,16 +1722,16 @@ async function runAgentKnowledgeBase( contextDraft, contextCandidates, selectedContextConversationIds, - totalDurationMs: Date.now() - startedAt, - }; + totalDurationMs: Date.now() - startedAt + } return { answer: plan.clarifyingQuestion || "I need one more constraint before I can answer this reliably.", sources: [], - agent: agentMeta, - }; + agent: agentMeta + } } if (plan.preferredPath === "weekly_summary") { @@ -1596,20 +1740,22 @@ async function runAgentKnowledgeBase( "time_scope_resolver", `requested=${plan.requestedTimeScope?.preset ?? "none"}`, async () => { - const resolved = plan?.resolvedTimeScope ?? resolveRequestedTimeScope(plan?.requestedTimeScope); + const resolved = + plan?.resolvedTimeScope ?? + resolveRequestedTimeScope(plan?.requestedTimeScope) if (!resolved) { - throw new Error("TIME_SCOPE_UNRESOLVED"); + throw new Error("TIME_SCOPE_UNRESOLVED") } - return resolved; + return resolved }, (value) => `${value.label} (${value.startDate} to ${value.endDate})` - ); + ) plan = { ...plan, resolvedTimeScope, - toolPlan: buildToolPlan("weekly_summary"), - }; + toolPlan: buildToolPlan("weekly_summary") + } weeklyResult = await runToolStep( toolCalls, @@ -1620,11 +1766,11 @@ async function runAgentKnowledgeBase( query, timeScope: resolvedTimeScope, searchScope, - settings, + settings }), (value) => `sourceOrigin=${value.sourceOrigin}, conversations=${value.conversations.length}, sources=${value.sources.length}` - ); + ) const compiledContext = await runToolStep( toolCalls, @@ -1634,25 +1780,26 @@ async function runAgentKnowledgeBase( const candidates = await buildWeeklyContextCandidates( weeklyResult!.conversations, resolvedTimeScope - ); + ) const draft = buildContextDraft({ query, sources: weeklyResult!.sources, candidates, searchScope, plan, - weeklySummaryText: weeklyResult!.summaryText, - }); - return { candidates, draft }; + weeklySummaryText: weeklyResult!.summaryText + }) + return { candidates, draft } }, - (value) => `draftChars=${value.draft.length}, candidates=${value.candidates.length}` - ); + (value) => + `draftChars=${value.draft.length}, candidates=${value.candidates.length}` + ) - contextCandidates = compiledContext.candidates; - contextDraft = compiledContext.draft; + contextCandidates = compiledContext.candidates + contextDraft = compiledContext.draft selectedContextConversationIds = contextCandidates.map( (candidate) => candidate.conversationId - ); + ) const answer = await runToolStep( toolCalls, @@ -1666,10 +1813,10 @@ async function runAgentKnowledgeBase( weeklySummaryText: weeklyResult!.summaryText, sources: weeklyResult!.sources, settings, - scoped: Boolean(getScopedConversationIds(searchScope)), + scoped: Boolean(getScopedConversationIds(searchScope)) }), (value) => `answerChars=${value.length}` - ); + ) const agentMeta: ExploreAgentMeta = { mode: "agent", @@ -1680,14 +1827,14 @@ async function runAgentKnowledgeBase( contextDraft, contextCandidates, selectedContextConversationIds, - totalDurationMs: Date.now() - startedAt, - }; + totalDurationMs: Date.now() - startedAt + } return { answer, sources: weeklyResult.sources, - agent: agentMeta, - }; + agent: agentMeta + } } retrieval = await runToolStep( @@ -1695,43 +1842,56 @@ async function runAgentKnowledgeBase( "search_rag", `sourceLimit=${plan.sourceLimit}, scope=${describeSearchScope(searchScope)}`, async () => retrieveRagContext(query, plan.sourceLimit, searchScope), - (value) => `retrieved=${value.sources.length}, scope=${describeSearchScope(searchScope)}` - ); + (value) => + `retrieved=${value.sources.length}, scope=${describeSearchScope(searchScope)}` + ) const summaryResult = await runToolStep( toolCalls, "summary_tool", `target=${plan.summaryTargetCount}`, - async () => resolveSummarySnippets(settings, retrieval!.sources, plan.summaryTargetCount), + async () => + resolveSummarySnippets( + settings, + retrieval!.sources, + plan.summaryTargetCount + ), (value) => `cacheHits=${value.cacheHits}, generated=${value.generated}, failed=${value.failed}` - ); + ) const compiledContext = await runToolStep( toolCalls, "context_compiler", `sources=${retrieval.sources.length}`, async () => { - const candidates = buildContextCandidates(retrieval!, summaryResult.snippets); + const candidates = buildContextCandidates( + retrieval!, + summaryResult.snippets + ) const draft = buildContextDraft({ query, sources: retrieval!.sources, candidates, searchScope, - plan, - }); - return { candidates, draft }; + plan + }) + return { candidates, draft } }, - (value) => `draftChars=${value.draft.length}, candidates=${value.candidates.length}` - ); + (value) => + `draftChars=${value.draft.length}, candidates=${value.candidates.length}` + ) - contextCandidates = compiledContext.candidates; - contextDraft = compiledContext.draft; + contextCandidates = compiledContext.candidates + contextDraft = compiledContext.draft selectedContextConversationIds = contextCandidates.map( (candidate) => candidate.conversationId - ); + ) - const summaryHints = buildSummaryHintsText(retrieval.sources, summaryResult.snippets); + const summaryHints = buildSummaryHintsText( + retrieval.sources, + summaryResult.snippets + ) const answer = await runToolStep( toolCalls, "answer_synthesizer", @@ -1742,10 +1902,10 @@ async function runAgentKnowledgeBase( historyContext, retrieval: retrieval!, summaryHints, - settings, + settings }), (value) => `answerChars=${value.length}` - ); + ) const agentMeta: ExploreAgentMeta = { mode: "agent", @@ -1756,14 +1916,14 @@ async function runAgentKnowledgeBase( contextDraft, contextCandidates, selectedContextConversationIds, - totalDurationMs: Date.now() - startedAt, - }; + totalDurationMs: Date.now() - startedAt + } return { answer, sources: retrieval.sources, - agent: agentMeta, - }; + agent: agentMeta + } } catch { if (plan?.preferredPath === "weekly_summary" && plan.resolvedTimeScope) { if (!contextDraft) { @@ -1772,18 +1932,18 @@ async function runAgentKnowledgeBase( weeklyResult.conversations, plan.resolvedTimeScope ) - : []; + : [] contextDraft = buildContextDraft({ query, sources: weeklyResult?.sources ?? [], candidates: contextCandidates, searchScope, plan, - weeklySummaryText: weeklyResult?.summaryText, - }); + weeklySummaryText: weeklyResult?.summaryText + }) selectedContextConversationIds = contextCandidates.map( (candidate) => candidate.conversationId - ); + ) } const agentMeta: ExploreAgentMeta = { @@ -1795,8 +1955,8 @@ async function runAgentKnowledgeBase( contextDraft, contextCandidates, selectedContextConversationIds, - totalDurationMs: Date.now() - startedAt, - }; + totalDurationMs: Date.now() - startedAt + } return { answer: buildWeeklyLocalFallbackAnswer({ @@ -1804,11 +1964,11 @@ async function runAgentKnowledgeBase( timeScope: plan.resolvedTimeScope, sources: weeklyResult?.sources ?? [], summaryText: weeklyResult?.summaryText, - scoped: Boolean(getScopedConversationIds(searchScope)), + scoped: Boolean(getScopedConversationIds(searchScope)) }), sources: weeklyResult?.sources ?? [], - agent: agentMeta, - }; + agent: agentMeta + } } const fallback = await runClassicKnowledgeBase( @@ -1817,20 +1977,23 @@ async function runAgentKnowledgeBase( limit, searchScope, retrieval - ); + ) if (!contextDraft && retrieval) { - contextCandidates = buildContextCandidates(retrieval, new Map()); + contextCandidates = buildContextCandidates( + retrieval, + new Map() + ) contextDraft = buildContextDraft({ query, sources: retrieval.sources, candidates: contextCandidates, searchScope, - plan, - }); + plan + }) selectedContextConversationIds = contextCandidates.map( (candidate) => candidate.conversationId - ); + ) } const agentMeta: ExploreAgentMeta = { @@ -1842,100 +2005,108 @@ async function runAgentKnowledgeBase( contextDraft, contextCandidates, selectedContextConversationIds, - totalDurationMs: Date.now() - startedAt, - }; + totalDurationMs: Date.now() - startedAt + } return { answer: fallback.answer, sources: fallback.sources, - agent: agentMeta, - }; + agent: agentMeta + } } } export async function hashText(text: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(text); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); + const encoder = new TextEncoder() + const data = encoder.encode(text) + const hashBuffer = await crypto.subtle.digest("SHA-256", data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("") } async function getConversationText( conversationId: number ): Promise<{ conversation: Conversation; text: string }> { - const conversation = await db.conversations.get(conversationId); + const conversation = await db.conversations.get(conversationId) if (!conversation || conversation.id === undefined) { - throw new Error("CONVERSATION_NOT_FOUND"); + throw new Error("CONVERSATION_NOT_FOUND") } const messages = await db.messages .where("conversation_id") .equals(conversationId) - .sortBy("created_at"); + .sortBy("created_at") const messageTexts = messages .slice(0, MAX_MESSAGE_COUNT) .map((message) => message.content_text) - .filter(Boolean); + .filter(Boolean) const annotationRecords = await db.annotations .where("conversation_id") .equals(conversationId) - .toArray(); + .toArray() const annotations = annotationRecords - .filter((record): record is Annotation => typeof record?.content_text === "string") + .filter( + (record): record is Annotation => typeof record?.content_text === "string" + ) .map((record) => ({ id: record.id as number, conversation_id: record.conversation_id, message_id: record.message_id, content_text: record.content_text, created_at: record.created_at, - days_after: record.days_after, - })); + days_after: record.days_after + })) const text = buildConversationText( conversation as Conversation, messageTexts, annotations - ); - return { conversation: conversation as Conversation, text }; + ) + return { conversation: conversation as Conversation, text } } type EdgeQueryOptions = { - threshold?: number; - conversationIds?: number[]; -}; + threshold?: number + conversationIds?: number[] +} function normalizeConversationIds(conversationIds?: number[]): number[] { if (!Array.isArray(conversationIds)) { - return []; + return [] } - const seen = new Set(); - const normalizedIds: number[] = []; + const seen = new Set() + const normalizedIds: number[] = [] conversationIds.forEach((value) => { - if (typeof value !== "number" || !Number.isFinite(value)) return; - const normalized = Math.floor(value); - if (normalized <= 0 || seen.has(normalized)) return; - seen.add(normalized); - normalizedIds.push(normalized); - }); + if (typeof value !== "number" || !Number.isFinite(value)) return + const normalized = Math.floor(value) + if (normalized <= 0 || seen.has(normalized)) return + seen.add(normalized) + normalizedIds.push(normalized) + }) - return normalizedIds; + return normalizedIds } -async function ensureVectorsForConversations(conversationIds: number[]): Promise { +async function ensureVectorsForConversations( + conversationIds: number[] +): Promise { for (const conversationId of conversationIds) { try { - const { text } = await getConversationText(conversationId); - await ensureVectorForConversation(conversationId, text); + const { text } = await getConversationText(conversationId) + await ensureVectorForConversation(conversationId, text) } catch (error) { - logger.warn("service", "Failed to ensure vector for network conversation", { - conversationId, - error: (error as Error)?.message ?? String(error), - }); + logger.warn( + "service", + "Failed to ensure vector for network conversation", + { + conversationId, + error: (error as Error)?.message ?? String(error) + } + ) } } } @@ -1944,140 +2115,171 @@ export async function ensureVectorForConversation( conversationId: number, text: string ): Promise { - const preparedText = normalizeEmbeddingInput(text); - if (!preparedText) return; + const preparedText = normalizeEmbeddingInput(text) + if (!preparedText) return - const textHash = await hashText(preparedText); + const textHash = await hashText(preparedText) const existing = await db.vectors .where("conversation_id") .equals(conversationId) .and((record) => record.text_hash === textHash) - .first(); - if (existing && existing.id !== undefined) return; + .first() + if (existing && existing.id !== undefined) return - const embedding = await embedText(preparedText); + const embedding = await embedText(preparedText) await db.transaction("rw", db.vectors, async () => { await db.vectors .where("conversation_id") .equals(conversationId) .and((record) => record.text_hash !== textHash) - .delete(); + .delete() await db.vectors.add({ conversation_id: conversationId, text_hash: textHash, - embedding, - }); - }); + embedding + }) + }) } function cosineSimilarity(a: Float32Array, b: Float32Array): number { - if (a.length === 0 || b.length === 0 || a.length !== b.length) return 0; - let dot = 0; + if (a.length === 0 || b.length === 0 || a.length !== b.length) return 0 + let dot = 0 for (let i = 0; i < a.length; i += 1) { - dot += a[i] * b[i]; + dot += a[i] * b[i] } - return dot; + return dot } export async function findRelatedConversations( conversationId: number, limit = 3 ): Promise { - const { text } = await getConversationText(conversationId); - await ensureVectorForConversation(conversationId, text); + const { text } = await getConversationText(conversationId) + await ensureVectorForConversation(conversationId, text) + await markKnowledgeConversationsDirty([conversationId]) + + const sqliteResult = await queryRelatedConversationsFromKnowledgeStore( + conversationId, + limit + ) + if (sqliteResult !== null) { + return sqliteResult + } const targetVector = await db.vectors .where("conversation_id") .equals(conversationId) - .first(); - if (!targetVector) return []; + .first() + if (!targetVector) return [] - const vectors = await db.vectors.toArray(); - const targetEmbedding = toFloat32Array(targetVector.embedding); + const vectors = await db.vectors.toArray() + const targetEmbedding = toFloat32Array(targetVector.embedding) - const scores: Array<{ id: number; similarity: number }> = []; + const scores: Array<{ id: number; similarity: number }> = [] for (const vector of vectors) { - if (vector.conversation_id === conversationId) continue; - const embedding = toFloat32Array(vector.embedding as Float32Array | number[]); - const similarity = cosineSimilarity(targetEmbedding, embedding); - scores.push({ id: vector.conversation_id, similarity }); + if (vector.conversation_id === conversationId) continue + const embedding = toFloat32Array( + vector.embedding as Float32Array | number[] + ) + const similarity = cosineSimilarity(targetEmbedding, embedding) + scores.push({ id: vector.conversation_id, similarity }) } - const top = scores.sort((a, b) => b.similarity - a.similarity).slice(0, limit); - const conversations = await db.conversations.bulkGet(top.map((item) => item.id)); - const byId = new Map(); + const top = scores.sort((a, b) => b.similarity - a.similarity).slice(0, limit) + const conversations = await db.conversations.bulkGet( + top.map((item) => item.id) + ) + const byId = new Map() conversations.forEach((item) => { if (item && item.id !== undefined) { - byId.set(item.id, item as Conversation); + byId.set(item.id, item as Conversation) } - }); + }) return top .map((item) => { - const conversation = byId.get(item.id); - if (!conversation) return null; + const conversation = byId.get(item.id) + if (!conversation) return null return { id: conversation.id, title: conversation.title, platform: conversation.platform, - similarity: Math.round(item.similarity * 100), - } as RelatedConversation; + similarity: Math.round(item.similarity * 100) + } as RelatedConversation }) - .filter(Boolean) as RelatedConversation[]; + .filter(Boolean) as RelatedConversation[] } export async function findAllEdges( options: EdgeQueryOptions = {} ): Promise> { - const threshold = options.threshold ?? 0.3; - const targetConversationIds = normalizeConversationIds(options.conversationIds); + const threshold = options.threshold ?? 0.3 + const targetConversationIds = normalizeConversationIds( + options.conversationIds + ) + if (targetConversationIds.length > 0) { + await ensureVectorsForConversations(targetConversationIds) + await markKnowledgeConversationsDirty(targetConversationIds) + } + + const sqliteResult = await queryAllEdgesFromKnowledgeStore({ + threshold, + conversationIds: + targetConversationIds.length > 0 ? targetConversationIds : undefined + }) + if (sqliteResult !== null) { + return sqliteResult + } const vectors = Array.isArray(options.conversationIds) ? targetConversationIds.length === 0 ? [] : await (async () => { - await ensureVectorsForConversations(targetConversationIds); - return db.vectors.where("conversation_id").anyOf(targetConversationIds).toArray(); + await ensureVectorsForConversations(targetConversationIds) + return db.vectors + .where("conversation_id") + .anyOf(targetConversationIds) + .toArray() })() - : await db.vectors.toArray(); + : await db.vectors.toArray() - const edges: Array<{ source: number; target: number; weight: number }> = []; - const seen = new Set(); + const edges: Array<{ source: number; target: number; weight: number }> = [] + const seen = new Set() for (let i = 0; i < vectors.length; i += 1) { for (let j = i + 1; j < vectors.length; j += 1) { - const left = vectors[i]; - const right = vectors[j]; + const left = vectors[i] + const right = vectors[j] if ( typeof left.conversation_id !== "number" || typeof right.conversation_id !== "number" ) { - continue; + continue } - const a = toFloat32Array(left.embedding as Float32Array | number[]); - const b = toFloat32Array(right.embedding as Float32Array | number[]); - if (a.length !== b.length || a.length === 0) continue; + const a = toFloat32Array(left.embedding as Float32Array | number[]) + const b = toFloat32Array(right.embedding as Float32Array | number[]) + if (a.length !== b.length || a.length === 0) continue - const similarity = cosineSimilarity(a, b); - if (similarity < threshold) continue; + const similarity = cosineSimilarity(a, b) + if (similarity < threshold) continue - const key = `${left.conversation_id}-${right.conversation_id}`; - if (seen.has(key)) continue; - seen.add(key); + const key = `${left.conversation_id}-${right.conversation_id}` + if (seen.has(key)) continue + seen.add(key) edges.push({ source: left.conversation_id, target: right.conversation_id, - weight: Math.round(similarity * 100) / 100, - }); + weight: Math.round(similarity * 100) / 100 + }) } } - return edges; + return edges } export async function askKnowledgeBase( @@ -2087,93 +2289,98 @@ export async function askKnowledgeBase( mode: ExploreMode = "agent", options?: ExploreAskOptions ): Promise { - const query = userQuery.trim(); + const query = userQuery.trim() if (!query) { - throw new Error("QUERY_EMPTY"); + throw new Error("QUERY_EMPTY") } - let sessionId = existingSessionId; + let sessionId = existingSessionId if (!sessionId) { - sessionId = await createExploreSession(query.slice(0, 100)); + sessionId = await createExploreSession(query.slice(0, 100)) } await addExploreMessage(sessionId, { role: "user", content: query, - timestamp: Date.now(), - }); + timestamp: Date.now() + }) - const recentMessages = await getExploreMessages(sessionId); - const historyContext = buildHistoryContext(recentMessages.slice(-6)); + const recentMessages = await getExploreMessages(sessionId) + const historyContext = buildHistoryContext(recentMessages.slice(-6)) const result = mode === "classic" - ? await runClassicKnowledgeBase(query, historyContext, limit, options?.searchScope) - : await runAgentKnowledgeBase(query, historyContext, limit, options); + ? await runClassicKnowledgeBase( + query, + historyContext, + limit, + options?.searchScope + ) + : await runAgentKnowledgeBase(query, historyContext, limit, options) await addExploreMessage(sessionId, { role: "assistant", content: result.answer, sources: result.sources, agentMeta: result.agent, - timestamp: Date.now(), - }); + timestamp: Date.now() + }) await updateExploreSession(sessionId, { - preview: result.answer.slice(0, 100), - }); + preview: result.answer.slice(0, 100) + }) return { ...result, - sessionId, - }; + sessionId + } } export async function hybridSearch(query: string): Promise { - return askKnowledgeBase(query, undefined, MAX_RAG_SOURCES, "agent"); + return askKnowledgeBase(query, undefined, MAX_RAG_SOURCES, "agent") } export async function getVectorStats(): Promise<{ - totalVectors: number; - totalConversations: number; - vectorizedConversations: number; - unvectorizedConversations: number; + totalVectors: number + totalConversations: number + vectorizedConversations: number + unvectorizedConversations: number }> { - const totalVectors = await db.vectors.count(); - const allConversations = await db.conversations.toArray(); - const totalConversations = allConversations.length; + const totalVectors = await db.vectors.count() + const allConversations = await db.conversations.toArray() + const totalConversations = allConversations.length - const vectorizedIds = new Set(); - const vectors = await db.vectors.toArray(); - vectors.forEach((v) => vectorizedIds.add(v.conversation_id)); + const vectorizedIds = new Set() + const vectors = await db.vectors.toArray() + vectors.forEach((v) => vectorizedIds.add(v.conversation_id)) return { totalVectors, totalConversations, vectorizedConversations: vectorizedIds.size, - unvectorizedConversations: totalConversations - vectorizedIds.size, - }; + unvectorizedConversations: totalConversations - vectorizedIds.size + } } export async function vectorizeAllConversations(): Promise { - const conversations = await db.conversations.toArray(); + const conversations = await db.conversations.toArray() - let created = 0; + let created = 0 for (const conversation of conversations) { - if (!conversation?.id) continue; + if (!conversation?.id) continue try { - const { text } = await getConversationText(conversation.id); - await ensureVectorForConversation(conversation.id, text); - created += 1; + const { text } = await getConversationText(conversation.id) + await ensureVectorForConversation(conversation.id, text) + created += 1 } catch (err) { console.error( "[Vectorize] Failed to vectorize conv", conversation.id, ":", (err as Error).message - ); + ) } } - return created; + return created } diff --git a/frontend/src/lib/services/storageService.ts b/frontend/src/lib/services/storageService.ts index c382d11..d62183f 100644 --- a/frontend/src/lib/services/storageService.ts +++ b/frontend/src/lib/services/storageService.ts @@ -1,70 +1,74 @@ import type { + ConversationFilters, + ConversationUpdateChanges +} from "../messaging/protocol" +import { sendRequest } from "../messaging/runtime" +import type { ActiveCaptureStatus, Annotation, Conversation, ConversationMatchSummary, - DataOverviewSnapshot, DashboardStats, + DataOverviewSnapshot, + ExploreAskOptions, + ExploreMessage, + ExploreMode, + ExploreSession, ExportFormat, ForceArchiveTransientResult, + GardenerResult, LlmConfig, Message, Note, Platform, - RelatedConversation, RagResponse, - ExploreSession, - ExploreMessage, - ExploreMode, - ExploreAskOptions, + RelatedConversation, + SearchConversationMatchesQuery, StorageUsageSnapshot, SummaryRecord, - SearchConversationMatchesQuery, - WeeklyReportRecord, Topic, - GardenerResult, -} from "../types"; -import type { ChatSummaryData } from "../types/insightsPresentation"; -import { sendRequest } from "../messaging/runtime"; -import type { ConversationUpdateChanges } from "../messaging/protocol"; -import type { LlmDiagnostic } from "./llmService"; -import { toChatSummaryData } from "./insightAdapter"; - -const LONG_RUNNING_TIMEOUT_MS = 120000; -const TEST_CONNECTION_TIMEOUT_MS = 45000; -const FULL_TEXT_SEARCH_TIMEOUT_MS = 15000; - -export async function getConversations(filters?: { - platform?: Platform; - search?: string; - dateRange?: { start: number; end: number }; -}): Promise { + WeeklyReportRecord +} from "../types" +import type { ChatSummaryData } from "../types/insightsPresentation" +import { toChatSummaryData } from "./insightAdapter" +import type { LlmDiagnostic } from "./llmService" + +const LONG_RUNNING_TIMEOUT_MS = 120000 +const TEST_CONNECTION_TIMEOUT_MS = 45000 +const FULL_TEXT_SEARCH_TIMEOUT_MS = 15000 + +export async function getConversations( + filters?: ConversationFilters +): Promise { return sendRequest({ type: "GET_CONVERSATIONS", target: "offscreen", - payload: filters, - }) as Promise; + payload: filters + }) as Promise } export async function getTopics(): Promise { return sendRequest({ type: "GET_TOPICS", - target: "offscreen", - }) as Promise; + target: "offscreen" + }) as Promise } -export async function createTopic(name: string, parent_id?: number | null): Promise { +export async function createTopic( + name: string, + parent_id?: number | null +): Promise { const result = (await sendRequest({ type: "CREATE_TOPIC", target: "offscreen", - payload: { name, parent_id }, - })) as { topic: Topic }; + payload: { name, parent_id } + })) as { topic: Topic } chrome.runtime.sendMessage({ type: "VESTI_DATA_UPDATED" }, () => { - void chrome.runtime.lastError; - }); + void chrome.runtime.lastError + }) - return result.topic; + return result.topic } export async function updateConversationTopic( @@ -74,16 +78,16 @@ export async function updateConversationTopic( const result = (await sendRequest({ type: "UPDATE_CONVERSATION_TOPIC", target: "offscreen", - payload: { id, topic_id }, - })) as { updated: boolean; conversation: Conversation }; + payload: { id, topic_id } + })) as { updated: boolean; conversation: Conversation } if (result.updated) { chrome.runtime.sendMessage({ type: "VESTI_DATA_UPDATED" }, () => { - void chrome.runtime.lastError; - }); + void chrome.runtime.lastError + }) } - return result.conversation; + return result.conversation } export async function updateConversation( @@ -93,121 +97,142 @@ export async function updateConversation( const result = (await sendRequest({ type: "UPDATE_CONVERSATION", target: "offscreen", - payload: { id, changes }, - })) as { updated: boolean; conversation: Conversation }; + payload: { id, changes } + })) as { updated: boolean; conversation: Conversation } if (result.updated) { chrome.runtime.sendMessage({ type: "VESTI_DATA_UPDATED" }, () => { - void chrome.runtime.lastError; - }); + void chrome.runtime.lastError + }) } - return result; + return result } export async function runGardener( conversationId: number -): Promise<{ updated: boolean; conversation: Conversation; result: GardenerResult }> { +): Promise<{ + updated: boolean + conversation: Conversation + result: GardenerResult +}> { const result = (await sendRequest({ type: "RUN_GARDENER", target: "offscreen", - payload: { conversationId }, - })) as { updated: boolean; conversation: Conversation; result: GardenerResult }; + payload: { conversationId } + })) as { + updated: boolean + conversation: Conversation + result: GardenerResult + } if (result.updated) { chrome.runtime.sendMessage({ type: "VESTI_DATA_UPDATED" }, () => { - void chrome.runtime.lastError; - }); + void chrome.runtime.lastError + }) } - return result; + return result } export async function getRelatedConversations( conversationId: number, limit?: number ): Promise { - return sendRequest({ - type: "GET_RELATED_CONVERSATIONS", - target: "offscreen", - payload: { conversationId, limit }, - }, LONG_RUNNING_TIMEOUT_MS) as Promise; + return sendRequest( + { + type: "GET_RELATED_CONVERSATIONS", + target: "offscreen", + payload: { conversationId, limit } + }, + LONG_RUNNING_TIMEOUT_MS + ) as Promise } export async function getAllEdges( options: { threshold?: number; conversationIds?: number[] } = {} ): Promise> { - return sendRequest({ - type: "GET_ALL_EDGES", - target: "offscreen", - payload: options, - }, LONG_RUNNING_TIMEOUT_MS) as Promise>; + return sendRequest( + { + type: "GET_ALL_EDGES", + target: "offscreen", + payload: options + }, + LONG_RUNNING_TIMEOUT_MS + ) as Promise> } export async function renameFolderTag( from: string, to: string ): Promise<{ updated: number }> { - const result = (await sendRequest({ - type: "RENAME_FOLDER_TAG", - target: "offscreen", - payload: { from, to }, - }, LONG_RUNNING_TIMEOUT_MS)) as { updated: number }; + const result = (await sendRequest( + { + type: "RENAME_FOLDER_TAG", + target: "offscreen", + payload: { from, to } + }, + LONG_RUNNING_TIMEOUT_MS + )) as { updated: number } if (result.updated > 0) { chrome.runtime.sendMessage({ type: "VESTI_DATA_UPDATED" }, () => { - void chrome.runtime.lastError; - }); + void chrome.runtime.lastError + }) } - return result; + return result } export async function moveFolderTag( from: string, to: string ): Promise<{ updated: number }> { - const result = (await sendRequest({ - type: "MOVE_FOLDER_TAG", - target: "offscreen", - payload: { from, to }, - }, LONG_RUNNING_TIMEOUT_MS)) as { updated: number }; + const result = (await sendRequest( + { + type: "MOVE_FOLDER_TAG", + target: "offscreen", + payload: { from, to } + }, + LONG_RUNNING_TIMEOUT_MS + )) as { updated: number } if (result.updated > 0) { chrome.runtime.sendMessage({ type: "VESTI_DATA_UPDATED" }, () => { - void chrome.runtime.lastError; - }); + void chrome.runtime.lastError + }) } - return result; + return result } export async function removeFolderTag( tag: string ): Promise<{ updated: number }> { - const result = (await sendRequest({ - type: "REMOVE_FOLDER_TAG", - target: "offscreen", - payload: { tag }, - }, LONG_RUNNING_TIMEOUT_MS)) as { updated: number }; + const result = (await sendRequest( + { + type: "REMOVE_FOLDER_TAG", + target: "offscreen", + payload: { tag } + }, + LONG_RUNNING_TIMEOUT_MS + )) as { updated: number } if (result.updated > 0) { chrome.runtime.sendMessage({ type: "VESTI_DATA_UPDATED" }, () => { - void chrome.runtime.lastError; - }); + void chrome.runtime.lastError + }) } - return result; + return result } -export async function getMessages( - conversationId: number -): Promise { +export async function getMessages(conversationId: number): Promise { return sendRequest({ type: "GET_MESSAGES", target: "offscreen", - payload: { conversationId }, - }) as Promise; + payload: { conversationId } + }) as Promise } export async function getAnnotationsByConversation( @@ -216,52 +241,54 @@ export async function getAnnotationsByConversation( return sendRequest({ type: "GET_ANNOTATIONS_BY_CONVERSATION", target: "offscreen", - payload: { conversationId }, - }) as Promise; + payload: { conversationId } + }) as Promise } export async function saveAnnotation(payload: { - conversationId: number; - messageId: number; - contentText: string; + conversationId: number + messageId: number + contentText: string }): Promise { const result = (await sendRequest({ type: "SAVE_ANNOTATION", target: "offscreen", - payload, - })) as { annotation: Annotation }; + payload + })) as { annotation: Annotation } chrome.runtime.sendMessage({ type: "VESTI_DATA_UPDATED" }, () => { - void chrome.runtime.lastError; - }); + void chrome.runtime.lastError + }) - return result.annotation; + return result.annotation } export async function deleteAnnotation(annotationId: number): Promise { await sendRequest({ type: "DELETE_ANNOTATION", target: "offscreen", - payload: { annotationId }, - }); + payload: { annotationId } + }) chrome.runtime.sendMessage({ type: "VESTI_DATA_UPDATED" }, () => { - void chrome.runtime.lastError; - }); + void chrome.runtime.lastError + }) } -export async function exportAnnotationToNote(annotationId: number): Promise { +export async function exportAnnotationToNote( + annotationId: number +): Promise { const result = (await sendRequest({ type: "EXPORT_ANNOTATION_TO_NOTE", target: "offscreen", - payload: { annotationId }, - })) as { note: Note }; + payload: { annotationId } + })) as { note: Note } chrome.runtime.sendMessage({ type: "VESTI_DATA_UPDATED" }, () => { - void chrome.runtime.lastError; - }); + void chrome.runtime.lastError + }) - return result.note; + return result.note } export async function exportAnnotationToNotion( @@ -270,17 +297,17 @@ export async function exportAnnotationToNotion( const result = (await sendRequest({ type: "EXPORT_ANNOTATION_TO_NOTION", target: "offscreen", - payload: { annotationId }, - })) as { pageId: string; url?: string }; + payload: { annotationId } + })) as { pageId: string; url?: string } - return result; + return result } export async function getNotes(): Promise { return sendRequest({ type: "GET_NOTES", - target: "offscreen", - }) as Promise; + target: "offscreen" + }) as Promise } export async function saveNote( @@ -289,9 +316,9 @@ export async function saveNote( const result = (await sendRequest({ type: "CREATE_NOTE", target: "offscreen", - payload: data, - })) as { note: Note }; - return result.note; + payload: data + })) as { note: Note } + return result.note } export async function updateNote( @@ -301,17 +328,17 @@ export async function updateNote( const result = (await sendRequest({ type: "UPDATE_NOTE", target: "offscreen", - payload: { id, changes }, - })) as { note: Note }; - return result.note; + payload: { id, changes } + })) as { note: Note } + return result.note } export async function deleteNote(id: number): Promise { await sendRequest({ type: "DELETE_NOTE", target: "offscreen", - payload: { id }, - }); + payload: { id } + }) } export async function searchConversationIdsByText( @@ -321,10 +348,10 @@ export async function searchConversationIdsByText( { type: "SEARCH_CONVERSATION_IDS_BY_TEXT", target: "offscreen", - payload: { query }, + payload: { query } }, FULL_TEXT_SEARCH_TIMEOUT_MS - ) as Promise; + ) as Promise } export async function searchConversationMatchesByText( @@ -334,10 +361,10 @@ export async function searchConversationMatchesByText( { type: "SEARCH_CONVERSATION_MATCHES_BY_TEXT", target: "offscreen", - payload: params, + payload: params }, FULL_TEXT_SEARCH_TIMEOUT_MS - ) as Promise; + ) as Promise } export async function askKnowledgeBase( @@ -351,10 +378,10 @@ export async function askKnowledgeBase( { type: "ASK_KNOWLEDGE_BASE", target: "offscreen", - payload: { query, sessionId, limit, mode, options }, + payload: { query, sessionId, limit, mode, options } }, LONG_RUNNING_TIMEOUT_MS - ) as Promise; + ) as Promise } // Explore Session APIs @@ -362,49 +389,58 @@ export async function createExploreSession(title: string): Promise { const result = (await sendRequest({ type: "CREATE_EXPLORE_SESSION", target: "offscreen", - payload: { title }, - })) as { sessionId: string }; - return result.sessionId; + payload: { title } + })) as { sessionId: string } + return result.sessionId } -export async function listExploreSessions(limit?: number): Promise { +export async function listExploreSessions( + limit?: number +): Promise { return sendRequest({ type: "LIST_EXPLORE_SESSIONS", target: "offscreen", - payload: { limit }, - }) as Promise; + payload: { limit } + }) as Promise } -export async function getExploreSession(sessionId: string): Promise { +export async function getExploreSession( + sessionId: string +): Promise { return sendRequest({ type: "GET_EXPLORE_SESSION", target: "offscreen", - payload: { sessionId }, - }) as Promise; + payload: { sessionId } + }) as Promise } -export async function getExploreMessages(sessionId: string): Promise { +export async function getExploreMessages( + sessionId: string +): Promise { return sendRequest({ type: "GET_EXPLORE_MESSAGES", target: "offscreen", - payload: { sessionId }, - }) as Promise; + payload: { sessionId } + }) as Promise } export async function deleteExploreSession(sessionId: string): Promise { await sendRequest({ type: "DELETE_EXPLORE_SESSION", target: "offscreen", - payload: { sessionId }, - }); + payload: { sessionId } + }) } -export async function renameExploreSession(sessionId: string, title: string): Promise { +export async function renameExploreSession( + sessionId: string, + title: string +): Promise { await sendRequest({ type: "RENAME_EXPLORE_SESSION", target: "offscreen", - payload: { sessionId, title }, - }); + payload: { sessionId, title } + }) } export async function updateExploreMessageContext( @@ -415,37 +451,37 @@ export async function updateExploreMessageContext( await sendRequest({ type: "UPDATE_EXPLORE_MESSAGE_CONTEXT", target: "offscreen", - payload: { messageId, contextDraft, selectedContextConversationIds }, - }); + payload: { messageId, contextDraft, selectedContextConversationIds } + }) } export async function deleteConversation(id: number): Promise { await sendRequest({ type: "DELETE_CONVERSATION", target: "offscreen", - payload: { id }, - }); + payload: { id } + }) chrome.runtime.sendMessage({ type: "VESTI_DATA_UPDATED" }, () => { - void chrome.runtime.lastError; - }); + void chrome.runtime.lastError + }) } export async function deleteConversations(ids: number[]): Promise { - const uniqueIds = Array.from(new Set(ids)); - if (uniqueIds.length === 0) return; + const uniqueIds = Array.from(new Set(ids)) + if (uniqueIds.length === 0) return for (const id of uniqueIds) { await sendRequest({ type: "DELETE_CONVERSATION", target: "offscreen", - payload: { id }, - }); + payload: { id } + }) } chrome.runtime.sendMessage({ type: "VESTI_DATA_UPDATED" }, () => { - void chrome.runtime.lastError; - }); + void chrome.runtime.lastError + }) } export async function updateConversationTitle( @@ -455,37 +491,37 @@ export async function updateConversationTitle( const result = (await sendRequest({ type: "UPDATE_CONVERSATION_TITLE", target: "offscreen", - payload: { id, title }, - })) as { updated: boolean; conversation: Conversation }; + payload: { id, title } + })) as { updated: boolean; conversation: Conversation } if (result.updated) { chrome.runtime.sendMessage({ type: "VESTI_DATA_UPDATED" }, () => { - void chrome.runtime.lastError; - }); + void chrome.runtime.lastError + }) } - return result.conversation; + return result.conversation } export async function getDashboardStats(): Promise { return sendRequest({ type: "GET_DASHBOARD_STATS", - target: "offscreen", - }) as Promise; + target: "offscreen" + }) as Promise } export async function getStorageUsage(): Promise { return sendRequest({ type: "GET_STORAGE_USAGE", - target: "offscreen", - }) as Promise; + target: "offscreen" + }) as Promise } export async function getDataOverview(): Promise { return sendRequest({ type: "GET_DATA_OVERVIEW", - target: "offscreen", - }) as Promise; + target: "offscreen" + }) as Promise } export async function exportData( @@ -494,66 +530,70 @@ export async function exportData( const result = (await sendRequest({ type: "EXPORT_DATA", target: "offscreen", - payload: { format }, - })) as { content: string; filename: string; mime: string }; + payload: { format } + })) as { content: string; filename: string; mime: string } return { blob: new Blob([result.content], { type: result.mime }), filename: result.filename, - mime: result.mime, - }; + mime: result.mime + } } export async function clearAllData(): Promise { await sendRequest({ type: "CLEAR_ALL_DATA", - target: "offscreen", - }); + target: "offscreen" + }) chrome.runtime.sendMessage({ type: "VESTI_DATA_UPDATED" }, () => { - void chrome.runtime.lastError; - }); + void chrome.runtime.lastError + }) } export async function clearInsightsCache(): Promise { await sendRequest({ type: "CLEAR_INSIGHTS_CACHE", - target: "offscreen", - }); + target: "offscreen" + }) chrome.runtime.sendMessage({ type: "VESTI_DATA_UPDATED" }, () => { - void chrome.runtime.lastError; - }); + void chrome.runtime.lastError + }) } export async function getLlmSettings(): Promise { const result = (await sendRequest({ type: "GET_LLM_SETTINGS", - target: "offscreen", - })) as { settings: LlmConfig | null }; - return result.settings; + target: "offscreen" + })) as { settings: LlmConfig | null } + return result.settings } export async function setLlmSettings(settings: LlmConfig): Promise { await sendRequest({ type: "SET_LLM_SETTINGS", target: "offscreen", - payload: { settings }, - }); + payload: { settings } + }) } export async function testLlmConnection(): Promise<{ - ok: boolean; - message?: string; - diagnostic?: LlmDiagnostic | null; + ok: boolean + message?: string + diagnostic?: LlmDiagnostic | null }> { return sendRequest( { type: "TEST_LLM_CONNECTION", - target: "offscreen", + target: "offscreen" }, TEST_CONNECTION_TIMEOUT_MS - ) as Promise<{ ok: boolean; message?: string; diagnostic?: LlmDiagnostic | null }>; + ) as Promise<{ + ok: boolean + message?: string + diagnostic?: LlmDiagnostic | null + }> } export async function getConversationSummary( @@ -562,15 +602,15 @@ export async function getConversationSummary( return sendRequest({ type: "GET_CONVERSATION_SUMMARY", target: "offscreen", - payload: { conversationId }, - }) as Promise; + payload: { conversationId } + }) as Promise } export async function getSummary( conversationId: number ): Promise { - const record = await getConversationSummary(conversationId); - return record ? toChatSummaryData(record) : null; + const record = await getConversationSummary(conversationId) + return record ? toChatSummaryData(record) : null } export async function generateConversationSummary( @@ -580,17 +620,17 @@ export async function generateConversationSummary( { type: "GENERATE_CONVERSATION_SUMMARY", target: "offscreen", - payload: { conversationId }, + payload: { conversationId } }, LONG_RUNNING_TIMEOUT_MS - ) as Promise; + ) as Promise } export async function generateSummary( conversationId: number ): Promise { - const record = await generateConversationSummary(conversationId); - return toChatSummaryData(record); + const record = await generateConversationSummary(conversationId) + return toChatSummaryData(record) } export async function getWeeklyReport( @@ -600,8 +640,8 @@ export async function getWeeklyReport( return sendRequest({ type: "GET_WEEKLY_REPORT", target: "offscreen", - payload: { rangeStart, rangeEnd }, - }) as Promise; + payload: { rangeStart, rangeEnd } + }) as Promise } export async function generateWeeklyReport( @@ -612,22 +652,22 @@ export async function generateWeeklyReport( { type: "GENERATE_WEEKLY_REPORT", target: "offscreen", - payload: { rangeStart, rangeEnd }, + payload: { rangeStart, rangeEnd } }, LONG_RUNNING_TIMEOUT_MS - ) as Promise; + ) as Promise } export async function getActiveCaptureStatus(): Promise { return sendRequest({ type: "GET_ACTIVE_CAPTURE_STATUS", - target: "background", - }) as Promise; + target: "background" + }) as Promise } export async function forceArchiveTransient(): Promise { return sendRequest({ type: "FORCE_ARCHIVE_TRANSIENT", - target: "background", - }) as Promise; + target: "background" + }) as Promise } diff --git a/frontend/src/lib/types/index.ts b/frontend/src/lib/types/index.ts index 0abfeb8..7f82674 100644 --- a/frontend/src/lib/types/index.ts +++ b/frontend/src/lib/types/index.ts @@ -3,7 +3,7 @@ // All interface definitions for Vesti (kept in sync with frontend) // ============================================================ -import type { AstRoot, AstVersion } from "./ast"; +import type { AstRoot, AstVersion } from "./ast" export type Platform = | "ChatGPT" @@ -13,89 +13,89 @@ export type Platform = | "Qwen" | "Doubao" | "Kimi" - | "Yuanbao"; + | "Yuanbao" export interface Topic { - id: number; - name: string; - parent_id: number | null; - created_at: number; - updated_at: number; - count?: number; - children?: Topic[]; + id: number + name: string + parent_id: number | null + created_at: number + updated_at: number + count?: number + children?: Topic[] } export interface GardenerStep { - step: string; - status: "pending" | "running" | "completed"; - details?: string; + step: string + status: "pending" | "running" | "completed" + details?: string } export interface GardenerResult { - tags: string[]; - matchedTopic?: Topic; - createdTopic?: Topic; - steps: GardenerStep[]; + tags: string[] + matchedTopic?: Topic + createdTopic?: Topic + steps: GardenerStep[] } export interface Conversation { - id: number; - uuid: string; - platform: Platform; - title: string; - snippet: string; - url: string; - source_created_at: number | null; - first_captured_at: number; - last_captured_at: number; - created_at: number; - updated_at: number; - message_count: number; - turn_count: number; - is_archived: boolean; - is_trash: boolean; - tags: string[]; - topic_id: number | null; - is_starred: boolean; - has_note?: boolean; + id: number + uuid: string + platform: Platform + title: string + snippet: string + url: string + source_created_at: number | null + first_captured_at: number + last_captured_at: number + created_at: number + updated_at: number + message_count: number + turn_count: number + is_archived: boolean + is_trash: boolean + tags: string[] + topic_id: number | null + is_starred: boolean + has_note?: boolean } export interface SearchConversationMatchesQuery { - query: string; - conversationIds?: number[]; + query: string + conversationIds?: number[] } export interface ConversationMatchSummary { - conversationId: number; - firstMatchedMessageId: number; - bestExcerpt: string; + conversationId: number + firstMatchedMessageId: number + bestExcerpt: string } export interface VectorRecord { - id?: number; - conversation_id: number; - text_hash: string; - embedding: Float32Array; + id?: number + conversation_id: number + text_hash: string + embedding: Float32Array } export interface RelatedConversation { - id: number; - title: string; - platform: Platform; - similarity: number; + id: number + title: string + platform: Platform + similarity: number } -export type ExploreMode = "agent" | "classic"; +export type ExploreMode = "agent" | "classic" -export type ExploreSearchScopeMode = "all" | "selected"; +export type ExploreSearchScopeMode = "all" | "selected" export interface ExploreSearchScope { - mode: ExploreSearchScopeMode; - conversationIds?: number[]; + mode: ExploreSearchScopeMode + conversationIds?: number[] } export interface ExploreAskOptions { - searchScope?: ExploreSearchScope; + searchScope?: ExploreSearchScope } export type ExploreIntentType = @@ -103,32 +103,32 @@ export type ExploreIntentType = | "cross_conversation_summary" | "weekly_review" | "timeline" - | "clarification_needed"; + | "clarification_needed" export type ExploreRequestedTimeScopePreset = | "none" | "current_week_to_date" | "last_7_days" | "last_full_week" - | "custom"; + | "custom" export interface ExploreRequestedTimeScope { - preset: ExploreRequestedTimeScopePreset; - label?: string; - startDate?: string; - endDate?: string; + preset: ExploreRequestedTimeScopePreset + label?: string + startDate?: string + endDate?: string } export interface ExploreResolvedTimeScope { - preset: Exclude; - label: string; - rangeStart: number; - rangeEnd: number; - startDate: string; - endDate: string; + preset: Exclude + label: string + rangeStart: number + rangeEnd: number + startDate: string + endDate: string } -export type ExplorePlannerPath = "rag" | "weekly_summary" | "clarify"; +export type ExplorePlannerPath = "rag" | "weekly_summary" | "clarify" export type ExploreToolName = | "intent_planner" @@ -138,83 +138,83 @@ export type ExploreToolName = | "search_rag" | "summary_tool" | "context_compiler" - | "answer_synthesizer"; + | "answer_synthesizer" -export type ExploreToolStatus = "completed" | "failed" | "skipped"; +export type ExploreToolStatus = "completed" | "failed" | "skipped" export interface ExploreToolCall { - id: string; - name: ExploreToolName; - status: ExploreToolStatus; - startedAt: number; - endedAt: number; - durationMs: number; - description?: string; - inputSummary?: string; - outputSummary?: string; - error?: string; + id: string + name: ExploreToolName + status: ExploreToolStatus + startedAt: number + endedAt: number + durationMs: number + description?: string + inputSummary?: string + outputSummary?: string + error?: string } export interface ExploreContextCandidate { - conversationId: number; - title: string; - platform: Platform; - similarity: number; - matchType?: "semantic" | "time_scope"; - selectionReason?: string; - summarySnippet?: string; - excerpt?: string; + conversationId: number + title: string + platform: Platform + similarity: number + matchType?: "semantic" | "time_scope" + selectionReason?: string + summarySnippet?: string + excerpt?: string } export interface ExploreAgentPlan { - intent: ExploreIntentType; - reason: string; - preferredPath: ExplorePlannerPath; - sourceLimit: number; - summaryTargetCount: number; - answerGoal?: string; - needsClarification?: boolean; - clarifyingQuestion?: string; - requestedTimeScope?: ExploreRequestedTimeScope; - resolvedTimeScope?: ExploreResolvedTimeScope; - toolPlan?: ExploreToolName[]; + intent: ExploreIntentType + reason: string + preferredPath: ExplorePlannerPath + sourceLimit: number + summaryTargetCount: number + answerGoal?: string + needsClarification?: boolean + clarifyingQuestion?: string + requestedTimeScope?: ExploreRequestedTimeScope + resolvedTimeScope?: ExploreResolvedTimeScope + toolPlan?: ExploreToolName[] } export interface ExploreAgentMeta { - mode: ExploreMode; - query?: string; - searchScope?: ExploreSearchScope; - plan?: ExploreAgentPlan; - toolCalls: ExploreToolCall[]; - contextDraft?: string; - contextCandidates?: ExploreContextCandidate[]; - selectedContextConversationIds?: number[]; - totalDurationMs?: number; + mode: ExploreMode + query?: string + searchScope?: ExploreSearchScope + plan?: ExploreAgentPlan + toolCalls: ExploreToolCall[] + contextDraft?: string + contextCandidates?: ExploreContextCandidate[] + selectedContextConversationIds?: number[] + totalDurationMs?: number } export interface RagResponse { - answer: string; - sources: RelatedConversation[]; - agent?: ExploreAgentMeta; + answer: string + sources: RelatedConversation[] + agent?: ExploreAgentMeta } export interface ExploreSession { - id: string; - title: string; - preview: string; - messageCount: number; - createdAt: number; - updatedAt: number; + id: string + title: string + preview: string + messageCount: number + createdAt: number + updatedAt: number } export interface ExploreMessage { - id: string; - sessionId: string; - role: "user" | "assistant"; - content: string; - sources?: RelatedConversation[]; - agentMeta?: ExploreAgentMeta; - timestamp: number; + id: string + sessionId: string + role: "user" | "assistant" + content: string + sources?: RelatedConversation[] + agentMeta?: ExploreAgentMeta + timestamp: number } export type MessageCitationSourceType = @@ -254,89 +254,104 @@ export interface MessageArtifact { } export interface Message { - id: number; - conversation_id: number; - role: "user" | "ai"; - content_text: string; - content_ast?: AstRoot | null; - content_ast_version?: AstVersion | null; - degraded_nodes_count?: number; - citations?: MessageCitation[]; - artifacts?: MessageArtifact[]; - normalized_html_snapshot?: string | null; - created_at: number; + id: number + conversation_id: number + role: "user" | "ai" + content_text: string + content_ast?: AstRoot | null + content_ast_version?: AstVersion | null + degraded_nodes_count?: number + citations?: MessageCitation[] + artifacts?: MessageArtifact[] + normalized_html_snapshot?: string | null + created_at: number } export interface Annotation { - id: number; - conversation_id: number; - message_id: number; - content_text: string; - created_at: number; - days_after: number; + id: number + conversation_id: number + message_id: number + content_text: string + created_at: number + days_after: number } export interface Note { - id: number; - title: string; - content: string; - created_at: number; - updated_at: number; - linked_conversation_ids: number[]; + id: number + title: string + content: string + created_at: number + updated_at: number + linked_conversation_ids: number[] } export interface DashboardStats { - totalConversations: number; - totalTokens: number; - firstCaptureStreak: number; - firstCapturedTodayCount: number; - platformDistribution: Record; - firstCaptureHeatmapData: { date: string; count: number }[]; + totalConversations: number + totalTokens: number + firstCaptureStreak: number + firstCapturedTodayCount: number + platformDistribution: Record + firstCaptureHeatmapData: { date: string; count: number }[] } -export type ExportFormat = "json" | "txt" | "md"; +export type ExportFormat = "json" | "txt" | "md" export interface ExportPayload { - content: string; - mime: string; - filename: string; + content: string + mime: string + filename: string } -export type StorageUsageStatus = "ok" | "warning" | "blocked"; +export type StorageUsageStatus = "ok" | "warning" | "blocked" export interface StorageUsageSnapshot { - originUsed: number; - originQuota: number | null; - localUsed: number; - unlimitedStorageEnabled: boolean; - softLimit: number; - hardLimit: number; - status: StorageUsageStatus; + originUsed: number + originQuota: number | null + localUsed: number + unlimitedStorageEnabled: boolean + softLimit: number + hardLimit: number + status: StorageUsageStatus +} + +export interface StorageEngineSnapshot { + activeEngine: "dexie" | "sqlite" + migrationState: + | "idle" + | "initializing" + | "migrating" + | "validating" + | "ready" + | "error" + snapshotWatermark: number | null + appliedWatermark: number | null + lastError: string | null } export interface DataOverviewSnapshot { - storage: StorageUsageSnapshot; - totalConversations: number; - compactedThreads: number; - summaryRecordCount: number; - weeklyReportCount: number; - lastCompactionAt: number | null; - indexedDbName: string; + storage: StorageUsageSnapshot + totalConversations: number + compactedThreads: number + summaryRecordCount: number + weeklyReportCount: number + lastCompactionAt: number | null + indexedDbName: string + storageEngine: StorageEngineSnapshot } -export type CapsuleState = "RECORDING" | "STANDBY" | "PAUSED" | "SAVED"; +export type CapsuleState = "RECORDING" | "STANDBY" | "PAUSED" | "SAVED" -export type CaptureMode = "mirror" | "smart" | "manual"; +export type CaptureMode = "mirror" | "smart" | "manual" export interface CaptureSettings { - mode: CaptureMode; + mode: CaptureMode smartConfig: { - minTurns: number; - blacklistKeywords: string[]; - }; + minTurns: number + blacklistKeywords: string[] + } } -export type CaptureDecision = "committed" | "held" | "rejected"; +export type CaptureDecision = "committed" | "held" | "rejected" export type CaptureDecisionReason = | "missing_conversation_id" @@ -348,18 +363,18 @@ export type CaptureDecisionReason = | "smart_pass" | "empty_payload" | "storage_limit_blocked" - | "persist_failed"; + | "persist_failed" export interface CaptureDecisionMeta { - mode: CaptureMode; - decision: CaptureDecision; - reason: CaptureDecisionReason; - messageCount: number; - turnCount: number; - blacklistHit: boolean; - forceFlag: boolean; - intercepted: boolean; - occurredAt: number; + mode: CaptureMode + decision: CaptureDecision + reason: CaptureDecisionReason + messageCount: number + turnCount: number + blacklistHit: boolean + forceFlag: boolean + intercepted: boolean + occurredAt: number } export type ActiveCaptureStatusReason = @@ -367,196 +382,199 @@ export type ActiveCaptureStatusReason = | "mode_mirror" | "unsupported_tab" | "no_transient" - | "content_unreachable"; + | "content_unreachable" export interface ActiveCaptureStatus { - mode: CaptureMode; - supported: boolean; - available: boolean; - reason: ActiveCaptureStatusReason; - platform?: Platform; - sessionUUID?: string; - transientKey?: string; - messageCount?: number; - turnCount?: number; - lastDecision?: CaptureDecisionMeta; - firstObservedAt?: number; - updatedAt?: number; + mode: CaptureMode + supported: boolean + available: boolean + reason: ActiveCaptureStatusReason + platform?: Platform + sessionUUID?: string + transientKey?: string + messageCount?: number + turnCount?: number + lastDecision?: CaptureDecisionMeta + firstObservedAt?: number + updatedAt?: number } export interface ForceArchiveTransientResult { - forced: true; - saved: boolean; - newMessages: number; - conversationId?: number; - decision: CaptureDecisionMeta; + forced: true + saved: boolean + newMessages: number + conversationId?: number + decision: CaptureDecisionMeta } -export type PageId = "timeline" | "insights" | "data" | "settings"; -export type UiThemeMode = "light" | "dark"; +export type PageId = "timeline" | "insights" | "data" | "settings" +export type UiThemeMode = "light" | "dark" export interface UiSettings { - themeMode: UiThemeMode; + themeMode: UiThemeMode } -export type NotionAuthMode = "disconnected" | "oauth_public" | "legacy_manual"; +export type NotionAuthMode = "disconnected" | "oauth_public" | "legacy_manual" export interface NotionSettings { - authMode: NotionAuthMode; - accessToken: string; - workspaceId: string; - workspaceName: string; - selectedDatabaseId: string; - selectedDatabaseTitle: string; - updatedAt: number; + authMode: NotionAuthMode + accessToken: string + workspaceId: string + workspaceName: string + selectedDatabaseId: string + selectedDatabaseTitle: string + updatedAt: number } export interface NotionDatabaseOption { - id: string; - title: string; - url?: string; + id: string + title: string + url?: string } -export type UiSemanticLayer = "app_shell" | "artifact_content"; -export type TypographySemantic = "ui_sans" | "reading_serif"; -export type VisualDensityMode = "guardrail_v1_1" | "target_v1_2"; +export type UiSemanticLayer = "app_shell" | "artifact_content" +export type TypographySemantic = "ui_sans" | "reading_serif" +export type VisualDensityMode = "guardrail_v1_1" | "target_v1_2" -export type LlmProvider = "modelscope"; -export type LlmAccessMode = "demo_proxy" | "custom_byok"; -export type StreamMode = "off" | "on"; -export type ReasoningPolicy = "off" | "auto" | "force"; -export type CapabilitySource = "model_id_heuristic" | "provider_catalog"; -export type ThinkHandlingPolicy = "strip" | "keep_debug" | "keep_raw"; +export type LlmProvider = "modelscope" +export type LlmAccessMode = "demo_proxy" | "custom_byok" +export type StreamMode = "off" | "on" +export type ReasoningPolicy = "off" | "auto" | "force" +export type CapabilitySource = "model_id_heuristic" | "provider_catalog" +export type ThinkHandlingPolicy = "strip" | "keep_debug" | "keep_raw" export interface LlmConfig { - provider: LlmProvider; - baseUrl: string; - apiKey: string; - modelId: string; - temperature: number; - maxTokens: number; - updatedAt: number; - mode?: LlmAccessMode; - proxyBaseUrl?: string; - proxyUrl?: string; - proxyServiceToken?: string; - gatewayLock?: "modelscope"; - customModelId?: string; - streamMode?: StreamMode; - reasoningPolicy?: ReasoningPolicy; - capabilitySource?: CapabilitySource; - thinkHandlingPolicy?: ThinkHandlingPolicy; -} - -export type InsightFormat = "plain_text" | "structured_v1" | "fallback_plain_text"; -export type InsightStatus = "ok" | "fallback"; + provider: LlmProvider + baseUrl: string + apiKey: string + modelId: string + temperature: number + maxTokens: number + updatedAt: number + mode?: LlmAccessMode + proxyBaseUrl?: string + proxyUrl?: string + proxyServiceToken?: string + gatewayLock?: "modelscope" + customModelId?: string + streamMode?: StreamMode + reasoningPolicy?: ReasoningPolicy + capabilitySource?: CapabilitySource + thinkHandlingPolicy?: ThinkHandlingPolicy +} + +export type InsightFormat = + | "plain_text" + | "structured_v1" + | "fallback_plain_text" +export type InsightStatus = "ok" | "fallback" export interface ConversationSummaryV1 { - topic_title: string; - key_takeaways: string[]; - sentiment: "neutral" | "positive" | "negative"; - action_items?: string[]; - tech_stack_detected: string[]; + topic_title: string + key_takeaways: string[] + sentiment: "neutral" | "positive" | "negative" + action_items?: string[] + tech_stack_detected: string[] } export interface ConversationSummaryV2 { - core_question: string; + core_question: string thinking_journey: Array<{ - step: number; - speaker: "User" | "AI"; - assertion: string; - real_world_anchor: string | null; - }>; + step: number + speaker: "User" | "AI" + assertion: string + real_world_anchor: string | null + }> key_insights: Array<{ - term: string; - definition: string; - }>; - unresolved_threads: string[]; + term: string + definition: string + }> + unresolved_threads: string[] meta_observations: { - thinking_style: string; - emotional_tone: string; - depth_level: "superficial" | "moderate" | "deep"; - }; - actionable_next_steps: string[]; + thinking_style: string + emotional_tone: string + depth_level: "superficial" | "moderate" | "deep" + } + actionable_next_steps: string[] } export interface ConversationSummaryV2Legacy { - core_question: string; + core_question: string thinking_journey: { - initial_state: string; - key_turns: string[]; - final_understanding: string; - }; - key_insights: string[]; - unresolved_threads: string[]; + initial_state: string + key_turns: string[] + final_understanding: string + } + key_insights: string[] + unresolved_threads: string[] meta_observations: { - thinking_style: string; - emotional_tone: string; - depth_level: "superficial" | "moderate" | "deep"; - }; - actionable_next_steps: string[]; + thinking_style: string + emotional_tone: string + depth_level: "superficial" | "moderate" | "deep" + } + actionable_next_steps: string[] } export interface WeeklyReportV1 { - period_title: string; - main_themes: string[]; - key_takeaways: string[]; - action_items?: string[]; - tech_stack_detected: string[]; + period_title: string + main_themes: string[] + key_takeaways: string[] + action_items?: string[] + tech_stack_detected: string[] } export interface WeeklyLiteReportV1 { time_range: { - start: string; - end: string; - total_conversations: number; - }; - highlights: string[]; - recurring_questions: string[]; + start: string + end: string + total_conversations: number + } + highlights: string[] + recurring_questions: string[] cross_domain_echoes: Array<{ - domain_a: string; - domain_b: string; - shared_logic: string; - evidence_ids: number[]; - }>; - unresolved_threads: string[]; - suggested_focus: string[]; + domain_a: string + domain_b: string + shared_logic: string + evidence_ids: number[] + }> + unresolved_threads: string[] + suggested_focus: string[] evidence: Array<{ - conversation_id: number; - note: string; - }>; - insufficient_data: boolean; + conversation_id: number + note: string + }> + insufficient_data: boolean } export interface SummaryRecord { - id: number; - conversationId: number; - content: string; + id: number + conversationId: number + content: string structured?: | ConversationSummaryV1 | ConversationSummaryV2 | ConversationSummaryV2Legacy - | null; - format?: InsightFormat; - status?: InsightStatus; - schemaVersion?: "conversation_summary.v1" | "conversation_summary.v2"; - modelId: string; - createdAt: number; - sourceUpdatedAt: number; + | null + format?: InsightFormat + status?: InsightStatus + schemaVersion?: "conversation_summary.v1" | "conversation_summary.v2" + modelId: string + createdAt: number + sourceUpdatedAt: number } export interface WeeklyReportRecord { - id: number; - rangeStart: number; - rangeEnd: number; - content: string; - structured?: WeeklyReportV1 | WeeklyLiteReportV1 | null; - format?: InsightFormat; - status?: InsightStatus; - schemaVersion?: "weekly_report.v1" | "weekly_lite.v1"; - modelId: string; - createdAt: number; - sourceHash: string; -} - -export type AsyncStatus = "idle" | "loading" | "ready" | "error"; + id: number + rangeStart: number + rangeEnd: number + content: string + structured?: WeeklyReportV1 | WeeklyLiteReportV1 | null + format?: InsightFormat + status?: InsightStatus + schemaVersion?: "weekly_report.v1" | "weekly_lite.v1" + modelId: string + createdAt: number + sourceHash: string +} + +export type AsyncStatus = "idle" | "loading" | "ready" | "error" diff --git a/frontend/src/lib/utils/chromeStorageBridge.ts b/frontend/src/lib/utils/chromeStorageBridge.ts new file mode 100644 index 0000000..6096fb3 --- /dev/null +++ b/frontend/src/lib/utils/chromeStorageBridge.ts @@ -0,0 +1,126 @@ +export type InternalStorageBridgeMessage = + | { + type: "VESTI_INTERNAL_STORAGE_GET" + payload: { key: string } + } + | { + type: "VESTI_INTERNAL_STORAGE_SET" + payload: { values: Record } + } + +export type InternalStorageBridgeResponse = + | { ok: true; value?: unknown } + | { ok: false; error: string } + +type InternalStorageBridgeSuccessResponse = Extract< + InternalStorageBridgeResponse, + { ok: true } +> +type InternalStorageBridgeFailureResponse = Extract< + InternalStorageBridgeResponse, + { ok: false } +> + +function isInternalStorageBridgeFailureResponse( + response: InternalStorageBridgeResponse +): response is InternalStorageBridgeFailureResponse { + return response.ok === false +} + +function resolveStorageArea(): chrome.storage.StorageArea | null { + if (typeof chrome === "undefined" || !chrome.storage?.local) { + return null + } + + return chrome.storage.local +} + +function sendInternalStorageBridgeMessage( + message: InternalStorageBridgeMessage +): Promise { + if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) { + return Promise.reject(new Error("STORAGE_UNAVAILABLE")) + } + + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage( + message, + (response: InternalStorageBridgeResponse | undefined) => { + const error = chrome.runtime?.lastError + if (error) { + reject(new Error(error.message)) + return + } + + if (!response) { + reject(new Error("STORAGE_UNAVAILABLE")) + return + } + + if (isInternalStorageBridgeFailureResponse(response)) { + reject(new Error(response.error || "STORAGE_UNAVAILABLE")) + return + } + + resolve(response) + } + ) + }) +} + +export async function getLocalStorageValue( + key: string +): Promise { + const storageArea = resolveStorageArea() + if (storageArea) { + return new Promise((resolve, reject) => { + storageArea.get([key], (result) => { + const error = chrome.runtime?.lastError + if (error) { + reject(new Error(error.message)) + return + } + + resolve(result?.[key] as T | undefined) + }) + }) + } + + const response = await sendInternalStorageBridgeMessage({ + type: "VESTI_INTERNAL_STORAGE_GET", + payload: { key } + }) + + return response.value as T | undefined +} + +export async function setLocalStorageValues( + values: Record +): Promise { + const storageArea = resolveStorageArea() + if (storageArea) { + return new Promise((resolve, reject) => { + storageArea.set(values, () => { + const error = chrome.runtime?.lastError + if (error) { + reject(new Error(error.message)) + return + } + + resolve() + }) + }) + } + + await sendInternalStorageBridgeMessage({ + type: "VESTI_INTERNAL_STORAGE_SET", + payload: { values } + }) +} + +export async function setLocalStorageValue( + key: string, + value: unknown +): Promise { + await setLocalStorageValues({ [key]: value }) +} diff --git a/frontend/src/offscreen/index.ts b/frontend/src/offscreen/index.ts index 9903909..a60d4fe 100644 --- a/frontend/src/offscreen/index.ts +++ b/frontend/src/offscreen/index.ts @@ -1,149 +1,204 @@ - -import { isRequestMessage } from "../lib/messaging/protocol"; -import type { RequestMessage, ResponseMessage } from "../lib/messaging/protocol"; -import { interceptAndPersistCapture } from "../lib/capture/storage-interceptor"; +import { interceptAndPersistCapture } from "../lib/capture/storage-interceptor" import { - listConversations, - listMessages, - listAnnotations, - listNotes, - getTopics, + clearKnowledgeReadModelAfterAuthoritativeClear, + initializeKnowledgeQueryStore, + markKnowledgeConversationsDeleted, + markKnowledgeConversationsDirty, + markKnowledgeGlobalMutation +} from "../lib/db/knowledgeQueryStore" +import { + clearAllData, + clearInsightsCache, + createExploreSession, + createNote, createTopic, - updateConversationTopic, - updateConversation, - saveAnnotation, deleteAnnotation, - searchConversationIdsByText, - searchConversationMatchesByText, deleteConversation, - createNote, - updateNote, + deleteExploreSession, deleteNote, - updateConversationTitle, - renameTagAcrossConversations, - moveTagAcrossConversations, - removeTagFromConversations, + exportAllData, getDashboardStats, getDataOverview, + getExploreMessages, + getExploreSession, getStorageUsage, - exportAllData, - clearAllData, - clearInsightsCache, getSummary, + getTopics, getWeeklyReport, - createExploreSession, + listAnnotations, + listConversations, listExploreSessions, - getExploreSession, - getExploreMessages, - deleteExploreSession, - updateExploreSession, + listMessages, + listNotes, + moveTagAcrossConversations, + removeTagFromConversations, + renameTagAcrossConversations, + saveAnnotation, + searchConversationIdsByText, + searchConversationMatchesByText, + updateConversation, + updateConversationTitle, + updateConversationTopic, updateExploreMessageContext, -} from "../lib/db/repository"; -import { runGardener } from "../lib/services/gardenerService"; -import { - findRelatedConversations, - findAllEdges, - askKnowledgeBase, -} from "../lib/services/searchService"; -import { requestVectorization } from "../lib/services/vectorizationService"; + updateExploreSession, + updateNote +} from "../lib/db/repository" +import { isRequestMessage } from "../lib/messaging/protocol" +import type { RequestMessage, ResponseMessage } from "../lib/messaging/protocol" import { exportAnnotationToMyNotes, - exportAnnotationToNotion, -} from "../lib/services/annotationExportService"; -import { getLlmSettings, setLlmSettings } from "../lib/services/llmSettingsService"; -import { callInference, getLlmDiagnostic } from "../lib/services/llmService"; + exportAnnotationToNotion +} from "../lib/services/annotationExportService" +import { runGardener } from "../lib/services/gardenerService" import { generateConversationSummary, - generateWeeklyReport, -} from "../lib/services/insightGenerationService"; -import type { LlmConfig } from "../lib/types"; -import { logger } from "../lib/utils/logger"; -import { getLlmAccessMode, normalizeLlmSettings } from "../lib/services/llmConfig"; + generateWeeklyReport +} from "../lib/services/insightGenerationService" +import { + getLlmAccessMode, + normalizeLlmSettings +} from "../lib/services/llmConfig" +import { callInference, getLlmDiagnostic } from "../lib/services/llmService" +import { + getLlmSettings, + setLlmSettings +} from "../lib/services/llmSettingsService" +import { + askKnowledgeBase, + findAllEdges, + findRelatedConversations +} from "../lib/services/searchService" +import { requestVectorization } from "../lib/services/vectorizationService" +import type { LlmConfig } from "../lib/types" +import { logger } from "../lib/utils/logger" + +const isOffscreenRuntime = + typeof window !== "undefined" && + new URL(window.location.href).searchParams.get("offscreen") === "1" function requireSettings(settings: LlmConfig | null): LlmConfig { if (!settings) { - throw new Error("LLM_CONFIG_MISSING"); + throw new Error("LLM_CONFIG_MISSING") } - const normalized = normalizeLlmSettings(settings); - const mode = getLlmAccessMode(normalized); + const normalized = normalizeLlmSettings(settings) + const mode = getLlmAccessMode(normalized) if (mode === "demo_proxy") { - if ((!normalized.proxyBaseUrl && !normalized.proxyUrl) || !normalized.modelId) { - throw new Error("LLM_CONFIG_MISSING"); + if ( + (!normalized.proxyBaseUrl && !normalized.proxyUrl) || + !normalized.modelId + ) { + throw new Error("LLM_CONFIG_MISSING") } - return normalized; + return normalized } if (!normalized.apiKey || !normalized.modelId || !normalized.baseUrl) { - throw new Error("LLM_CONFIG_MISSING"); + throw new Error("LLM_CONFIG_MISSING") + } + return normalized +} + +async function markConversationMutation( + conversationId: number | undefined +): Promise { + if (typeof conversationId !== "number" || !Number.isFinite(conversationId)) { + return } - return normalized; + + await markKnowledgeConversationsDirty([conversationId]) } -async function handleRequest(message: RequestMessage): Promise { - const messageType = message.type; +async function handleRequest( + message: RequestMessage +): Promise { + const messageType = message.type try { switch (message.type) { case "CAPTURE_CONVERSATION": { - const result = await interceptAndPersistCapture(message.payload); - return { ok: true, type: messageType, data: result }; + const result = await interceptAndPersistCapture(message.payload) + if (result.saved && typeof result.conversationId === "number") { + await markConversationMutation(result.conversationId) + } + return { ok: true, type: messageType, data: result } } case "GET_CONVERSATIONS": { - const data = await listConversations(message.payload); - return { ok: true, type: messageType, data }; + const data = await listConversations(message.payload) + return { ok: true, type: messageType, data } } case "GET_TOPICS": { - const data = await getTopics(); - return { ok: true, type: messageType, data }; + const data = await getTopics() + return { ok: true, type: messageType, data } } case "CREATE_TOPIC": { - const topic = await createTopic(message.payload); - return { ok: true, type: messageType, data: { topic } }; + const topic = await createTopic(message.payload) + await markKnowledgeGlobalMutation() + return { ok: true, type: messageType, data: { topic } } } case "UPDATE_CONVERSATION_TOPIC": { const conversation = await updateConversationTopic( message.payload.id, message.payload.topic_id - ); - return { ok: true, type: messageType, data: { updated: true, conversation } }; + ) + await markConversationMutation(conversation.id) + return { + ok: true, + type: messageType, + data: { updated: true, conversation } + } } case "UPDATE_CONVERSATION": { const data = await updateConversation( message.payload.id, message.payload.changes - ); - return { ok: true, type: messageType, data }; + ) + if (data.updated) { + await markConversationMutation(data.conversation.id) + } + return { ok: true, type: messageType, data } } case "RUN_GARDENER": { - const data = await runGardener(message.payload.conversationId); - return { ok: true, type: messageType, data }; + const data = await runGardener(message.payload.conversationId) + if (data.updated) { + await markConversationMutation(data.conversation.id) + } + return { ok: true, type: messageType, data } } case "GET_RELATED_CONVERSATIONS": { const data = await findRelatedConversations( message.payload.conversationId, message.payload.limit - ); - return { ok: true, type: messageType, data }; + ) + return { ok: true, type: messageType, data } } case "GET_ALL_EDGES": { - const data = await findAllEdges(message.payload); - return { ok: true, type: messageType, data }; + const data = await findAllEdges(message.payload) + return { ok: true, type: messageType, data } } case "RENAME_FOLDER_TAG": { const updated = await renameTagAcrossConversations( message.payload.from, message.payload.to - ); - return { ok: true, type: messageType, data: { updated } }; + ) + if (updated > 0) { + await markKnowledgeGlobalMutation() + } + return { ok: true, type: messageType, data: { updated } } } case "MOVE_FOLDER_TAG": { const updated = await moveTagAcrossConversations( message.payload.from, message.payload.to - ); - return { ok: true, type: messageType, data: { updated } }; + ) + if (updated > 0) { + await markKnowledgeGlobalMutation() + } + return { ok: true, type: messageType, data: { updated } } } case "REMOVE_FOLDER_TAG": { - const updated = await removeTagFromConversations(message.payload.tag); - return { ok: true, type: messageType, data: { updated } }; + const updated = await removeTagFromConversations(message.payload.tag) + if (updated > 0) { + await markKnowledgeGlobalMutation() + } + return { ok: true, type: messageType, data: { updated } } } case "ASK_KNOWLEDGE_BASE": { const data = await askKnowledgeBase( @@ -152,144 +207,176 @@ async function handleRequest(message: RequestMessage): Promise message.payload.limit, message.payload.mode, message.payload.options - ); - return { ok: true, type: messageType, data }; + ) + await markKnowledgeGlobalMutation() + return { ok: true, type: messageType, data } } case "CREATE_EXPLORE_SESSION": { - const sessionId = await createExploreSession(message.payload.title); - return { ok: true, type: messageType, data: { sessionId } }; + const sessionId = await createExploreSession(message.payload.title) + await markKnowledgeGlobalMutation() + return { ok: true, type: messageType, data: { sessionId } } } case "LIST_EXPLORE_SESSIONS": { - const sessions = await listExploreSessions(message.payload?.limit); - return { ok: true, type: messageType, data: sessions }; + const sessions = await listExploreSessions(message.payload?.limit) + return { ok: true, type: messageType, data: sessions } } case "GET_EXPLORE_SESSION": { - const session = await getExploreSession(message.payload.sessionId); - return { ok: true, type: messageType, data: session }; + const session = await getExploreSession(message.payload.sessionId) + return { ok: true, type: messageType, data: session } } case "GET_EXPLORE_MESSAGES": { - const msgs = await getExploreMessages(message.payload.sessionId); - return { ok: true, type: messageType, data: msgs }; + const msgs = await getExploreMessages(message.payload.sessionId) + return { ok: true, type: messageType, data: msgs } } case "DELETE_EXPLORE_SESSION": { - await deleteExploreSession(message.payload.sessionId); - return { ok: true, type: messageType, data: { deleted: true } }; + await deleteExploreSession(message.payload.sessionId) + await markKnowledgeGlobalMutation() + return { ok: true, type: messageType, data: { deleted: true } } } case "RENAME_EXPLORE_SESSION": { await updateExploreSession(message.payload.sessionId, { - title: message.payload.title, - }); - return { ok: true, type: messageType, data: { updated: true } }; + title: message.payload.title + }) + await markKnowledgeGlobalMutation() + return { ok: true, type: messageType, data: { updated: true } } } case "UPDATE_EXPLORE_MESSAGE_CONTEXT": { await updateExploreMessageContext( message.payload.messageId, message.payload.contextDraft, message.payload.selectedContextConversationIds - ); - return { ok: true, type: messageType, data: { updated: true } }; + ) + await markKnowledgeGlobalMutation() + return { ok: true, type: messageType, data: { updated: true } } } case "GET_MESSAGES": { - const data = await listMessages(message.payload.conversationId); - return { ok: true, type: messageType, data }; + const data = await listMessages(message.payload.conversationId) + return { ok: true, type: messageType, data } } case "GET_ANNOTATIONS_BY_CONVERSATION": { - const data = await listAnnotations(message.payload.conversationId); - return { ok: true, type: messageType, data }; + const data = await listAnnotations(message.payload.conversationId) + return { ok: true, type: messageType, data } } case "SAVE_ANNOTATION": { - const annotation = await saveAnnotation(message.payload); - requestVectorization(); - return { ok: true, type: messageType, data: { annotation } }; + const annotation = await saveAnnotation(message.payload) + requestVectorization() + await markConversationMutation(annotation.conversation_id) + return { ok: true, type: messageType, data: { annotation } } } case "DELETE_ANNOTATION": { - await deleteAnnotation(message.payload.annotationId); - requestVectorization(); - return { ok: true, type: messageType, data: { deleted: true } }; + await deleteAnnotation(message.payload.annotationId) + requestVectorization() + await markKnowledgeGlobalMutation() + return { ok: true, type: messageType, data: { deleted: true } } } case "EXPORT_ANNOTATION_TO_NOTE": { - const note = await exportAnnotationToMyNotes(message.payload.annotationId); - return { ok: true, type: messageType, data: { note } }; + const note = await exportAnnotationToMyNotes( + message.payload.annotationId + ) + await markKnowledgeGlobalMutation() + return { ok: true, type: messageType, data: { note } } } case "EXPORT_ANNOTATION_TO_NOTION": { - const data = await exportAnnotationToNotion(message.payload.annotationId); - return { ok: true, type: messageType, data }; + const data = await exportAnnotationToNotion( + message.payload.annotationId + ) + return { ok: true, type: messageType, data } } case "GET_NOTES": { - const data = await listNotes(); - return { ok: true, type: messageType, data }; + const data = await listNotes() + return { ok: true, type: messageType, data } } case "CREATE_NOTE": { - const note = await createNote(message.payload); - return { ok: true, type: messageType, data: { note } }; + const note = await createNote(message.payload) + await markKnowledgeGlobalMutation() + return { ok: true, type: messageType, data: { note } } } case "UPDATE_NOTE": { - const note = await updateNote(message.payload.id, message.payload.changes); - return { ok: true, type: messageType, data: { note } }; + const note = await updateNote( + message.payload.id, + message.payload.changes + ) + await markKnowledgeGlobalMutation() + return { ok: true, type: messageType, data: { note } } } case "DELETE_NOTE": { - await deleteNote(message.payload.id); - return { ok: true, type: messageType, data: { deleted: true } }; + await deleteNote(message.payload.id) + await markKnowledgeGlobalMutation() + return { ok: true, type: messageType, data: { deleted: true } } } case "SEARCH_CONVERSATION_IDS_BY_TEXT": { - const data = await searchConversationIdsByText(message.payload.query); - return { ok: true, type: messageType, data }; + const data = await searchConversationIdsByText(message.payload.query) + return { ok: true, type: messageType, data } } case "SEARCH_CONVERSATION_MATCHES_BY_TEXT": { - const data = await searchConversationMatchesByText(message.payload); - return { ok: true, type: messageType, data }; + const data = await searchConversationMatchesByText(message.payload) + return { ok: true, type: messageType, data } } case "DELETE_CONVERSATION": { - const deleted = await deleteConversation(message.payload.id); - return { ok: true, type: messageType, data: { deleted } }; + const deleted = await deleteConversation(message.payload.id) + if (deleted) { + await markKnowledgeConversationsDeleted([message.payload.id]) + } + return { ok: true, type: messageType, data: { deleted } } } case "UPDATE_CONVERSATION_TITLE": { const conversation = await updateConversationTitle( message.payload.id, message.payload.title - ); - return { ok: true, type: messageType, data: { updated: true, conversation } }; + ) + await markConversationMutation(conversation.id) + return { + ok: true, + type: messageType, + data: { updated: true, conversation } + } } case "GET_DASHBOARD_STATS": { - const data = await getDashboardStats(); - return { ok: true, type: messageType, data }; + const data = await getDashboardStats() + return { ok: true, type: messageType, data } } case "GET_STORAGE_USAGE": { - const data = await getStorageUsage(); - return { ok: true, type: messageType, data }; + const data = await getStorageUsage() + return { ok: true, type: messageType, data } } case "GET_DATA_OVERVIEW": { - const data = await getDataOverview(); - return { ok: true, type: messageType, data }; + const data = await getDataOverview() + return { ok: true, type: messageType, data } } case "EXPORT_DATA": { - const data = await exportAllData(message.payload.format); - return { ok: true, type: messageType, data }; + const data = await exportAllData(message.payload.format) + return { ok: true, type: messageType, data } } case "CLEAR_ALL_DATA": { - const cleared = await clearAllData(); - return { ok: true, type: messageType, data: { cleared } }; + const cleared = await clearAllData() + if (cleared) { + await clearKnowledgeReadModelAfterAuthoritativeClear() + } + return { ok: true, type: messageType, data: { cleared } } } case "CLEAR_INSIGHTS_CACHE": { - const cleared = await clearInsightsCache(); - return { ok: true, type: messageType, data: { cleared } }; + const cleared = await clearInsightsCache() + if (cleared) { + await markKnowledgeGlobalMutation() + } + return { ok: true, type: messageType, data: { cleared } } } case "GET_LLM_SETTINGS": { - const settings = await getLlmSettings(); - return { ok: true, type: messageType, data: { settings } }; + const settings = await getLlmSettings() + return { ok: true, type: messageType, data: { settings } } } case "SET_LLM_SETTINGS": { - await setLlmSettings(message.payload.settings); - return { ok: true, type: messageType, data: { saved: true } }; + await setLlmSettings(message.payload.settings) + return { ok: true, type: messageType, data: { saved: true } } } case "TEST_LLM_CONNECTION": { - const settings = requireSettings(await getLlmSettings()); + const settings = requireSettings(await getLlmSettings()) try { await callInference(settings, "Reply with OK only.", { - systemPrompt: "You are a connectivity probe. Reply with OK only.", - }); + systemPrompt: "You are a connectivity probe. Reply with OK only." + }) } catch (error) { - const diagnostic = getLlmDiagnostic(error); + const diagnostic = getLlmDiagnostic(error) if (diagnostic) { return { ok: true, @@ -297,75 +384,94 @@ async function handleRequest(message: RequestMessage): Promise data: { ok: false, message: diagnostic.userMessage, - diagnostic, - }, - }; + diagnostic + } + } } - throw error; + throw error } return { ok: true, type: messageType, - data: { ok: true, message: "Connection verified." }, - }; + data: { ok: true, message: "Connection verified." } + } } case "GET_CONVERSATION_SUMMARY": { - const data = await getSummary(message.payload.conversationId); - return { ok: true, type: messageType, data }; + const data = await getSummary(message.payload.conversationId) + return { ok: true, type: messageType, data } } case "GENERATE_CONVERSATION_SUMMARY": { - const settings = requireSettings(await getLlmSettings()); + const settings = requireSettings(await getLlmSettings()) const record = await generateConversationSummary( settings, message.payload.conversationId - ); - return { ok: true, type: messageType, data: record }; + ) + await markConversationMutation(message.payload.conversationId) + return { ok: true, type: messageType, data: record } } case "GET_WEEKLY_REPORT": { const data = await getWeeklyReport( message.payload.rangeStart, message.payload.rangeEnd - ); - return { ok: true, type: messageType, data }; + ) + return { ok: true, type: messageType, data } } case "GENERATE_WEEKLY_REPORT": { - const settings = requireSettings(await getLlmSettings()); + const settings = requireSettings(await getLlmSettings()) const record = await generateWeeklyReport( settings, message.payload.rangeStart, message.payload.rangeEnd - ); - return { ok: true, type: messageType, data: record }; + ) + await markKnowledgeGlobalMutation() + return { ok: true, type: messageType, data: record } } default: return { ok: false, type: messageType, - error: `Unsupported message type: ${messageType}`, - }; + error: `Unsupported message type: ${messageType}` + } } } catch (error) { - logger.error("offscreen", "Request failed", error as Error); + logger.error("offscreen", "Request failed", error as Error) return { ok: false, type: messageType, - error: (error as Error).message || "Unknown error", - }; + error: (error as Error).message || "Unknown error" + } } } -chrome.runtime.onMessage.addListener( - (message: unknown, _sender: chrome.runtime.MessageSender, sendResponse: (response?: any) => void) => { - if (!isRequestMessage(message)) return; - if (message.target !== "offscreen") return; +if (isOffscreenRuntime) { + chrome.runtime.onMessage.addListener( + ( + message: unknown, + _sender: chrome.runtime.MessageSender, + sendResponse: (response?: any) => void + ) => { + if (!isRequestMessage(message)) return + if (message.target !== "offscreen") return + if ((message as { via?: string }).via !== "background") return - void (async () => { - const response = await handleRequest(message); - sendResponse(response); - })(); + void (async () => { + const response = await handleRequest(message) + sendResponse(response) + })() - return true; - } -); + return true + } + ) -logger.info("offscreen", "Offscreen handler initialized"); + void initializeKnowledgeQueryStore().catch((error) => { + logger.warn( + "offscreen", + "SQLite read-model bootstrap failed; using Dexie only", + { + error: (error as Error)?.message ?? String(error) + } + ) + }) + + logger.info("offscreen", "Offscreen handler initialized") +} diff --git a/frontend/src/options.tsx b/frontend/src/options.tsx index fa01acd..0b81c71 100644 --- a/frontend/src/options.tsx +++ b/frontend/src/options.tsx @@ -1,3 +1,13 @@ -import Dashboard from "./dashboard"; +import Dashboard from "./dashboard" -export default Dashboard; +import "./offscreen" + +const isOffscreenRuntime = + typeof window !== "undefined" && + new URL(window.location.href).searchParams.get("offscreen") === "1" + +function OffscreenOptionsRuntime() { + return null +} + +export default isOffscreenRuntime ? OffscreenOptionsRuntime : Dashboard diff --git a/frontend/src/sidepanel/components/DataManagementPanel.tsx b/frontend/src/sidepanel/components/DataManagementPanel.tsx index e94349f..e95613c 100644 --- a/frontend/src/sidepanel/components/DataManagementPanel.tsx +++ b/frontend/src/sidepanel/components/DataManagementPanel.tsx @@ -1,4 +1,3 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; import { BarChart3, ChevronDown, @@ -8,86 +7,88 @@ import { FolderArchive, Loader2, Trash2, - TriangleAlert, -} from "lucide-react"; -import type { - AsyncStatus, - DataOverviewSnapshot, - ExportFormat, - StorageUsageSnapshot, -} from "~lib/types"; + TriangleAlert +} from "lucide-react" +import { useCallback, useEffect, useMemo, useState } from "react" + import { clearAllData, clearInsightsCache, exportData, - getDataOverview, -} from "~lib/services/storageService"; + getDataOverview +} from "~lib/services/storageService" +import type { + AsyncStatus, + DataOverviewSnapshot, + ExportFormat, + StorageUsageSnapshot +} from "~lib/types" -const FALLBACK_SOFT_LIMIT = 900 * 1024 * 1024; -const FALLBACK_HARD_LIMIT = 1024 * 1024 * 1024; +const FALLBACK_SOFT_LIMIT = 900 * 1024 * 1024 +const FALLBACK_HARD_LIMIT = 1024 * 1024 * 1024 -type AccordionKey = "storage" | "export" | "cleanup"; +type AccordionKey = "storage" | "export" | "cleanup" interface DataAccordionProps { - icon: React.ReactNode; - iconTone: "storage" | "export" | "cleanup" | "dashboard"; - label: string; - subtitle: string; - open?: boolean; - disabled?: boolean; - soonTag?: string; - onToggle?: () => void; - children?: React.ReactNode; + icon: React.ReactNode + iconTone: "storage" | "export" | "cleanup" | "dashboard" + label: string + subtitle: string + open?: boolean + disabled?: boolean + soonTag?: string + onToggle?: () => void + children?: React.ReactNode } function getErrorMessage(error: unknown): string { - if (error instanceof Error) return error.message; - return String(error); + if (error instanceof Error) return error.message + return String(error) } function formatBytes(value: number): string { - if (!Number.isFinite(value) || value <= 0) return "0 B"; - const units = ["B", "KB", "MB", "GB", "TB"]; - let size = value; - let unitIndex = 0; + if (!Number.isFinite(value) || value <= 0) return "0 B" + const units = ["B", "KB", "MB", "GB", "TB"] + let size = value + let unitIndex = 0 while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex += 1; + size /= 1024 + unitIndex += 1 } - const digits = size >= 100 || unitIndex === 0 ? 0 : 1; - return `${size.toFixed(digits)} ${units[unitIndex]}`; + const digits = size >= 100 || unitIndex === 0 ? 0 : 1 + return `${size.toFixed(digits)} ${units[unitIndex]}` } function formatDateTime(value: number | null): string { - if (!value || !Number.isFinite(value)) return "—"; + if (!value || !Number.isFinite(value)) return "—" return new Date(value).toLocaleString("en-US", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", - minute: "2-digit", - }); + minute: "2-digit" + }) } function formatCount(value: number | null | undefined): string { - if (typeof value !== "number" || !Number.isFinite(value)) return "—"; - return `${value}`; + if (typeof value !== "number" || !Number.isFinite(value)) return "—" + return `${value}` } function buildLimitLabel(limit: number): string { - if (!Number.isFinite(limit) || limit <= 0) return "Unknown"; - return formatBytes(limit); + if (!Number.isFinite(limit) || limit <= 0) return "Unknown" + return formatBytes(limit) } function triggerDownload(blob: Blob, filename: string): void { - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) } function DataAccordion({ @@ -99,22 +100,22 @@ function DataAccordion({ disabled = false, soonTag, onToggle, - children, + children }: DataAccordionProps) { return (
+ }`}> @@ -134,150 +138,155 @@ function DataAccordion({ ) : null}
- ); + ) } export function DataManagementPanel() { - const [overview, setOverview] = useState(null); - const [overviewLoading, setOverviewLoading] = useState(false); - const [actionKey, setActionKey] = useState(null); - const [detailOpen, setDetailOpen] = useState(false); - const [status, setStatus] = useState("idle"); - const [message, setMessage] = useState(null); + const [overview, setOverview] = useState(null) + const [overviewLoading, setOverviewLoading] = useState(false) + const [actionKey, setActionKey] = useState(null) + const [detailOpen, setDetailOpen] = useState(false) + const [status, setStatus] = useState("idle") + const [message, setMessage] = useState(null) const [openMap, setOpenMap] = useState>({ storage: true, export: false, - cleanup: false, - }); + cleanup: false + }) const refreshOverview = useCallback(async () => { - setOverviewLoading(true); + setOverviewLoading(true) try { - const result = await getDataOverview(); - setOverview(result); + const result = await getDataOverview() + setOverview(result) } catch (error) { - setStatus("error"); - setMessage(getErrorMessage(error)); + setStatus("error") + setMessage(getErrorMessage(error)) } finally { - setOverviewLoading(false); + setOverviewLoading(false) } - }, []); + }, []) useEffect(() => { - void refreshOverview(); - }, [refreshOverview]); + void refreshOverview() + }, [refreshOverview]) const toggleAccordion = (key: AccordionKey) => { setOpenMap((prev) => ({ ...prev, - [key]: !prev[key], - })); - }; + [key]: !prev[key] + })) + } const handleExport = async (format: ExportFormat) => { - setActionKey(`export-${format}`); - setStatus("loading"); - setMessage(null); + setActionKey(`export-${format}`) + setStatus("loading") + setMessage(null) try { - const file = await exportData(format); - triggerDownload(file.blob, file.filename); - setStatus("ready"); - setMessage(`Exported ${file.filename}`); + const file = await exportData(format) + triggerDownload(file.blob, file.filename) + setStatus("ready") + setMessage(`Exported ${file.filename}`) } catch (error) { - setStatus("error"); - setMessage(getErrorMessage(error)); + setStatus("error") + setMessage(getErrorMessage(error)) } finally { - setActionKey(null); - await refreshOverview(); + setActionKey(null) + await refreshOverview() } - }; + } const handleClearInsightsCache = async () => { const confirmed = window.confirm( "Clear cached summaries and weekly reports only?\nConversations and messages will be kept." - ); - if (!confirmed) return; + ) + if (!confirmed) return - setActionKey("clear-insights-cache"); - setStatus("loading"); - setMessage(null); + setActionKey("clear-insights-cache") + setStatus("loading") + setMessage(null) try { - await clearInsightsCache(); - setStatus("ready"); - setMessage("Insights cache cleared. Conversations and messages were kept."); + await clearInsightsCache() + setStatus("ready") + setMessage( + "Insights cache cleared. Conversations and messages were kept." + ) } catch (error) { - setStatus("error"); - setMessage(getErrorMessage(error)); + setStatus("error") + setMessage(getErrorMessage(error)) } finally { - setActionKey(null); - await refreshOverview(); + setActionKey(null) + await refreshOverview() } - }; + } const handleClearAllData = async () => { const input = window.prompt( "This will clear all local conversations, messages, summaries, and weekly reports.\nType DELETE to continue:" - ); + ) if (input !== "DELETE") { - setStatus("idle"); - setMessage("Clear cancelled."); - return; + setStatus("idle") + setMessage("Clear cancelled.") + return } - setActionKey("clear-all-data"); - setStatus("loading"); - setMessage(null); + setActionKey("clear-all-data") + setStatus("loading") + setMessage(null) try { - await clearAllData(); - setStatus("ready"); - setMessage("Local data cleared. LLM configuration is kept."); + await clearAllData() + setStatus("ready") + setMessage("Local data cleared. LLM configuration is kept.") } catch (error) { - setStatus("error"); - setMessage(getErrorMessage(error)); + setStatus("error") + setMessage(getErrorMessage(error)) } finally { - setActionKey(null); - await refreshOverview(); + setActionKey(null) + await refreshOverview() } - }; + } - const storage: StorageUsageSnapshot | null = overview?.storage ?? null; - const used = storage?.originUsed ?? 0; - const hardLimit = storage?.hardLimit ?? FALLBACK_HARD_LIMIT; - const softLimit = storage?.softLimit ?? FALLBACK_SOFT_LIMIT; - const usagePercentRaw = hardLimit > 0 ? (used / hardLimit) * 100 : 0; - const usagePercent = Math.min(100, Math.max(used > 0 ? 0.8 : 0, usagePercentRaw)); + const storage: StorageUsageSnapshot | null = overview?.storage ?? null + const used = storage?.originUsed ?? 0 + const hardLimit = storage?.hardLimit ?? FALLBACK_HARD_LIMIT + const softLimit = storage?.softLimit ?? FALLBACK_SOFT_LIMIT + const usagePercentRaw = hardLimit > 0 ? (used / hardLimit) * 100 : 0 + const usagePercent = Math.min( + 100, + Math.max(used > 0 ? 0.8 : 0, usagePercentRaw) + ) const statusTone = useMemo(() => { if (!storage || storage.status === "ok") { - return { label: "Healthy", cls: "is-healthy" }; + return { label: "Healthy", cls: "is-healthy" } } if (storage.status === "warning") { - return { label: "Soft limit warning", cls: "is-warning" }; + return { label: "Soft limit warning", cls: "is-warning" } } - return { label: "Write blocked", cls: "is-danger" }; - }, [storage]); + return { label: "Write blocked", cls: "is-danger" } + }, [storage]) const exportOptions: Array<{ - format: ExportFormat; - name: string; - description: string; + format: ExportFormat + name: string + description: string }> = [ { format: "json", name: "JSON", - description: "Reversible - includes summaries and weekly caches", + description: "Reversible - includes summaries and weekly caches" }, { format: "txt", name: "TXT", - description: "Human-readable plain text export", + description: "Human-readable plain text export" }, { format: "md", name: "MD", - description: "Markdown - compatible with Obsidian and notes tools", - }, - ]; + description: "Markdown - compatible with Obsidian and notes tools" + } + ] return (
@@ -288,8 +297,7 @@ export function DataManagementPanel() { label="Storage" subtitle="Used space, quota, and browser details" open={openMap.storage} - onToggle={() => toggleAccordion("storage")} - > + onToggle={() => toggleAccordion("storage")}>
Used / App limit (1GB) @@ -298,13 +306,18 @@ export function DataManagementPanel() {
-
+
Browser quota - {storage?.originQuota ? formatBytes(storage.originQuota) : "Unknown"} + {storage?.originQuota + ? formatBytes(storage.originQuota) + : "Unknown"} {statusTone.label} @@ -319,7 +332,8 @@ export function DataManagementPanel() { ) : null} {storage?.status === "blocked" ? (

- Storage reached 1GB. New writes are blocked until you export or clear data. + Storage reached 1GB. New writes are blocked until you export or + clear data.

) : null} @@ -328,9 +342,11 @@ export function DataManagementPanel() { @@ -355,13 +371,27 @@ export function DataManagementPanel() { IndexedDB store {overview?.indexedDbName ?? "MemoryHubDB"}
+
+ Read-model engine + {overview?.storageEngine.activeEngine ?? "dexie"} +
+
+ Migration state + {overview?.storageEngine.migrationState ?? "idle"} +
Last compaction {formatDateTime(overview?.lastCompactionAt ?? null)}
+ {overview?.storageEngine.lastError ? ( +

+ SQLite read-model fallback active:{" "} + {overview.storageEngine.lastError} +

+ ) : null}

- Compacted metrics currently use summary cache proxy and can be upgraded - to strict Agent A compaction lineage later. + Compacted metrics currently use summary cache proxy and can be + upgraded to strict Agent A compaction lineage later.

Soft limit @@ -375,7 +405,9 @@ export function DataManagementPanel() { Estimated IndexedDB + other {formatBytes( - storage ? Math.max(storage.originUsed - storage.localUsed, 0) : 0 + storage + ? Math.max(storage.originUsed - storage.localUsed, 0) + : 0 )}
@@ -389,12 +421,11 @@ export function DataManagementPanel() { label="Export" subtitle="Download data in JSON, TXT, or MD" open={openMap.export} - onToggle={() => toggleAccordion("export")} - > + onToggle={() => toggleAccordion("export")}>

Export format

{exportOptions.map((item) => { - const busy = actionKey === `export-${item.format}`; + const busy = actionKey === `export-${item.format}` return (
@@ -405,17 +436,19 @@ export function DataManagementPanel() { type="button" className="data-export-btn" disabled={Boolean(actionKey)} - onClick={() => handleExport(item.format)} - > + onClick={() => handleExport(item.format)}> {busy ? ( - + ) : ( )} Export
- ); + ) })}
@@ -426,8 +459,7 @@ export function DataManagementPanel() { label="Cleanup" subtitle="Remove summary cache or wipe all local data" open={openMap.cleanup} - onToggle={() => toggleAccordion("cleanup")} - > + onToggle={() => toggleAccordion("cleanup")}>

Insights cache

@@ -435,10 +467,12 @@ export function DataManagementPanel() { type="button" className="data-secondary-btn" disabled={Boolean(actionKey)} - onClick={handleClearInsightsCache} - > + onClick={handleClearInsightsCache}> {actionKey === "clear-insights-cache" ? ( - + ) : ( )} @@ -464,8 +498,7 @@ export function DataManagementPanel() { type="button" className="data-danger-btn" disabled={Boolean(actionKey)} - onClick={handleClearAllData} - > + onClick={handleClearAllData}> {actionKey === "clear-all-data" ? ( ) : ( @@ -494,10 +527,11 @@ export function DataManagementPanel() { )} {message && !overviewLoading && ( -

+

{message}

)}
- ); + ) } diff --git a/frontend/src/sidepanel/containers/ConversationList.tsx b/frontend/src/sidepanel/containers/ConversationList.tsx index 9e54bbf..6eedb1c 100644 --- a/frontend/src/sidepanel/containers/ConversationList.tsx +++ b/frontend/src/sidepanel/containers/ConversationList.tsx @@ -1,146 +1,165 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { - Conversation, - ConversationMatchSummary, - Message, - Platform, - Topic, -} from "~lib/types"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react" + import { getConversationCaptureFreshnessAt, getConversationFirstCapturedAt, getConversationOriginAt, - getConversationSourceCreatedAt, -} from "~lib/conversations/timestamps"; + getConversationSourceCreatedAt +} from "~lib/conversations/timestamps" import { deleteConversation, getConversations, getMessages, getTopics, searchConversationMatchesByText, - updateConversationTitle, -} from "~lib/services/storageService"; -import { trackCardActionClick } from "~lib/services/telemetry"; -import type { DatePreset } from "../types/timelineFilters"; -import { ConversationCard } from "../components/ConversationCard"; -import { SearchLineIcon, SearchSlashIcon } from "../components/ThreadSearchIcons"; + updateConversationTitle +} from "~lib/services/storageService" +import { trackCardActionClick } from "~lib/services/telemetry" +import type { + Conversation, + ConversationMatchSummary, + Message, + Platform, + Topic +} from "~lib/types" + +import { ConversationCard } from "../components/ConversationCard" +import { + SearchLineIcon, + SearchSlashIcon +} from "../components/ThreadSearchIcons" +import type { DatePreset } from "../types/timelineFilters" interface ConversationListProps { - searchQuery: string; - datePreset: DatePreset; - selectedPlatforms: Set; - onSelect: (conversation: Conversation) => void; - refreshToken: number; - resultSummaryMap: Record; - onResultSummaryMapChange: (next: Record) => void; - anchorConversationId?: number | null; - onAnchorConsumed?: () => void; - onBodySearchStarted?: () => void; - onBodySearchResolved?: (summaries: ConversationMatchSummary[]) => void; + searchQuery: string + datePreset: DatePreset + selectedPlatforms: Set + onSelect: (conversation: Conversation) => void + refreshToken: number + resultSummaryMap: Record + onResultSummaryMapChange: ( + next: Record + ) => void + anchorConversationId?: number | null + onAnchorConsumed?: () => void + onBodySearchStarted?: () => void + onBodySearchResolved?: (summaries: ConversationMatchSummary[]) => void // Batch selection support - isBatchMode?: boolean; - selectedIds?: Set; - onToggleSelection?: (id: number) => void; - onSelectFromMenu?: (id: number) => void; - onFilteredConversationsChange?: (conversations: Conversation[]) => void; - onConversationsLoaded?: (conversations: Conversation[]) => void; - bottomInsetPx?: number; + isBatchMode?: boolean + selectedIds?: Set + onToggleSelection?: (id: number) => void + onSelectFromMenu?: (id: number) => void + onFilteredConversationsChange?: (conversations: Conversation[]) => void + onConversationsLoaded?: (conversations: Conversation[]) => void + bottomInsetPx?: number } interface FilteredConversationItem { - conversation: Conversation; - matchedInMessagesOnly: boolean; - summary?: ConversationMatchSummary; + conversation: Conversation + matchedInMessagesOnly: boolean + summary?: ConversationMatchSummary } function pad2(value: number): string { - return String(value).padStart(2, "0"); + return String(value).padStart(2, "0") } function toLocalDateTime(value: number): string { - const date = new Date(value); - const yyyy = date.getFullYear(); - const mm = pad2(date.getMonth() + 1); - const dd = pad2(date.getDate()); - const hh = pad2(date.getHours()); - const mi = pad2(date.getMinutes()); - const ss = pad2(date.getSeconds()); - return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`; + const date = new Date(value) + const yyyy = date.getFullYear() + const mm = pad2(date.getMonth() + 1) + const dd = pad2(date.getDate()) + const hh = pad2(date.getHours()) + const mi = pad2(date.getMinutes()) + const ss = pad2(date.getSeconds()) + return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}` } function buildConversationCopyText( conversation: Conversation, messages: Message[] ): string { - const lines: string[] = []; - lines.push(`# ${conversation.title || "Untitled Conversation"}`); - lines.push(`Platform: ${conversation.platform}`); - lines.push(`Source URL: ${conversation.url || "N/A"}`); - lines.push(`Started At: ${toLocalDateTime(getConversationOriginAt(conversation))}`); - const sourceCreatedAt = getConversationSourceCreatedAt(conversation); + const lines: string[] = [] + lines.push(`# ${conversation.title || "Untitled Conversation"}`) + lines.push(`Platform: ${conversation.platform}`) + lines.push(`Source URL: ${conversation.url || "N/A"}`) + lines.push( + `Started At: ${toLocalDateTime(getConversationOriginAt(conversation))}` + ) + const sourceCreatedAt = getConversationSourceCreatedAt(conversation) if (sourceCreatedAt !== null) { - lines.push(`Source Time: ${toLocalDateTime(sourceCreatedAt)}`); + lines.push(`Source Time: ${toLocalDateTime(sourceCreatedAt)}`) } lines.push( `First Captured At: ${toLocalDateTime( getConversationFirstCapturedAt(conversation) )}` - ); + ) lines.push( `Last Captured At: ${toLocalDateTime( getConversationCaptureFreshnessAt(conversation) )}` - ); - lines.push(`Last Modified: ${toLocalDateTime(conversation.updated_at)}`); - lines.push(`Message Count: ${messages.length}`); - lines.push(""); + ) + lines.push(`Last Modified: ${toLocalDateTime(conversation.updated_at)}`) + lines.push(`Message Count: ${messages.length}`) + lines.push("") for (const message of messages) { - const role = message.role === "user" ? "User" : "AI"; - lines.push(`${role}: [${toLocalDateTime(message.created_at)}]`); - lines.push(message.content_text); - lines.push(""); + const role = message.role === "user" ? "User" : "AI" + lines.push(`${role}: [${toLocalDateTime(message.created_at)}]`) + lines.push(message.content_text) + lines.push("") } - return lines.join("\n").trim(); + return lines.join("\n").trim() } -function matchesSearch(conversation: Conversation, normalizedQuery: string): boolean { - if (!normalizedQuery) return true; +function matchesSearch( + conversation: Conversation, + normalizedQuery: string +): boolean { + if (!normalizedQuery) return true return ( conversation.title.toLowerCase().includes(normalizedQuery) || conversation.snippet.toLowerCase().includes(normalizedQuery) - ); + ) } -function matchesDatePreset(timestamp: number, preset: DatePreset): boolean { - if (preset === "all_time") return true; +function buildDateRangeForPreset( + preset: DatePreset +): { start: number; end: number } | undefined { + if (preset === "all_time") { + return undefined + } - const now = new Date(); + const now = Date.now() + const current = new Date(now) const startOfToday = new Date( - now.getFullYear(), - now.getMonth(), - now.getDate() - ).getTime(); + current.getFullYear(), + current.getMonth(), + current.getDate() + ).getTime() if (preset === "today") { - return timestamp >= startOfToday; + return { start: startOfToday, end: now } } if (preset === "this_week") { - const day = new Date(startOfToday).getDay(); - const offset = (day + 6) % 7; // Monday as week start - const startOfWeek = startOfToday - offset * 24 * 60 * 60 * 1000; - return timestamp >= startOfWeek; + const day = new Date(startOfToday).getDay() + const offset = (day + 6) % 7 + const startOfWeek = startOfToday - offset * 24 * 60 * 60 * 1000 + return { start: startOfWeek, end: now } } - const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).getTime(); - return timestamp >= startOfMonth; + return { + start: new Date(current.getFullYear(), current.getMonth(), 1).getTime(), + end: now + } } interface TopicOption { - id: number; - label: string; + id: number + label: string } function flattenTopics( @@ -149,13 +168,13 @@ function flattenTopics( acc: TopicOption[] = [] ): TopicOption[] { for (const topic of topics) { - const prefix = level > 0 ? `${"- ".repeat(level)}` : ""; - acc.push({ id: topic.id, label: `${prefix}${topic.name}` }); + const prefix = level > 0 ? `${"- ".repeat(level)}` : "" + acc.push({ id: topic.id, label: `${prefix}${topic.name}` }) if (topic.children && topic.children.length > 0) { - flattenTopics(topic.children, level + 1, acc); + flattenTopics(topic.children, level + 1, acc) } } - return acc; + return acc } export function ConversationList({ @@ -176,313 +195,316 @@ export function ConversationList({ onSelectFromMenu, onFilteredConversationsChange, onConversationsLoaded, - bottomInsetPx = 16, + bottomInsetPx = 16 }: ConversationListProps) { - const [conversations, setConversations] = useState([]); - const [topics, setTopics] = useState([]); - const [loading, setLoading] = useState(true); - const [isMessageSearchPending, setIsMessageSearchPending] = useState(false); - const fullTextCacheRef = useRef>(new Map()); - const queryCacheRef = useRef>>( - new Map() - ); - const searchRequestSeqRef = useRef(0); - const searchDebounceRef = useRef(null); - const listContainerRef = useRef(null); - const lastAnchorRef = useRef(null); - const normalizedSearchQuery = searchQuery.trim().toLowerCase(); + const [conversations, setConversations] = useState([]) + const [topics, setTopics] = useState([]) + const [loading, setLoading] = useState(true) + const [isMessageSearchPending, setIsMessageSearchPending] = useState(false) + const fullTextCacheRef = useRef>(new Map()) + const queryCacheRef = useRef< + Map> + >(new Map()) + const searchRequestSeqRef = useRef(0) + const searchDebounceRef = useRef(null) + const listContainerRef = useRef(null) + const lastAnchorRef = useRef(null) + const normalizedSearchQuery = searchQuery.trim().toLowerCase() const filterKey = useMemo(() => { - const platforms = Array.from(selectedPlatforms).sort().join(","); - return `${datePreset}|${platforms}`; - }, [datePreset, selectedPlatforms]); + const platforms = Array.from(selectedPlatforms).sort().join(",") + return `${datePreset}|${platforms}` + }, [datePreset, selectedPlatforms]) + const remoteDateRange = useMemo( + () => buildDateRangeForPreset(datePreset), + [datePreset] + ) + const remotePlatforms = useMemo(() => { + if (selectedPlatforms.size === 0) { + return undefined + } + + return Array.from(selectedPlatforms).sort() + }, [selectedPlatforms]) + const hasListFilters = datePreset !== "all_time" || selectedPlatforms.size > 0 const candidateConversationIds = useMemo(() => { - if (!conversations.length) return []; - return conversations - .filter((conversation) => { - if (!matchesDatePreset(getConversationOriginAt(conversation), datePreset)) { - return false; - } - if (selectedPlatforms.size > 0 && !selectedPlatforms.has(conversation.platform)) { - return false; - } - return true; - }) - .map((conversation) => conversation.id); - }, [conversations, datePreset, selectedPlatforms]); + if (!conversations.length) return [] + return conversations.map((conversation) => conversation.id) + }, [conversations]) useEffect(() => { - let cancelled = false; - setLoading(true); - queryCacheRef.current.clear(); - onResultSummaryMapChange({}); - getConversations() + let cancelled = false + setLoading(true) + queryCacheRef.current.clear() + onResultSummaryMapChange({}) + getConversations({ + dateRange: remoteDateRange, + platforms: remotePlatforms + }) .then((data) => { if (!cancelled) { - setConversations(data); - setLoading(false); - onConversationsLoaded?.(data); + setConversations(data) + setLoading(false) + onConversationsLoaded?.(data) } }) .catch(() => { if (!cancelled) { - setConversations([]); - setLoading(false); - onConversationsLoaded?.([]); + setConversations([]) + setLoading(false) + onConversationsLoaded?.([]) } - }); + }) return () => { - cancelled = true; - }; - }, [refreshToken, onConversationsLoaded]); + cancelled = true + } + }, [ + refreshToken, + remoteDateRange, + remotePlatforms, + onConversationsLoaded, + onResultSummaryMapChange + ]) useEffect(() => { - const requestSeq = searchRequestSeqRef.current + 1; - searchRequestSeqRef.current = requestSeq; + const requestSeq = searchRequestSeqRef.current + 1 + searchRequestSeqRef.current = requestSeq if (searchDebounceRef.current !== null) { - window.clearTimeout(searchDebounceRef.current); - searchDebounceRef.current = null; + window.clearTimeout(searchDebounceRef.current) + searchDebounceRef.current = null } if (normalizedSearchQuery.length < 2) { - setIsMessageSearchPending(false); - onResultSummaryMapChange({}); - return; + setIsMessageSearchPending(false) + onResultSummaryMapChange({}) + return } - const cacheKey = `${normalizedSearchQuery}::${filterKey}`; - const cached = queryCacheRef.current.get(cacheKey); + const cacheKey = `${normalizedSearchQuery}::${filterKey}` + const cached = queryCacheRef.current.get(cacheKey) if (cached) { - setIsMessageSearchPending(false); - onResultSummaryMapChange(cached); - onBodySearchResolved?.(Object.values(cached)); - return; + setIsMessageSearchPending(false) + onResultSummaryMapChange(cached) + onBodySearchResolved?.(Object.values(cached)) + return } if (candidateConversationIds.length === 0) { - setIsMessageSearchPending(false); - onResultSummaryMapChange({}); - onBodySearchResolved?.([]); - return; + setIsMessageSearchPending(false) + onResultSummaryMapChange({}) + onBodySearchResolved?.([]) + return } - setIsMessageSearchPending(true); - onBodySearchStarted?.(); + setIsMessageSearchPending(true) + onBodySearchStarted?.() searchDebounceRef.current = window.setTimeout(() => { void (async () => { try { const summaries = await searchConversationMatchesByText({ query: normalizedSearchQuery, - conversationIds: candidateConversationIds, - }); + conversationIds: candidateConversationIds + }) if (requestSeq !== searchRequestSeqRef.current) { - return; + return } - const summaryMap: Record = {}; + const summaryMap: Record = {} for (const summary of summaries) { - summaryMap[summary.conversationId] = summary; + summaryMap[summary.conversationId] = summary } - queryCacheRef.current.set(cacheKey, summaryMap); - onResultSummaryMapChange(summaryMap); - onBodySearchResolved?.(summaries); + queryCacheRef.current.set(cacheKey, summaryMap) + onResultSummaryMapChange(summaryMap) + onBodySearchResolved?.(summaries) } catch { if (requestSeq !== searchRequestSeqRef.current) { - return; + return } - onResultSummaryMapChange({}); - onBodySearchResolved?.([]); + onResultSummaryMapChange({}) + onBodySearchResolved?.([]) } finally { if (requestSeq === searchRequestSeqRef.current) { - setIsMessageSearchPending(false); + setIsMessageSearchPending(false) } } - })(); - }, 180); + })() + }, 180) return () => { if (searchDebounceRef.current !== null) { - window.clearTimeout(searchDebounceRef.current); - searchDebounceRef.current = null; + window.clearTimeout(searchDebounceRef.current) + searchDebounceRef.current = null } - }; + } }, [ candidateConversationIds, filterKey, normalizedSearchQuery, - onResultSummaryMapChange, - ]); + onBodySearchResolved, + onBodySearchStarted, + onResultSummaryMapChange + ]) const filteredConversations = useMemo(() => { - const convs = conversations ?? []; + const convs = conversations ?? [] return convs.reduce((acc, conversation) => { - const baseMatch = matchesSearch(conversation, normalizedSearchQuery); + const baseMatch = matchesSearch(conversation, normalizedSearchQuery) const summary = normalizedSearchQuery.length >= 2 ? resultSummaryMap[conversation.id] - : undefined; - const textMatch = Boolean(summary); + : undefined + const textMatch = Boolean(summary) const matchesQuery = - normalizedSearchQuery.length === 0 ? true : baseMatch || textMatch; - if (!matchesQuery) return acc; - if (!matchesDatePreset(getConversationOriginAt(conversation), datePreset)) { - return acc; - } - if (selectedPlatforms.size > 0 && !selectedPlatforms.has(conversation.platform)) { - return acc; - } + normalizedSearchQuery.length === 0 ? true : baseMatch || textMatch + if (!matchesQuery) return acc acc.push({ conversation, matchedInMessagesOnly: textMatch && !baseMatch, - summary, - }); - return acc; - }, []); - }, [ - conversations, - datePreset, - normalizedSearchQuery, - resultSummaryMap, - selectedPlatforms, - ]); + summary + }) + return acc + }, []) + }, [conversations, normalizedSearchQuery, resultSummaryMap]) useEffect(() => { - let cancelled = false; + let cancelled = false getTopics() .then((data) => { if (!cancelled) { - setTopics(data); + setTopics(data) } }) .catch(() => { if (!cancelled) { - setTopics([]); + setTopics([]) } - }); + }) return () => { - cancelled = true; - }; - }, [refreshToken]); + cancelled = true + } + }, [refreshToken]) - const topicOptions = useMemo(() => flattenTopics(topics), [topics]); + const topicOptions = useMemo(() => flattenTopics(topics), [topics]) useEffect(() => { onFilteredConversationsChange?.( filteredConversations.map((item) => item.conversation) - ); - }, [filteredConversations, onFilteredConversationsChange]); + ) + }, [filteredConversations, onFilteredConversationsChange]) const grouped = useMemo(() => { - const now = Date.now(); - const today: FilteredConversationItem[] = []; - const week: FilteredConversationItem[] = []; - const older: FilteredConversationItem[] = []; + const now = Date.now() + const today: FilteredConversationItem[] = [] + const week: FilteredConversationItem[] = [] + const older: FilteredConversationItem[] = [] for (const item of filteredConversations) { - const diff = now - getConversationOriginAt(item.conversation); - if (diff < 86_400_000) today.push(item); - else if (diff < 604_800_000) week.push(item); - else older.push(item); + const diff = now - getConversationOriginAt(item.conversation) + if (diff < 86_400_000) today.push(item) + else if (diff < 604_800_000) week.push(item) + else older.push(item) } - const groups: { label: string; items: FilteredConversationItem[] }[] = []; - if (today.length > 0) groups.push({ label: "Started Today", items: today }); - if (week.length > 0) groups.push({ label: "Started This Week", items: week }); - if (older.length > 0) groups.push({ label: "Started Earlier", items: older }); - return groups; - }, [filteredConversations]); + const groups: { label: string; items: FilteredConversationItem[] }[] = [] + if (today.length > 0) groups.push({ label: "Started Today", items: today }) + if (week.length > 0) + groups.push({ label: "Started This Week", items: week }) + if (older.length > 0) + groups.push({ label: "Started Earlier", items: older }) + return groups + }, [filteredConversations]) useEffect(() => { - if (!anchorConversationId || loading) return; - if (lastAnchorRef.current === anchorConversationId) return; + if (!anchorConversationId || loading) return + if (lastAnchorRef.current === anchorConversationId) return const target = listContainerRef.current?.querySelector( `[data-conversation-id="${anchorConversationId}"]` - ); + ) if (target instanceof HTMLElement) { - target.scrollIntoView({ block: "nearest", behavior: "smooth" }); - lastAnchorRef.current = anchorConversationId; - onAnchorConsumed?.(); + target.scrollIntoView({ block: "nearest", behavior: "smooth" }) + lastAnchorRef.current = anchorConversationId + onAnchorConsumed?.() } - }, [anchorConversationId, grouped, loading, onAnchorConsumed]); + }, [anchorConversationId, grouped, loading, onAnchorConsumed]) const handleCopyFullText = useCallback(async (conversation: Conversation) => { - const hasCache = fullTextCacheRef.current.has(conversation.id); + const hasCache = fullTextCacheRef.current.has(conversation.id) trackCardActionClick({ action_type: "copy_text", platform_source: conversation.platform, has_full_text_cache: hasCache, - conversation_id: conversation.id, - }); + conversation_id: conversation.id + }) try { - let fullText = fullTextCacheRef.current.get(conversation.id); + let fullText = fullTextCacheRef.current.get(conversation.id) if (!fullText) { - const messages = await getMessages(conversation.id); - fullText = buildConversationCopyText(conversation, messages); - fullTextCacheRef.current.set(conversation.id, fullText); + const messages = await getMessages(conversation.id) + fullText = buildConversationCopyText(conversation, messages) + fullTextCacheRef.current.set(conversation.id, fullText) } - await navigator.clipboard.writeText(fullText); - return true; + await navigator.clipboard.writeText(fullText) + return true } catch { - return false; + return false } - }, []); + }, []) const handleOpenSource = useCallback((conversation: Conversation) => { trackCardActionClick({ action_type: "open_source_url", platform_source: conversation.platform, has_full_text_cache: null, - conversation_id: conversation.id, - }); - if (!conversation.url.trim()) return; - window.open(conversation.url, "_blank", "noopener,noreferrer"); - }, []); + conversation_id: conversation.id + }) + if (!conversation.url.trim()) return + window.open(conversation.url, "_blank", "noopener,noreferrer") + }, []) const handleDeleteConversation = useCallback( async (id: number) => { - const targetConversation = conversations.find((item) => item.id === id); - if (!targetConversation) return; + const targetConversation = conversations.find((item) => item.id === id) + if (!targetConversation) return trackCardActionClick({ action_type: "delete_conversation", platform_source: targetConversation.platform, has_full_text_cache: null, - conversation_id: id, - }); + conversation_id: id + }) - await deleteConversation(id); - fullTextCacheRef.current.delete(id); - setConversations((prev) => prev.filter((item) => item.id !== id)); + await deleteConversation(id) + fullTextCacheRef.current.delete(id) + setConversations((prev) => prev.filter((item) => item.id !== id)) }, [conversations] - ); + ) const handleRenameTitle = useCallback( async (conversationId: number, title: string) => { const targetConversation = conversations.find( (item) => item.id === conversationId - ); - if (!targetConversation) return false; + ) + if (!targetConversation) return false - const normalizedTitle = title.trim(); + const normalizedTitle = title.trim() if (!normalizedTitle || normalizedTitle.length > 120) { - return false; + return false } trackCardActionClick({ action_type: "rename_title", platform_source: targetConversation.platform, has_full_text_cache: null, - conversation_id: conversationId, - }); + conversation_id: conversationId + }) try { const updatedConversation = await updateConversationTitle( conversationId, normalizedTitle - ); - fullTextCacheRef.current.delete(conversationId); + ) + fullTextCacheRef.current.delete(conversationId) setConversations((prev) => prev.map((item) => @@ -490,15 +512,15 @@ export function ConversationList({ ? { ...item, title: updatedConversation.title } : item ) - ); - return true; + ) + return true } catch (error) { - console.error("Failed to rename conversation title", error); - return false; + console.error("Failed to rename conversation title", error) + return false } }, [conversations] - ); + ) const handleConversationUpdated = useCallback( (updatedConversation: Conversation) => { @@ -507,26 +529,26 @@ export function ConversationList({ item.id === updatedConversation.id ? { ...item, ...updatedConversation } : item - ); + ) next = next.sort( (a, b) => getConversationOriginAt(b) - getConversationOriginAt(a) - ); + ) if (!normalizedSearchQuery) { - return next; + return next } return next.filter((item) => { - const baseMatch = matchesSearch(item, normalizedSearchQuery); + const baseMatch = matchesSearch(item, normalizedSearchQuery) const textMatch = - normalizedSearchQuery.length >= 2 && resultSummaryMap[item.id]; - return baseMatch || Boolean(textMatch); - }); - }); + normalizedSearchQuery.length >= 2 && resultSummaryMap[item.id] + return baseMatch || Boolean(textMatch) + }) + }) }, [normalizedSearchQuery, resultSummaryMap] - ); + ) if (loading) { return ( @@ -538,19 +560,32 @@ export function ConversationList({ /> ))}
- ); + ) } if (conversations.length === 0) { + if (hasListFilters || normalizedSearchQuery.length > 0) { + return ( +
+
+ + No matches +
+
+ ) + } + return (

No conversations yet

- ); + ) } if (filteredConversations.length === 0) { - const emptyLabel = isMessageSearchPending ? "Searching messages..." : "No matches"; + const emptyLabel = isMessageSearchPending + ? "Searching messages..." + : "No matches" return (
@@ -562,15 +597,14 @@ export function ConversationList({ {emptyLabel}
- ); + ) } return (
+ style={{ paddingBottom: `${bottomInsetPx}px` }}> {grouped.map((group) => (

@@ -584,7 +618,9 @@ export function ConversationList({ matchedInMessagesOnly={item.matchedInMessagesOnly} searchQuery={searchQuery} messageExcerpt={ - item.matchedInMessagesOnly ? item.summary?.bestExcerpt ?? null : null + item.matchedInMessagesOnly + ? item.summary?.bestExcerpt ?? null + : null } onClick={() => onSelect(item.conversation)} onCopyFullText={handleCopyFullText} @@ -597,17 +633,14 @@ export function ConversationList({ isBatchMode={isBatchMode} isSelected={selectedIds.has(item.conversation.id)} onToggleSelect={() => onToggleSelection?.(item.conversation.id)} - onSelectFromMenu={() => onSelectFromMenu?.(item.conversation.id)} + onSelectFromMenu={() => + onSelectFromMenu?.(item.conversation.id) + } /> ))}

))}
- ); + ) } - - - - - diff --git a/frontend/src/sidepanel/pages/InsightsPage.tsx b/frontend/src/sidepanel/pages/InsightsPage.tsx index 7d070b5..6a68cf5 100644 --- a/frontend/src/sidepanel/pages/InsightsPage.tsx +++ b/frontend/src/sidepanel/pages/InsightsPage.tsx @@ -1,4 +1,3 @@ -import { useEffect, useMemo, useRef, useState } from "react"; import { CalendarDays, Compass, @@ -7,59 +6,62 @@ import { Loader2, Network, Pause, - Play, -} from "lucide-react"; -import type { - AsyncStatus, - Conversation, - Platform, - SummaryRecord, - WeeklyReportRecord, -} from "~lib/types"; + Play +} from "lucide-react" +import { useEffect, useMemo, useRef, useState } from "react" + +import { resolveTurnCount } from "~lib/capture/turn-metrics" +import { getConversationOriginAt } from "~lib/conversations/timestamps" import type { InsightPipelineProgressPayload, InsightPipelineStage, - InsightPipelineStatus, -} from "~lib/messaging/protocol"; + InsightPipelineStatus +} from "~lib/messaging/protocol" +import { + toChatSummaryData, + toWeeklySummaryData +} from "~lib/services/insightAdapter" import { generateConversationSummary, generateWeeklyReport, - getConversationSummary, getConversations, - getWeeklyReport, -} from "~lib/services/storageService"; -import { resolveTurnCount } from "~lib/capture/turn-metrics"; -import { - toChatSummaryData, - toWeeklySummaryData, -} from "~lib/services/insightAdapter"; -import { getConversationOriginAt } from "~lib/conversations/timestamps"; -import type { WeeklySummaryData } from "~lib/types/insightsPresentation"; -import { InsightsAccordionItem } from "../components/InsightsAccordionItem"; -import { InsightsWandIcon } from "../components/InsightsWandIcon"; + getConversationSummary, + getWeeklyReport +} from "~lib/services/storageService" +import type { + AsyncStatus, + Conversation, + Platform, + SummaryRecord, + WeeklyReportRecord +} from "~lib/types" +import type { WeeklySummaryData } from "~lib/types/insightsPresentation" + +import { InsightsAccordionItem } from "../components/InsightsAccordionItem" +import { InsightsWandIcon } from "../components/InsightsWandIcon" -const COLLAPSE_AT = 3; -const WEEKLY_DIGEST_SOON = true; +const COLLAPSE_AT = 3 +const WEEKLY_DIGEST_SOON = true type WeeklyDigestUiState = | "idle" | "generating" | "ready" | "sparse_week" - | "error"; + | "error" -type WeeklyStableUiState = "idle" | "ready" | "sparse_week"; +type WeeklyStableUiState = "idle" | "ready" | "sparse_week" -type WeeklyRangeMode = "last_7_days" | "last_full_week"; +type WeeklyRangeMode = "last_7_days" | "last_full_week" -type WeeklySparseReason = "sub3" | "semantic_degraded"; +type WeeklySparseReason = "sub3" | "semantic_degraded" type WeeklyGenerationPhase = | "ready_to_compile" | "loading_thread_summaries" | "pattern_detection" | "cross_domain_mapping" - | "composing_and_persisting"; + | "composing_and_persisting" type ThreadSummaryUiState = | "no_thread" @@ -68,15 +70,15 @@ type ThreadSummaryUiState = | "selected_error" | "ready" | "ready_loading" - | "ready_error"; + | "ready_error" interface WeeklyPhaseDefinition { - phase: Exclude; - status: string; - label: string; - sublabel: string; - minDurationMs: number; - hint: string; + phase: Exclude + status: string + label: string + sublabel: string + minDurationMs: number + hint: string } const WEEKLY_PHASES: WeeklyPhaseDefinition[] = [ @@ -86,7 +88,7 @@ const WEEKLY_PHASES: WeeklyPhaseDefinition[] = [ label: "Loading thread summaries", sublabel: "Reading stored summaries for the selected week", minDurationMs: 12000, - hint: "~12s", + hint: "~12s" }, { phase: "pattern_detection", @@ -94,7 +96,7 @@ const WEEKLY_PHASES: WeeklyPhaseDefinition[] = [ label: "Pattern detection", sublabel: "Cross-thread frequency and recurrence analysis", minDurationMs: 14000, - hint: "~14s", + hint: "~14s" }, { phase: "cross_domain_mapping", @@ -102,7 +104,7 @@ const WEEKLY_PHASES: WeeklyPhaseDefinition[] = [ label: "Cross-domain mapping", sublabel: "Structural isomorphism detection", minDurationMs: 15000, - hint: "~15s", + hint: "~15s" }, { phase: "composing_and_persisting", @@ -110,16 +112,16 @@ const WEEKLY_PHASES: WeeklyPhaseDefinition[] = [ label: "Composing and persisting", sublabel: "Digest composition and persistence", minDurationMs: 10000, - hint: "~10s", - }, -]; + hint: "~10s" + } +] interface ThreadPhaseDefinition { - status: string; - label: string; - sublabel: string; - hint: string; - maxElapsedMs: number; + status: string + label: string + sublabel: string + hint: string + maxElapsedMs: number } const THREAD_PHASES: ThreadPhaseDefinition[] = [ @@ -128,184 +130,187 @@ const THREAD_PHASES: ThreadPhaseDefinition[] = [ label: "Initialising pipeline", sublabel: "Checking cache and waking context window", hint: "~12s", - maxElapsedMs: 12000, + maxElapsedMs: 12000 }, { status: "Distilling core logic...", label: "Distilling logic", sublabel: "Tracing what changed across turns", hint: "~14s", - maxElapsedMs: 26000, + maxElapsedMs: 26000 }, { status: "Curating structured summary...", label: "Curating summary", sublabel: "Building journey steps and insight glossary", hint: "~15s", - maxElapsedMs: 41000, + maxElapsedMs: 41000 }, { status: "Finalising and persisting...", label: "Finalising artefacts", sublabel: "Writing storage record and refreshing card", hint: "~10s", - maxElapsedMs: Number.POSITIVE_INFINITY, - }, -]; + maxElapsedMs: Number.POSITIVE_INFINITY + } +] type ThreadTrackedStage = | "initiating_pipeline" | "distilling_core_logic" | "curating_summary" - | "persisting_result"; + | "persisting_result" const THREAD_TRACKED_STAGE_ORDER: ThreadTrackedStage[] = [ "initiating_pipeline", "distilling_core_logic", "curating_summary", - "persisting_result", -]; + "persisting_result" +] interface ThreadPipelineTimingState { - pipelineId: string; - stageStarts: Partial>; - stageDurationsMs: Partial>; - activeStage: ThreadTrackedStage | null; - terminal: boolean; + pipelineId: string + stageStarts: Partial> + stageDurationsMs: Partial> + activeStage: ThreadTrackedStage | null + terminal: boolean } function getErrorMessage(error: unknown): string { if (error instanceof Error) { if (error.message.includes("STORAGE_HARD_LIMIT_REACHED")) { - return "Storage limit reached (1GB). Export or clear data in the Data tab."; + return "Storage limit reached (1GB). Export or clear data in the Data tab." } - return error.message; + return error.message } - return String(error); + return String(error) } function formatDateTime(ts: number): string { - const d = new Date(ts); + const d = new Date(ts) return d.toLocaleString("en-US", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", - minute: "2-digit", - }); + minute: "2-digit" + }) } function getPreviousNaturalWeekRangeLocal(referenceDate = new Date()): { - rangeStart: number; - rangeEnd: number; + rangeStart: number + rangeEnd: number } { - const cursor = new Date(referenceDate); - const localDay = cursor.getDay(); - const daysSinceMonday = (localDay + 6) % 7; + const cursor = new Date(referenceDate) + const localDay = cursor.getDay() + const daysSinceMonday = (localDay + 6) % 7 - const currentWeekMonday = new Date(cursor); - currentWeekMonday.setHours(0, 0, 0, 0); - currentWeekMonday.setDate(currentWeekMonday.getDate() - daysSinceMonday); + const currentWeekMonday = new Date(cursor) + currentWeekMonday.setHours(0, 0, 0, 0) + currentWeekMonday.setDate(currentWeekMonday.getDate() - daysSinceMonday) - const previousWeekMonday = new Date(currentWeekMonday); - previousWeekMonday.setDate(previousWeekMonday.getDate() - 7); + const previousWeekMonday = new Date(currentWeekMonday) + previousWeekMonday.setDate(previousWeekMonday.getDate() - 7) - const previousWeekSunday = new Date(previousWeekMonday); - previousWeekSunday.setDate(previousWeekSunday.getDate() + 6); - previousWeekSunday.setHours(23, 59, 59, 999); + const previousWeekSunday = new Date(previousWeekMonday) + previousWeekSunday.setDate(previousWeekSunday.getDate() + 6) + previousWeekSunday.setHours(23, 59, 59, 999) return { rangeStart: previousWeekMonday.getTime(), - rangeEnd: previousWeekSunday.getTime(), - }; + rangeEnd: previousWeekSunday.getTime() + } } function getLastSevenDaysRangeLocal(referenceDate = new Date()): { - rangeStart: number; - rangeEnd: number; + rangeStart: number + rangeEnd: number } { - const end = new Date(referenceDate); - end.setHours(23, 59, 59, 999); + const end = new Date(referenceDate) + end.setHours(23, 59, 59, 999) - const start = new Date(end); - start.setDate(start.getDate() - 6); - start.setHours(0, 0, 0, 0); + const start = new Date(end) + start.setDate(start.getDate() - 6) + start.setHours(0, 0, 0, 0) return { rangeStart: start.getTime(), - rangeEnd: end.getTime(), - }; + rangeEnd: end.getTime() + } } function formatWeekRangeLabel(rangeStart: number, rangeEnd: number): string { - const start = new Date(rangeStart); - const end = new Date(rangeEnd); - const sameYear = start.getFullYear() === end.getFullYear(); + const start = new Date(rangeStart) + const end = new Date(rangeEnd) + const sameYear = start.getFullYear() === end.getFullYear() const startText = start.toLocaleDateString("en-US", { month: "short", - day: "numeric", - }); + day: "numeric" + }) const endText = end.toLocaleDateString("en-US", { month: "short", - day: "numeric", - }); + day: "numeric" + }) if (sameYear) { - return `${startText} - ${endText}, ${end.getFullYear()}`; + return `${startText} - ${endText}, ${end.getFullYear()}` } const startWithYear = start.toLocaleDateString("en-US", { month: "short", day: "numeric", - year: "numeric", - }); + year: "numeric" + }) const endWithYear = end.toLocaleDateString("en-US", { month: "short", day: "numeric", - year: "numeric", - }); + year: "numeric" + }) - return `${startWithYear} - ${endWithYear}`; + return `${startWithYear} - ${endWithYear}` } function formatTimer(elapsedMs: number): string { - const totalSeconds = Math.floor(elapsedMs / 1000); - const mins = Math.floor(totalSeconds / 60); - const secs = totalSeconds % 60; - return `${mins}:${String(secs).padStart(2, "0")}`; + const totalSeconds = Math.floor(elapsedMs / 1000) + const mins = Math.floor(totalSeconds / 60) + const secs = totalSeconds % 60 + return `${mins}:${String(secs).padStart(2, "0")}` } function parsePlainTextLines(text?: string): string[] { - if (!text) return []; + if (!text) return [] return text .split(/\r?\n/) .map((line) => line.trim()) - .filter((line) => line.length > 0); + .filter((line) => line.length > 0) } function normalizeJourneyCopy(value: string): string { - return value.replace(/[\u200B-\u200D\uFEFF]/g, "").replace(/\s+/g, " ").trim(); + return value + .replace(/[\u200B-\u200D\uFEFF]/g, "") + .replace(/\s+/g, " ") + .trim() } function hasRenderableJourneyCopy(value: string): boolean { - const normalized = normalizeJourneyCopy(value); - if (!normalized) return false; - if (/^[^A-Za-z0-9\u3400-\u9FFF]+$/.test(normalized)) return false; - const semanticChars = normalized.replace(/[^A-Za-z0-9\u3400-\u9FFF]/g, ""); - if (semanticChars.length >= 6) return true; - return normalized.length >= 12; + const normalized = normalizeJourneyCopy(value) + if (!normalized) return false + if (/^[^A-Za-z0-9\u3400-\u9FFF]+$/.test(normalized)) return false + const semanticChars = normalized.replace(/[^A-Za-z0-9\u3400-\u9FFF]/g, "") + if (semanticChars.length >= 6) return true + return normalized.length >= 12 } function getThreadPhaseIndex(elapsedMs: number): number { for (let index = 0; index < THREAD_PHASES.length; index += 1) { if (elapsedMs <= THREAD_PHASES[index].maxElapsedMs) { - return index; + return index } } - return THREAD_PHASES.length - 1; + return THREAD_PHASES.length - 1 } function getThreadPhaseIndexFromPipelineStage( @@ -313,17 +318,17 @@ function getThreadPhaseIndexFromPipelineStage( ): number | null { switch (stage) { case "initiating_pipeline": - return 0; + return 0 case "distilling_core_logic": - return 1; + return 1 case "curating_summary": - return 2; + return 2 case "persisting_result": case "completed": case "degraded_fallback": - return 3; + return 3 default: - return null; + return null } } @@ -332,34 +337,36 @@ function getThreadStatusFromPipelineStage( status: InsightPipelineStatus ): string { if (status === "degraded_fallback" || stage === "degraded_fallback") { - return "Summary completed with degraded fallback."; + return "Summary completed with degraded fallback." } switch (stage) { case "initiating_pipeline": - return "Preparing conversation context..."; + return "Preparing conversation context..." case "distilling_core_logic": - return "Distilling core logic..."; + return "Distilling core logic..." case "curating_summary": - return "Curating structured summary..."; + return "Curating structured summary..." case "persisting_result": - return "Finalising and persisting..."; + return "Finalising and persisting..." case "completed": - return "Summary generated."; + return "Summary generated." default: - return "Generating summary..."; + return "Generating summary..." } } function getWeeklyPhaseByElapsed(elapsedMs: number): WeeklyGenerationPhase { - let threshold = 0; + let threshold = 0 for (const phase of WEEKLY_PHASES) { - threshold += phase.minDurationMs; + threshold += phase.minDurationMs if (elapsedMs < threshold) { - return phase.phase; + return phase.phase } } - return WEEKLY_PHASES[WEEKLY_PHASES.length - 1]?.phase ?? "loading_thread_summaries"; + return ( + WEEKLY_PHASES[WEEKLY_PHASES.length - 1]?.phase ?? "loading_thread_summaries" + ) } function toThreadTrackedStage( @@ -367,81 +374,81 @@ function toThreadTrackedStage( ): ThreadTrackedStage | null { switch (stage) { case "initiating_pipeline": - return "initiating_pipeline"; + return "initiating_pipeline" case "distilling_core_logic": - return "distilling_core_logic"; + return "distilling_core_logic" case "curating_summary": - return "curating_summary"; + return "curating_summary" case "persisting_result": - return "persisting_result"; + return "persisting_result" default: - return null; + return null } } function formatPhaseDuration(ms: number): string { - const seconds = Math.max(ms, 0) / 1000; + const seconds = Math.max(ms, 0) / 1000 if (seconds >= 100) { - return `${Math.round(seconds)}s`; + return `${Math.round(seconds)}s` } - return `${seconds.toFixed(1)}s`; + return `${seconds.toFixed(1)}s` } function toDepthLabel(depth: "superficial" | "moderate" | "deep"): string { - if (depth === "deep") return "深度拆解"; - if (depth === "moderate") return "逐步深挖"; - return "轻量梳理"; + if (depth === "deep") return "深度拆解" + if (depth === "moderate") return "逐步深挖" + return "轻量梳理" } function getPlatformBadgeClass(platform: Platform): string { switch (platform) { case "ChatGPT": - return "ins-platform-badge-chatgpt"; + return "ins-platform-badge-chatgpt" case "DeepSeek": - return "ins-platform-badge-deepseek"; + return "ins-platform-badge-deepseek" case "Qwen": - return "ins-platform-badge-qwen"; + return "ins-platform-badge-qwen" case "Doubao": - return "ins-platform-badge-doubao"; + return "ins-platform-badge-doubao" case "Gemini": - return "ins-platform-badge-gemini"; + return "ins-platform-badge-gemini" case "Claude": - return "ins-platform-badge-claude"; + return "ins-platform-badge-claude" case "Kimi": - return "ins-platform-badge-kimi"; + return "ins-platform-badge-kimi" case "Yuanbao": - return "ins-platform-badge-yuanbao"; + return "ins-platform-badge-yuanbao" default: - return "ins-platform-badge-chatgpt"; + return "ins-platform-badge-chatgpt" } } function getThreadThemeClass(platform: Platform): string { switch (platform) { case "ChatGPT": - return "ins-thread-theme-chatgpt"; + return "ins-thread-theme-chatgpt" case "Claude": - return "ins-thread-theme-claude"; + return "ins-thread-theme-claude" case "Gemini": - return "ins-thread-theme-gemini"; + return "ins-thread-theme-gemini" case "DeepSeek": - return "ins-thread-theme-deepseek"; + return "ins-thread-theme-deepseek" case "Qwen": - return "ins-thread-theme-qwen"; + return "ins-thread-theme-qwen" case "Doubao": - return "ins-thread-theme-doubao"; + return "ins-thread-theme-doubao" case "Kimi": - return "ins-thread-theme-kimi"; + return "ins-thread-theme-kimi" case "Yuanbao": - return "ins-thread-theme-yuanbao"; + return "ins-thread-theme-yuanbao" default: - return "ins-thread-theme-chatgpt"; + return "ins-thread-theme-chatgpt" } } function formatConversationWeekday(conversation: Conversation): string { - const ts = getConversationOriginAt(conversation); - return new Date(ts).toLocaleDateString("en-US", { weekday: "short" }); + const ts = getConversationOriginAt(conversation) + return new Date(ts).toLocaleDateString("en-US", { weekday: "short" }) } function toThreadSummaryUiState( @@ -449,158 +456,172 @@ function toThreadSummaryUiState( summaryStatus: AsyncStatus, summaryData: ReturnType | null ): ThreadSummaryUiState { - if (!conversation) return "no_thread"; + if (!conversation) return "no_thread" if (summaryData) { - if (summaryStatus === "loading") return "ready_loading"; - if (summaryStatus === "error") return "ready_error"; - return "ready"; + if (summaryStatus === "loading") return "ready_loading" + if (summaryStatus === "error") return "ready_error" + return "ready" } - if (summaryStatus === "loading") return "selected_loading"; - if (summaryStatus === "error") return "selected_error"; - return "selected_idle"; + if (summaryStatus === "loading") return "selected_loading" + if (summaryStatus === "error") return "selected_error" + return "selected_idle" } -function toWeeklyStableState(data: WeeklySummaryData | null): WeeklyStableUiState { - if (!data) return "idle"; - return data.insufficient_data ? "sparse_week" : "ready"; +function toWeeklyStableState( + data: WeeklySummaryData | null +): WeeklyStableUiState { + if (!data) return "idle" + return data.insufficient_data ? "sparse_week" : "ready" } interface InsightsPageProps { - conversation: Conversation | null; - refreshToken: number; - pipelineProgressEvent?: InsightPipelineProgressPayload | null; + conversation: Conversation | null + refreshToken: number + pipelineProgressEvent?: InsightPipelineProgressPayload | null } export function InsightsPage({ conversation, refreshToken, - pipelineProgressEvent = null, + pipelineProgressEvent = null }: InsightsPageProps) { - const [summary, setSummary] = useState(null); - const [summaryStatus, setSummaryStatus] = useState("idle"); - const [summaryError, setSummaryError] = useState(null); - - const [weeklyReport, setWeeklyReport] = useState(null); - const [weeklyUiState, setWeeklyUiState] = useState("idle"); - const [weeklyStableState, setWeeklyStableState] = useState("idle"); - const [weeklyError, setWeeklyError] = useState(null); - - const [weeklyConversations, setWeeklyConversations] = useState([]); - const [isWeeklyListExpanded, setIsWeeklyListExpanded] = useState(false); + const [summary, setSummary] = useState(null) + const [summaryStatus, setSummaryStatus] = useState("idle") + const [summaryError, setSummaryError] = useState(null) + + const [weeklyReport, setWeeklyReport] = useState( + null + ) + const [weeklyUiState, setWeeklyUiState] = + useState("idle") + const [weeklyStableState, setWeeklyStableState] = + useState("idle") + const [weeklyError, setWeeklyError] = useState(null) + + const [weeklyConversations, setWeeklyConversations] = useState< + Conversation[] + >([]) + const [isWeeklyListExpanded, setIsWeeklyListExpanded] = useState(false) const [weeklyRangeMode, setWeeklyRangeMode] = - useState("last_7_days"); + useState("last_7_days") const [weeklyPhase, setWeeklyPhase] = - useState("ready_to_compile"); - const [weeklyGenerationStartedAt, setWeeklyGenerationStartedAt] = - useState(null); - const [weeklyElapsedMs, setWeeklyElapsedMs] = useState(0); - const [weeklyGenerationPaused, setWeeklyGenerationPaused] = useState(false); - const [weeklyPauseStartedAt, setWeeklyPauseStartedAt] = - useState(null); - const [weeklyPausedAccumulatedMs, setWeeklyPausedAccumulatedMs] = useState(0); - const [threadGenerationStartedAt, setThreadGenerationStartedAt] = - useState(null); - const [threadElapsedMs, setThreadElapsedMs] = useState(0); + useState("ready_to_compile") + const [weeklyGenerationStartedAt, setWeeklyGenerationStartedAt] = useState< + number | null + >(null) + const [weeklyElapsedMs, setWeeklyElapsedMs] = useState(0) + const [weeklyGenerationPaused, setWeeklyGenerationPaused] = useState(false) + const [weeklyPauseStartedAt, setWeeklyPauseStartedAt] = useState< + number | null + >(null) + const [weeklyPausedAccumulatedMs, setWeeklyPausedAccumulatedMs] = useState(0) + const [threadGenerationStartedAt, setThreadGenerationStartedAt] = useState< + number | null + >(null) + const [threadElapsedMs, setThreadElapsedMs] = useState(0) const [threadPipelineTiming, setThreadPipelineTiming] = - useState(null); + useState(null) - const [threadSummaryOpen, setThreadSummaryOpen] = useState(true); - const [weeklyDigestOpen, setWeeklyDigestOpen] = useState(true); - const [discoveryOpen, setDiscoveryOpen] = useState(true); + const [threadSummaryOpen, setThreadSummaryOpen] = useState(true) + const [weeklyDigestOpen, setWeeklyDigestOpen] = useState(true) + const [discoveryOpen, setDiscoveryOpen] = useState(true) - const weeklyStableRef = useRef("idle"); - const weeklyUiStateRef = useRef("idle"); - const weeklyHasReportRef = useRef(false); - const weeklyGenerationRunRef = useRef(0); + const weeklyStableRef = useRef("idle") + const weeklyUiStateRef = useRef("idle") + const weeklyHasReportRef = useRef(false) + const weeklyGenerationRunRef = useRef(0) - const weekAnchorKey = new Date().toDateString(); + const weekAnchorKey = new Date().toDateString() const weeklyRange = useMemo( () => weeklyRangeMode === "last_full_week" ? getPreviousNaturalWeekRangeLocal(new Date()) : getLastSevenDaysRangeLocal(new Date()), [weekAnchorKey, weeklyRangeMode] - ); + ) const summaryData = useMemo( () => summary ? toChatSummaryData(summary, { - conversationTitle: conversation?.title, + conversationTitle: conversation?.title }) : null, [summary, conversation?.title] - ); + ) const weeklyData = useMemo( () => (weeklyReport ? toWeeklySummaryData(weeklyReport) : null), [weeklyReport] - ); + ) const activeThreadPipelineEvent = useMemo(() => { - if (!pipelineProgressEvent || !conversation) return null; - if (pipelineProgressEvent.scope !== "summary") return null; - if (pipelineProgressEvent.targetId !== String(conversation.id)) return null; - return pipelineProgressEvent; - }, [pipelineProgressEvent, conversation]); - const threadPipelinePhaseIndex = - activeThreadPipelineEvent - ? getThreadPhaseIndexFromPipelineStage(activeThreadPipelineEvent.stage) - : null; + if (!pipelineProgressEvent || !conversation) return null + if (pipelineProgressEvent.scope !== "summary") return null + if (pipelineProgressEvent.targetId !== String(conversation.id)) return null + return pipelineProgressEvent + }, [pipelineProgressEvent, conversation]) + const threadPipelinePhaseIndex = activeThreadPipelineEvent + ? getThreadPhaseIndexFromPipelineStage(activeThreadPipelineEvent.stage) + : null const threadSummaryUiState = toThreadSummaryUiState( conversation, summaryStatus, summaryData - ); + ) const threadJourneySteps = useMemo(() => { - const rawSteps = summaryData?.thinking_journey ?? []; + const rawSteps = summaryData?.thinking_journey ?? [] const normalized = rawSteps .map((step) => { - const assertion = normalizeJourneyCopy(step.assertion); + const assertion = normalizeJourneyCopy(step.assertion) const anchor = step.real_world_anchor ? normalizeJourneyCopy(step.real_world_anchor) - : null; + : null - const assertionRenderable = hasRenderableJourneyCopy(assertion); - const anchorRenderable = anchor ? hasRenderableJourneyCopy(anchor) : false; + const assertionRenderable = hasRenderableJourneyCopy(assertion) + const anchorRenderable = anchor + ? hasRenderableJourneyCopy(anchor) + : false if (!assertionRenderable && step.speaker === "AI" && anchorRenderable) { return { ...step, assertion: anchor!, - real_world_anchor: null, - }; + real_world_anchor: null + } } return { ...step, assertion, - real_world_anchor: anchorRenderable ? anchor : null, - }; + real_world_anchor: anchorRenderable ? anchor : null + } }) .filter((step) => hasRenderableJourneyCopy(step.assertion)) .map((step, index) => ({ ...step, - step: index + 1, - })); - - return normalized; - }, [summaryData?.thinking_journey]); - const threadInsightItems = summaryData?.key_insights ?? []; - const threadUnresolvedItems = summaryData?.unresolved_threads ?? []; - const threadNextStepItems = summaryData?.actionable_next_steps ?? []; + step: index + 1 + })) + + return normalized + }, [summaryData?.thinking_journey]) + const threadInsightItems = summaryData?.key_insights ?? [] + const threadUnresolvedItems = summaryData?.unresolved_threads ?? [] + const threadNextStepItems = summaryData?.actionable_next_steps ?? [] const threadRealWorldAnchors = threadJourneySteps .map((step) => step.real_world_anchor) - .filter((anchor): anchor is string => Boolean(anchor && anchor.trim().length > 0)); + .filter((anchor): anchor is string => + Boolean(anchor && anchor.trim().length > 0) + ) const threadPhaseIndex = summaryStatus === "loading" ? threadPipelinePhaseIndex ?? getThreadPhaseIndex(threadElapsedMs) : threadGenerationStartedAt ? getThreadPhaseIndex(threadElapsedMs) - : -1; + : -1 const threadStatusText = summaryStatus === "loading" && activeThreadPipelineEvent ? getThreadStatusFromPipelineStage( @@ -609,151 +630,151 @@ export function InsightsPage({ ) : threadPhaseIndex >= 0 ? THREAD_PHASES[threadPhaseIndex]?.status ?? "Generating summary..." - : "Ready to generate."; + : "Ready to generate." const getThreadPhaseTimeLabel = (index: number): string => { - const fallbackHint = THREAD_PHASES[index]?.hint ?? "~10s"; - const stageKey = THREAD_TRACKED_STAGE_ORDER[index]; + const fallbackHint = THREAD_PHASES[index]?.hint ?? "~10s" + const stageKey = THREAD_TRACKED_STAGE_ORDER[index] if (!stageKey || !threadPipelineTiming) { - return fallbackHint; + return fallbackHint } - const measured = threadPipelineTiming.stageDurationsMs[stageKey]; + const measured = threadPipelineTiming.stageDurationsMs[stageKey] if (typeof measured === "number") { - return formatPhaseDuration(measured); + return formatPhaseDuration(measured) } if (threadPipelineTiming.activeStage === stageKey) { - const start = threadPipelineTiming.stageStarts[stageKey]; + const start = threadPipelineTiming.stageStarts[stageKey] if (typeof start === "number") { - return formatPhaseDuration(Date.now() - start); + return formatPhaseDuration(Date.now() - start) } } - return fallbackHint; - }; + return fallbackHint + } const weeklyRangeLabel = weeklyData?.meta.range_label ?? - formatWeekRangeLabel(weeklyRange.rangeStart, weeklyRange.rangeEnd); + formatWeekRangeLabel(weeklyRange.rangeStart, weeklyRange.rangeEnd) - const weeklyThreadCount = weeklyConversations.length; + const weeklyThreadCount = weeklyConversations.length const weeklyCountLabel = `${weeklyThreadCount} thread${ weeklyThreadCount === 1 ? "" : "s" - } in range`; + } in range` const sortedWeeklyConversations = useMemo(() => { return [...weeklyConversations].sort( (a, b) => getConversationOriginAt(b) - getConversationOriginAt(a) - ); - }, [weeklyConversations]); + ) + }, [weeklyConversations]) const visibleWeeklyConversations = isWeeklyListExpanded ? sortedWeeklyConversations - : sortedWeeklyConversations.slice(0, COLLAPSE_AT); + : sortedWeeklyConversations.slice(0, COLLAPSE_AT) const hiddenWeeklyConversationCount = Math.max( sortedWeeklyConversations.length - COLLAPSE_AT, 0 - ); + ) const turnCount = conversation ? resolveTurnCount(conversation.turn_count, conversation.message_count) - : 0; + : 0 const weeklyPhaseIndex = WEEKLY_PHASES.findIndex( (phase) => phase.phase === weeklyPhase - ); + ) - const isWeeklyGenerating = weeklyUiState === "generating"; + const isWeeklyGenerating = weeklyUiState === "generating" const weeklyStatusText = weeklyPhase === "ready_to_compile" ? "Ready to compile weekly digest." : WEEKLY_PHASES.find((phase) => phase.phase === weeklyPhase)?.status ?? - "Generating digest..."; + "Generating digest..." const weeklyRangeModeLabel = - weeklyRangeMode === "last_7_days" ? "Last 7 Days" : "Last Full Week"; + weeklyRangeMode === "last_7_days" ? "Last 7 Days" : "Last Full Week" const weeklyHighlightItems = weeklyData?.highlights && weeklyData.highlights.length > 0 ? weeklyData.highlights - : parsePlainTextLines(weeklyData?.plain_text).slice(0, 3); + : parsePlainTextLines(weeklyData?.plain_text).slice(0, 3) - const weeklyRecurringItems = weeklyData?.recurring_questions ?? []; - const weeklyCrossDomainEchoes = weeklyData?.cross_domain_echoes ?? []; - const weeklyUnresolvedItems = weeklyData?.unresolved_threads ?? []; - const weeklyNextWeekItems = weeklyData?.suggested_focus ?? []; + const weeklyRecurringItems = weeklyData?.recurring_questions ?? [] + const weeklyCrossDomainEchoes = weeklyData?.cross_domain_echoes ?? [] + const weeklyUnresolvedItems = weeklyData?.unresolved_threads ?? [] + const weeklyNextWeekItems = weeklyData?.suggested_focus ?? [] const weeklySubstantialCount = useMemo(() => { const structured = weeklyReport?.structured as | { time_range?: { total_conversations?: unknown } } | null - | undefined; - const total = structured?.time_range?.total_conversations; - return typeof total === "number" ? total : null; - }, [weeklyReport]); + | undefined + const total = structured?.time_range?.total_conversations + return typeof total === "number" ? total : null + }, [weeklyReport]) const weeklySparseReason: WeeklySparseReason = weeklySubstantialCount !== null && weeklySubstantialCount < 3 ? "sub3" - : "semantic_degraded"; + : "semantic_degraded" useEffect(() => { - weeklyStableRef.current = weeklyStableState; - }, [weeklyStableState]); + weeklyStableRef.current = weeklyStableState + }, [weeklyStableState]) useEffect(() => { - weeklyUiStateRef.current = weeklyUiState; - }, [weeklyUiState]); + weeklyUiStateRef.current = weeklyUiState + }, [weeklyUiState]) useEffect(() => { - weeklyHasReportRef.current = Boolean(weeklyReport); - }, [weeklyReport]); + weeklyHasReportRef.current = Boolean(weeklyReport) + }, [weeklyReport]) useEffect(() => { - if (!activeThreadPipelineEvent) return; + if (!activeThreadPipelineEvent) return setThreadPipelineTiming((prev) => { - const trackedStage = toThreadTrackedStage(activeThreadPipelineEvent.stage); + const trackedStage = toThreadTrackedStage(activeThreadPipelineEvent.stage) const isTerminal = activeThreadPipelineEvent.stage === "completed" || - activeThreadPipelineEvent.stage === "degraded_fallback"; + activeThreadPipelineEvent.stage === "degraded_fallback" if (!prev || prev.pipelineId !== activeThreadPipelineEvent.pipelineId) { - const stageStarts: Partial> = {}; + const stageStarts: Partial> = {} if (trackedStage) { - stageStarts[trackedStage] = activeThreadPipelineEvent.updatedAt; + stageStarts[trackedStage] = activeThreadPipelineEvent.updatedAt } return { pipelineId: activeThreadPipelineEvent.pipelineId, stageStarts, stageDurationsMs: {}, activeStage: trackedStage, - terminal: isTerminal, - }; + terminal: isTerminal + } } - const stageStarts = { ...prev.stageStarts }; - const stageDurationsMs = { ...prev.stageDurationsMs }; - let activeStage = prev.activeStage; + const stageStarts = { ...prev.stageStarts } + const stageDurationsMs = { ...prev.stageDurationsMs } + let activeStage = prev.activeStage const finalizeStage = (stage: ThreadTrackedStage, endAt: number) => { - const startAt = stageStarts[stage]; - if (typeof startAt !== "number") return; - stageDurationsMs[stage] = Math.max(0, endAt - startAt); - }; + const startAt = stageStarts[stage] + if (typeof startAt !== "number") return + stageDurationsMs[stage] = Math.max(0, endAt - startAt) + } if (trackedStage && activeStage !== trackedStage) { if (activeStage) { - finalizeStage(activeStage, activeThreadPipelineEvent.updatedAt); + finalizeStage(activeStage, activeThreadPipelineEvent.updatedAt) } if (typeof stageStarts[trackedStage] !== "number") { - stageStarts[trackedStage] = activeThreadPipelineEvent.updatedAt; + stageStarts[trackedStage] = activeThreadPipelineEvent.updatedAt } - activeStage = trackedStage; + activeStage = trackedStage } if (isTerminal && activeStage) { - finalizeStage(activeStage, activeThreadPipelineEvent.updatedAt); - activeStage = null; + finalizeStage(activeStage, activeThreadPipelineEvent.updatedAt) + activeStage = null } return { @@ -761,297 +782,305 @@ export function InsightsPage({ stageStarts, stageDurationsMs, activeStage, - terminal: prev.terminal || isTerminal, - }; - }); - }, [activeThreadPipelineEvent]); + terminal: prev.terminal || isTerminal + } + }) + }, [activeThreadPipelineEvent]) useEffect(() => { if (weeklyUiStateRef.current === "generating") { - return; + return } - setWeeklyReport(null); - setWeeklyStableState("idle"); - setWeeklyUiState("idle"); - setWeeklyError(null); - setWeeklyPhase("ready_to_compile"); - }, [weekAnchorKey, weeklyRangeMode]); + setWeeklyReport(null) + setWeeklyStableState("idle") + setWeeklyUiState("idle") + setWeeklyError(null) + setWeeklyPhase("ready_to_compile") + }, [weekAnchorKey, weeklyRangeMode]) useEffect(() => { return () => { - weeklyGenerationRunRef.current += 1; - }; - }, []); + weeklyGenerationRunRef.current += 1 + } + }, []) useEffect(() => { if (!weeklyGenerationStartedAt) { - setWeeklyElapsedMs(0); - return; + setWeeklyElapsedMs(0) + return } const tick = () => { - const now = Date.now(); + const now = Date.now() const livePauseMs = weeklyGenerationPaused && weeklyPauseStartedAt ? now - weeklyPauseStartedAt - : 0; + : 0 const elapsed = Math.max( 0, - now - weeklyGenerationStartedAt - weeklyPausedAccumulatedMs - livePauseMs - ); - setWeeklyElapsedMs(elapsed); + now - + weeklyGenerationStartedAt - + weeklyPausedAccumulatedMs - + livePauseMs + ) + setWeeklyElapsedMs(elapsed) setWeeklyPhase((prev) => { - const next = getWeeklyPhaseByElapsed(elapsed); - return prev === next ? prev : next; - }); - }; + const next = getWeeklyPhaseByElapsed(elapsed) + return prev === next ? prev : next + }) + } - tick(); - const timerId = window.setInterval(tick, 250); + tick() + const timerId = window.setInterval(tick, 250) return () => { - window.clearInterval(timerId); - }; + window.clearInterval(timerId) + } }, [ weeklyGenerationPaused, weeklyGenerationStartedAt, weeklyPauseStartedAt, - weeklyPausedAccumulatedMs, - ]); + weeklyPausedAccumulatedMs + ]) useEffect(() => { if (summaryStatus === "loading") { - setThreadGenerationStartedAt((prev) => prev ?? Date.now()); - return; + setThreadGenerationStartedAt((prev) => prev ?? Date.now()) + return } - setThreadGenerationStartedAt(null); - setThreadElapsedMs(0); - }, [summaryStatus]); + setThreadGenerationStartedAt(null) + setThreadElapsedMs(0) + }, [summaryStatus]) useEffect(() => { if (!threadGenerationStartedAt) { - setThreadElapsedMs(0); - return; + setThreadElapsedMs(0) + return } const tick = () => { - setThreadElapsedMs(Date.now() - threadGenerationStartedAt); - }; + setThreadElapsedMs(Date.now() - threadGenerationStartedAt) + } - tick(); - const timerId = window.setInterval(tick, 250); + tick() + const timerId = window.setInterval(tick, 250) return () => { - window.clearInterval(timerId); - }; - }, [threadGenerationStartedAt]); + window.clearInterval(timerId) + } + }, [threadGenerationStartedAt]) useEffect(() => { if (!conversation) { - setSummary(null); - setSummaryStatus("idle"); - setSummaryError(null); - setThreadPipelineTiming(null); - return; + setSummary(null) + setSummaryStatus("idle") + setSummaryError(null) + setThreadPipelineTiming(null) + return } - let active = true; - setSummaryStatus("loading"); - setSummaryError(null); + let active = true + setSummaryStatus("loading") + setSummaryError(null) getConversationSummary(conversation.id) .then((data) => { - if (!active) return; - setSummary(data); - setSummaryStatus(data ? "ready" : "idle"); + if (!active) return + setSummary(data) + setSummaryStatus(data ? "ready" : "idle") }) .catch((error) => { - if (!active) return; - setSummary(null); - setSummaryStatus("error"); - setSummaryError(getErrorMessage(error)); - }); + if (!active) return + setSummary(null) + setSummaryStatus("error") + setSummaryError(getErrorMessage(error)) + }) return () => { - active = false; - }; - }, [conversation?.id, refreshToken]); + active = false + } + }, [conversation?.id, refreshToken]) useEffect(() => { - setThreadPipelineTiming(null); - }, [conversation?.id]); + setThreadPipelineTiming(null) + }, [conversation?.id]) useEffect(() => { if (WEEKLY_DIGEST_SOON) { - setWeeklyConversations([]); - setIsWeeklyListExpanded(false); - return; + setWeeklyConversations([]) + setIsWeeklyListExpanded(false) + return } - let active = true; + let active = true getConversations({ dateRange: { start: weeklyRange.rangeStart, - end: weeklyRange.rangeEnd, + end: weeklyRange.rangeEnd }, + includeTrash: false }) .then((items) => { - if (!active) return; - setWeeklyConversations(items.filter((item) => !item.is_trash)); - setIsWeeklyListExpanded(false); + if (!active) return + setWeeklyConversations(items.filter((item) => !item.is_trash)) + setIsWeeklyListExpanded(false) }) .catch(() => { - if (!active) return; - setWeeklyConversations([]); - setIsWeeklyListExpanded(false); - }); + if (!active) return + setWeeklyConversations([]) + setIsWeeklyListExpanded(false) + }) return () => { - active = false; - }; - }, [refreshToken, weeklyRange.rangeStart, weeklyRange.rangeEnd]); + active = false + } + }, [refreshToken, weeklyRange.rangeStart, weeklyRange.rangeEnd]) useEffect(() => { if (WEEKLY_DIGEST_SOON) { - setWeeklyReport(null); - setWeeklyStableState("idle"); - setWeeklyUiState("idle"); - setWeeklyError(null); - setWeeklyPhase("ready_to_compile"); - return; + setWeeklyReport(null) + setWeeklyStableState("idle") + setWeeklyUiState("idle") + setWeeklyError(null) + setWeeklyPhase("ready_to_compile") + return } if (weeklyUiStateRef.current === "generating") { - return; + return } - let active = true; - setWeeklyError(null); + let active = true + setWeeklyError(null) getWeeklyReport(weeklyRange.rangeStart, weeklyRange.rangeEnd) .then((data) => { - if (!active) return; - setWeeklyReport(data); - const nextData = data ? toWeeklySummaryData(data) : null; - const nextStableState = toWeeklyStableState(nextData); - setWeeklyStableState(nextStableState); - setWeeklyUiState(nextStableState); - setWeeklyPhase("ready_to_compile"); + if (!active) return + setWeeklyReport(data) + const nextData = data ? toWeeklySummaryData(data) : null + const nextStableState = toWeeklyStableState(nextData) + setWeeklyStableState(nextStableState) + setWeeklyUiState(nextStableState) + setWeeklyPhase("ready_to_compile") }) .catch((error) => { - if (!active) return; - setWeeklyError(getErrorMessage(error)); - setWeeklyUiState(weeklyHasReportRef.current ? weeklyStableRef.current : "error"); - }); + if (!active) return + setWeeklyError(getErrorMessage(error)) + setWeeklyUiState( + weeklyHasReportRef.current ? weeklyStableRef.current : "error" + ) + }) return () => { - active = false; - }; - }, [refreshToken, weeklyRange.rangeStart, weeklyRange.rangeEnd]); + active = false + } + }, [refreshToken, weeklyRange.rangeStart, weeklyRange.rangeEnd]) const handleGenerateSummary = async () => { - if (!conversation) return; + if (!conversation) return - setThreadSummaryOpen(true); - setThreadGenerationStartedAt(Date.now()); - setThreadElapsedMs(0); - setThreadPipelineTiming(null); - setSummaryStatus("loading"); - setSummaryError(null); + setThreadSummaryOpen(true) + setThreadGenerationStartedAt(Date.now()) + setThreadElapsedMs(0) + setThreadPipelineTiming(null) + setSummaryStatus("loading") + setSummaryError(null) try { - const data = await generateConversationSummary(conversation.id); - setSummary(data); - setSummaryStatus("ready"); + const data = await generateConversationSummary(conversation.id) + setSummary(data) + setSummaryStatus("ready") } catch (error) { - setSummaryStatus("error"); - setSummaryError(getErrorMessage(error)); + setSummaryStatus("error") + setSummaryError(getErrorMessage(error)) } - }; + } const handleGenerateWeekly = async () => { if (WEEKLY_DIGEST_SOON) { - return; + return } - const runId = weeklyGenerationRunRef.current + 1; - weeklyGenerationRunRef.current = runId; - - setWeeklyDigestOpen(true); - setWeeklyUiState("generating"); - setWeeklyError(null); - setWeeklyPhase("loading_thread_summaries"); - setWeeklyGenerationStartedAt(Date.now()); - setWeeklyGenerationPaused(false); - setWeeklyPauseStartedAt(null); - setWeeklyPausedAccumulatedMs(0); - - const result = await generateWeeklyReport(weeklyRange.rangeStart, weeklyRange.rangeEnd) + const runId = weeklyGenerationRunRef.current + 1 + weeklyGenerationRunRef.current = runId + + setWeeklyDigestOpen(true) + setWeeklyUiState("generating") + setWeeklyError(null) + setWeeklyPhase("loading_thread_summaries") + setWeeklyGenerationStartedAt(Date.now()) + setWeeklyGenerationPaused(false) + setWeeklyPauseStartedAt(null) + setWeeklyPausedAccumulatedMs(0) + + const result = await generateWeeklyReport( + weeklyRange.rangeStart, + weeklyRange.rangeEnd + ) .then((data) => ({ ok: true as const, data })) - .catch((error) => ({ ok: false as const, error })); + .catch((error) => ({ ok: false as const, error })) if (weeklyGenerationRunRef.current !== runId) { - return; + return } - setWeeklyGenerationStartedAt(null); - setWeeklyGenerationPaused(false); - setWeeklyPauseStartedAt(null); - setWeeklyPausedAccumulatedMs(0); - setWeeklyPhase("ready_to_compile"); + setWeeklyGenerationStartedAt(null) + setWeeklyGenerationPaused(false) + setWeeklyPauseStartedAt(null) + setWeeklyPausedAccumulatedMs(0) + setWeeklyPhase("ready_to_compile") if (result.ok === true) { - setWeeklyReport(result.data); - const nextData = toWeeklySummaryData(result.data); - const nextStableState = toWeeklyStableState(nextData); - setWeeklyStableState(nextStableState); - setWeeklyUiState(nextStableState); - setWeeklyError(null); - return; + setWeeklyReport(result.data) + const nextData = toWeeklySummaryData(result.data) + const nextStableState = toWeeklyStableState(nextData) + setWeeklyStableState(nextStableState) + setWeeklyUiState(nextStableState) + setWeeklyError(null) + return } const nextError = getErrorMessage( result.ok === false ? result.error : "UNKNOWN_ERROR" - ); - setWeeklyError(nextError); + ) + setWeeklyError(nextError) if (weeklyHasReportRef.current) { - setWeeklyUiState(weeklyStableRef.current); + setWeeklyUiState(weeklyStableRef.current) } else { - setWeeklyUiState("error"); + setWeeklyUiState("error") } - }; + } const handleToggleWeeklyPause = () => { if (!isWeeklyGenerating || !weeklyGenerationStartedAt) { - return; + return } - const now = Date.now(); + const now = Date.now() if (weeklyGenerationPaused) { if (weeklyPauseStartedAt) { setWeeklyPausedAccumulatedMs( (prev) => prev + (now - weeklyPauseStartedAt) - ); + ) } - setWeeklyPauseStartedAt(null); - setWeeklyGenerationPaused(false); - return; + setWeeklyPauseStartedAt(null) + setWeeklyGenerationPaused(false) + return } - setWeeklyPauseStartedAt(now); - setWeeklyGenerationPaused(true); - }; + setWeeklyPauseStartedAt(now) + setWeeklyGenerationPaused(true) + } const renderThreadContext = () => { - if (!conversation) return null; + if (!conversation) return null return (
+ )} ins-thread-platform-badge`}> {conversation.platform}
@@ -1061,8 +1090,8 @@ export function InsightsPage({

- ); - }; + ) + } const renderThreadSummaryBody = () => { if (threadSummaryUiState === "no_thread") { @@ -1070,16 +1099,16 @@ export function InsightsPage({

Select a thread from Threads to generate a summary.

- ); + ) } const showGeneratingShell = threadSummaryUiState === "selected_loading" || - threadSummaryUiState === "ready_loading"; + threadSummaryUiState === "ready_loading" const showReadyShell = threadSummaryUiState === "ready" || threadSummaryUiState === "ready_loading" || - threadSummaryUiState === "ready_error"; + threadSummaryUiState === "ready_error" return (
@@ -1099,7 +1128,9 @@ export function InsightsPage({

{threadStatusText}

- {formatTimer(threadElapsedMs)} + + {formatTimer(threadElapsedMs)} +
@@ -1109,20 +1140,26 @@ export function InsightsPage({ ? "ins-thread-phase-done" : threadPhaseIndex === index ? "ins-thread-phase-active" - : "ins-thread-phase-idle"; + : "ins-thread-phase-idle" return ( -
+
- {phase.label} - {phase.sublabel} + + {phase.label} + + + {phase.sublabel} + {getThreadPhaseTimeLabel(index)} OK
- ); + ) })}
@@ -1132,28 +1169,36 @@ export function InsightsPage({
+ }`}>
-

{"\u6838\u5fc3\u95ee\u9898"}

-

{summaryData.core_question}

+

+ {"\u6838\u5fc3\u95ee\u9898"} +

+

+ {summaryData.core_question} +

{threadJourneySteps.length > 0 && (
- {"\u601d\u8003\u8f68\u8ff9"} + + {"\u601d\u8003\u8f68\u8ff9"} + - {threadJourneySteps.length} + + {threadJourneySteps.length} +
{threadJourneySteps.map((step, index) => (
+ step.speaker === "User" + ? "ins-thread-step-user" + : "ins-thread-step-ai" + }`}>
{String(index + 1).padStart(2, "0")} @@ -1163,16 +1208,19 @@ export function InsightsPage({ step.speaker === "User" ? "ins-thread-speaker-user" : "ins-thread-speaker-ai" - }`} - > + }`}> {step.speaker === "User" ? "\u4f60" : "\u52a9\u624b"}
-

{step.assertion}

+

+ {step.assertion} +

{step.speaker === "AI" && step.real_world_anchor && (
-

{step.real_world_anchor}

+

+ {step.real_world_anchor} +

)}
@@ -1184,15 +1232,23 @@ export function InsightsPage({ {threadInsightItems.length > 0 && (
- {"\u5173\u952e\u6d1e\u5bdf"} + + {"\u5173\u952e\u6d1e\u5bdf"} + - {threadInsightItems.length} + + {threadInsightItems.length} +
{threadInsightItems.map((item, index) => ( -
+

{item.term}

-

{item.definition}

+

+ {item.definition} +

))}
@@ -1202,13 +1258,19 @@ export function InsightsPage({ {threadUnresolvedItems.length > 0 && (
- {"\u672a\u89e3\u95ee\u9898"} + + {"\u672a\u89e3\u95ee\u9898"} + - {threadUnresolvedItems.length} + + {threadUnresolvedItems.length} +
{threadUnresolvedItems.map((item, index) => ( -
+

{item}

@@ -1220,13 +1282,19 @@ export function InsightsPage({ {threadNextStepItems.length > 0 && (
- {"\u4e0b\u4e00\u6b65\u5efa\u8bae"} + + {"\u4e0b\u4e00\u6b65\u5efa\u8bae"} + - {threadNextStepItems.length} + + {threadNextStepItems.length} +
{threadNextStepItems.map((item, index) => ( -
+
{String(index + 1).padStart(2, "0")} @@ -1239,7 +1307,9 @@ export function InsightsPage({
- {"\u601d\u7ef4\u4fa7\u5199"} + + {"\u601d\u7ef4\u4fa7\u5199"} +
@@ -1269,8 +1339,7 @@ export function InsightsPage({ type="button" onClick={handleGenerateSummary} disabled={summaryStatus === "loading"} - className="ins-generate-btn" - > + className="ins-generate-btn"> {summaryStatus === "loading" ? ( ) : ( @@ -1293,8 +1362,7 @@ export function InsightsPage({

@@ -1315,16 +1383,15 @@ export function InsightsPage({
)}
- ); - }; + ) + } const renderWeeklyRangeToggle = () => { return (
+ aria-label="Weekly digest range">
- ); - }; + ) + } const renderWeeklyIdle = () => { return ( @@ -1370,8 +1435,7 @@ export function InsightsPage({ + )}`}> {item.platform}

{item.title}

@@ -1385,8 +1449,7 @@ export function InsightsPage({ - ); - }; + ) + } const renderWeeklyGenerating = () => { return ( @@ -1444,7 +1506,9 @@ export function InsightsPage({

- {formatTimer(weeklyElapsedMs)} + + {formatTimer(weeklyElapsedMs)} +
@@ -1454,19 +1518,23 @@ export function InsightsPage({ ? "ins-week-phase-done" : weeklyPhaseIndex === index ? "ins-week-phase-active" - : "ins-week-phase-idle"; + : "ins-week-phase-idle" return ( -
+
{phase.label} - {phase.sublabel} + + {phase.sublabel} + {phase.hint} OK
- ); + ) })}
@@ -1475,9 +1543,12 @@ export function InsightsPage({ type="button" onClick={handleToggleWeeklyPause} aria-pressed={weeklyGenerationPaused} - aria-label={weeklyGenerationPaused ? "Resume progress view" : "Pause progress view"} - className="ins-week-gen-control-btn" - > + aria-label={ + weeklyGenerationPaused + ? "Resume progress view" + : "Pause progress view" + } + className="ins-week-gen-control-btn"> {weeklyGenerationPaused ? ( ) : ( @@ -1491,8 +1562,8 @@ export function InsightsPage({
- ); - }; + ) + } const renderWeeklyReady = () => { return ( @@ -1511,8 +1582,7 @@ export function InsightsPage({

@@ -1527,7 +1597,9 @@ export function InsightsPage({
{weeklyHighlightItems.map((item, index) => ( -
+

{item}

))} @@ -1543,7 +1615,9 @@ export function InsightsPage({
{weeklyRecurringItems.map((item, index) => ( -
+
"

{item}

@@ -1560,11 +1634,17 @@ export function InsightsPage({
{weeklyCrossDomainEchoes.map((echo, index) => ( -
+
- {echo.domain_a} + + {echo.domain_a} + <-> - {echo.domain_b} + + {echo.domain_b} +

Shared logic

{echo.shared_logic}

@@ -1591,7 +1671,9 @@ export function InsightsPage({
{weeklyUnresolvedItems.map((item, index) => ( -
+

{item}

@@ -1608,7 +1690,9 @@ export function InsightsPage({
{weeklyNextWeekItems.map((item, index) => ( -
+
->

{item}

@@ -1621,8 +1705,7 @@ export function InsightsPage({ @@ -1631,7 +1714,9 @@ export function InsightsPage({

Model: - {weeklyReport.modelId} + + {weeklyReport.modelId} +

Generated: @@ -1642,8 +1727,8 @@ export function InsightsPage({

)} - ); - }; + ) + } const renderWeeklySparse = () => { return ( @@ -1662,8 +1747,7 @@ export function InsightsPage({

@@ -1675,21 +1759,24 @@ export function InsightsPage({

{weeklySparseReason === "sub3" && (

- This range has fewer than 3 substantial summaries. Weekly Digest will - resume automatically when enough structured evidence is available. + This range has fewer than 3 substantial summaries. Weekly Digest + will resume automatically when enough structured evidence is + available.

)} {weeklySparseReason === "semantic_degraded" && (

- Enough summaries were found, but semantic quality gate downgraded this - run to prevent low-signal fragments. + Enough summaries were found, but semantic quality gate downgraded + this run to prevent low-signal fragments.

)}
Threads started in range: {weeklyThreadCount} Substantial summaries:{" "} - {weeklySubstantialCount === null ? "unknown" : weeklySubstantialCount} + {weeklySubstantialCount === null + ? "unknown" + : weeklySubstantialCount}
@@ -1697,14 +1784,13 @@ export function InsightsPage({ - ); - }; + ) + } const renderWeeklyError = () => { return ( @@ -1724,39 +1810,38 @@ export function InsightsPage({
- ); - }; + ) + } const renderWeeklyBody = () => { if (weeklyUiState === "generating") { - return renderWeeklyGenerating(); + return renderWeeklyGenerating() } if (weeklyUiState === "ready") { - return renderWeeklyReady(); + return renderWeeklyReady() } if (weeklyUiState === "sparse_week") { - return renderWeeklySparse(); + return renderWeeklySparse() } if (weeklyUiState === "error") { - return renderWeeklyError(); + return renderWeeklyError() } - return renderWeeklyIdle(); - }; + return renderWeeklyIdle() + } function openDashboard(tab: "explore" | "network") { - const url = chrome.runtime.getURL(`options.html?tab=${tab}`); - chrome.tabs.create({ url }); + const url = chrome.runtime.getURL(`options.html?tab=${tab}`) + chrome.tabs.create({ url }) } return ( @@ -1778,8 +1863,7 @@ export function InsightsPage({ - } - > + }> {renderThreadSummaryBody()} @@ -1790,12 +1874,13 @@ export function InsightsPage({ description="Highlights from the past seven days" open={weeklyDigestOpen} onToggle={ - WEEKLY_DIGEST_SOON ? undefined : () => setWeeklyDigestOpen((prev) => !prev) + WEEKLY_DIGEST_SOON + ? undefined + : () => setWeeklyDigestOpen((prev) => !prev) } icon={} disabled={WEEKLY_DIGEST_SOON} - soonTag={WEEKLY_DIGEST_SOON ? "Soon" : undefined} - > + soonTag={WEEKLY_DIGEST_SOON ? "Soon" : undefined}> {renderWeeklyBody()} @@ -1806,20 +1891,23 @@ export function InsightsPage({ description="Knowledge graph and thread connections" open={discoveryOpen} onToggle={() => setDiscoveryOpen((prev) => !prev)} - icon={} - > + icon={}>