From e1a477883be69ab4cd9b50be1bae41159043ac45 Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 19:41:19 -0400 Subject: [PATCH 01/16] fix(i18n): correct stale local embedding hints about vector sync and the vector index entry --- package.json | 2 +- src/constants/version.ts | 2 +- src/i18n/en-US/embedding-ui.ts | 2 +- src/i18n/en-US/settings-ui.ts | 3 ++- src/i18n/zh-CN/embedding-ui.ts | 2 +- src/i18n/zh-CN/settings-ui.ts | 4 ++-- src/i18n/zh-TW/embedding-ui.ts | 2 +- src/i18n/zh-TW/settings-ui.ts | 4 ++-- 8 files changed, 11 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index fa3e429f..e5744d0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tsukuyomi-translator", - "version": "0.17.0", + "version": "0.18.0", "description": "Tsukuyomi - Moonlit Translator", "productName": "Tsukuyomi - Moonlit Translator", "author": "rozx ", diff --git a/src/constants/version.ts b/src/constants/version.ts index f0b8a004..c799c9df 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.17.0.18'; +export const APP_VERSION = '0.18.0.1'; diff --git a/src/i18n/en-US/embedding-ui.ts b/src/i18n/en-US/embedding-ui.ts index 63315a8e..4ea49d4f 100644 --- a/src/i18n/en-US/embedding-ui.ts +++ b/src/i18n/en-US/embedding-ui.ts @@ -35,7 +35,7 @@ export default { resume: 'Resume', mobileUnsupported: 'Local embeddings are unavailable on mobile', mobileHint: - 'Model size and unstable mobile WebGPU support prevent local embedding on mobile. Generate embeddings on desktop; mobile devices can read them.', + 'Model size and unstable mobile WebGPU support prevent local embedding on mobile. Mobile search uses keyword matching only; vectors stay on the device that generated them and are not synced to mobile.', notEnabled: 'Local embeddings are disabled', enableHint: 'Enable this feature to download an embedding model (about 190 MiB) for semantic memory and chapter search. Related actions are hidden while disabled.', diff --git a/src/i18n/en-US/settings-ui.ts b/src/i18n/en-US/settings-ui.ts index 3e0ffbc5..939e3d79 100644 --- a/src/i18n/en-US/settings-ui.ts +++ b/src/i18n/en-US/settings-ui.ts @@ -86,7 +86,8 @@ export default { chapterAuto: 'Chapter embeddings run automatically in the background and are recomputed 60 seconds after paragraphs or translations change; there are no settings. To view, backfill, or rebuild progress, use the', vectorIndex: 'Vector index', - chapterAutoSuffix: 'popup at the top of the book details page.', + chapterAutoSuffix: + 'panel on the book details page (in the side rail on desktop and tablet, or the top system bar on narrow screens).', }, sites: { title: 'Site mappings', diff --git a/src/i18n/zh-CN/embedding-ui.ts b/src/i18n/zh-CN/embedding-ui.ts index 0ffb7205..6ee661bd 100644 --- a/src/i18n/zh-CN/embedding-ui.ts +++ b/src/i18n/zh-CN/embedding-ui.ts @@ -35,7 +35,7 @@ export default { resume: '恢复', mobileUnsupported: '移动设备不支持本地嵌入', mobileHint: - '模型过大、WebGPU 在移动浏览器上不稳定,本功能在移动端被强制禁用。请在桌面端开启并生成向量,手机端只读使用。', + '模型过大、WebGPU 在移动浏览器上不稳定,本功能在移动端被强制禁用。手机端检索仅使用关键词匹配;向量只保存在生成它的设备上,不会同步到手机。', notEnabled: '本地嵌入未启用', enableHint: '启用后可在本地下载嵌入模型(约 190 MiB),支持语义记忆检索与章节向量搜索。关闭状态下所有相关操作按钮均已隐藏。', diff --git a/src/i18n/zh-CN/settings-ui.ts b/src/i18n/zh-CN/settings-ui.ts index 2029a0fd..b46d5a1c 100644 --- a/src/i18n/zh-CN/settings-ui.ts +++ b/src/i18n/zh-CN/settings-ui.ts @@ -78,9 +78,9 @@ export default { chapterTitle: '章节嵌入', chapterDescription: '为每章生成多段向量,让 AI 按剧情/事件/人物语义找相关章节', chapterAuto: - '章节嵌入在后台自动运行,段落或译文变更后 60 秒防抖重算,无可配置项。要查看 / 回填 / 重算进度,请在书籍详情页顶部的', + '章节嵌入在后台自动运行,段落或译文变更后 60 秒防抖重算,无可配置项。要查看 / 回填 / 重算进度,请在书籍详情页的', vectorIndex: '向量索引', - chapterAutoSuffix: '弹窗中操作。', + chapterAutoSuffix: '面板中操作(桌面与平板在侧栏,窄屏在顶部系统栏)。', }, sites: { title: '网站映射', diff --git a/src/i18n/zh-TW/embedding-ui.ts b/src/i18n/zh-TW/embedding-ui.ts index 5c2f4d63..76fdbf3c 100644 --- a/src/i18n/zh-TW/embedding-ui.ts +++ b/src/i18n/zh-TW/embedding-ui.ts @@ -35,7 +35,7 @@ export default { resume: '恢復', mobileUnsupported: '行動裝置不支援本機嵌入', mobileHint: - '模型過大,且 WebGPU 在行動瀏覽器上不穩定,因此行動裝置停用此功能。請於桌面啟用並產生向量,手機僅供讀取。', + '模型過大,且 WebGPU 在行動瀏覽器上不穩定,因此行動裝置停用此功能。手機端檢索僅使用關鍵詞比對;向量只保存在產生它的裝置上,不會同步到手機。', notEnabled: '本機嵌入尚未啟用', enableHint: '啟用後可下載嵌入模型(約 190 MiB),支援語意記憶檢索及章節向量搜尋。停用時隱藏相關操作按鈕。', diff --git a/src/i18n/zh-TW/settings-ui.ts b/src/i18n/zh-TW/settings-ui.ts index d08f8564..42a0f739 100644 --- a/src/i18n/zh-TW/settings-ui.ts +++ b/src/i18n/zh-TW/settings-ui.ts @@ -78,9 +78,9 @@ export default { chapterTitle: '章節嵌入', chapterDescription: '為每章產生多段向量,讓 AI 依劇情/事件/人物語意找到相關章節', chapterAuto: - '章節嵌入在背景自動執行,段落或譯文變更後 60 秒防抖重算,無可設定項目。要查看/回填/重算進度,請在書籍詳情頁頂部的', + '章節嵌入在背景自動執行,段落或譯文變更後 60 秒防抖重算,無可設定項目。要查看/回填/重算進度,請在書籍詳情頁的', vectorIndex: '向量索引', - chapterAutoSuffix: '彈窗中操作。', + chapterAutoSuffix: '面板中操作(桌面與平板在側欄,窄螢幕在頂部系統列)。', }, sites: { title: '網站對應', From f64f585d1d9ff72c3bed13fca08ddd950a9f928e Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 19:41:19 -0400 Subject: [PATCH 02/16] chore(release): prepare v0.18.0 release notes, help docs and READMEs --- README.en-US.md | 25 +-- README.md | 25 +-- README.zh-TW.md | 25 +-- public/help/en-US/ai-models-guide.md | 8 +- public/help/en-US/book-details-memory.md | 8 +- public/help/en-US/index.json | 9 + public/help/en-US/local-embedding.md | 145 +++++++++------- public/help/en-US/settings-guide.md | 33 +++- public/help/en-US/toolbar-guide.md | 93 ++++++---- public/help/zh-CN/ai-models-guide.md | 8 +- public/help/zh-CN/book-details-memory.md | 6 +- public/help/zh-CN/index.json | 9 + public/help/zh-CN/local-embedding.md | 109 +++++++----- public/help/zh-CN/settings-guide.md | 33 +++- public/help/zh-CN/toolbar-guide.md | 93 ++++++---- public/help/zh-TW/ai-models-guide.md | 12 +- public/help/zh-TW/book-details-memory.md | 8 +- public/help/zh-TW/index.json | 9 + public/help/zh-TW/local-embedding.md | 169 +++++++++++-------- public/help/zh-TW/settings-guide.md | 37 +++- public/help/zh-TW/toolbar-guide.md | 97 ++++++----- public/releaseNotes/RELEASE_NOTES_v0.18.0.md | 101 +++++++++++ src/constants/version.ts | 2 +- 23 files changed, 716 insertions(+), 348 deletions(-) create mode 100644 public/releaseNotes/RELEASE_NOTES_v0.18.0.md diff --git a/README.en-US.md b/README.en-US.md index 83c53292..27954b45 100644 --- a/README.en-US.md +++ b/README.en-US.md @@ -17,17 +17,19 @@ - [Open the web app](https://tsukuyomi.rozx.moe/) - [Download the desktop app](https://github.com/rozx/Tsukuyomi/releases/latest) -## 🌐 New in v0.17: Three-Language UI and Per-Book Target Language +## ☁️ New in v0.18: Chapter-Level Incremental Sync and Lighter Local Embeddings -The interface, help docs, and README are available in Simplified Chinese, Traditional Chinese, and English. Each book now has its own translation target language, and source text is no longer limited to Japanese. +Sync now uploads and downloads chapters in small groups, so large libraries sync smoothly. Local embeddings switch to a smaller model that runs in a dedicated Worker and no longer freezes the UI. -- **Interface language**: switch it under Settings → General → Interface language. On first launch it matches your system language, and the preference syncs with your settings. -- **Per-book target language**: choose Simplified Chinese, Traditional Chinese, or English in the book's Translation settings. New books start with the interface language at creation time. Translations, volume and chapter titles, and term / character names are stored per language and never overwrite each other. -- **Any source language**: the AI detects the source language paragraph by paragraph; paragraphs already in the target language can be kept as-is. -- **Firecrawl fetch fallback**: when a site blocks proxy access, fetching falls back to Firecrawl (works without a key), replacing the old automatic proxy switching. Book update checks now read only the catalog and compare chapter text on demand. -- **Sync protocol v6**: book metadata and chapter content sync separately. Chapters use stable groups based on their IDs, so adding or editing one chapter normally uploads only its group, changed metadata, and the manifest. Entity deletion protection and local-only memory access times are preserved. Manifest-based reads support libraries with more than 300 sync files. **For multiple devices, back up first, upgrade every device to a version supporting v6, then sync them one at a time. The first upgrade performs a full migration.** +- **Sync protocol v6**: book metadata and chapter content are stored separately, and chapters are split into 16 fixed groups by chapter ID. Adding or editing one chapter normally uploads only its group, the changed metadata, and the manifest instead of the whole book. +- **Large-library sync**: when GitHub truncates the file list at 300 files, the client fills in the missing files from the full sync manifest and keeps going. When nothing in the library changed, the full library scan is skipped. +- **Leftover file cleanup and revision history**: under Settings → Sync, scan for and remove old remote files the manifest no longer references. Revision history groups file changes by book. +- **More reliable settings sync**: Tavily / Firecrawl API keys and each task's default model merge by their own modification time, so other settings edits no longer override them and they are no longer cleared mid-sync. +- **Bekko local embeddings**: the default model is now `bekko-embedding-v1-a25m` (384 dimensions, about 190 MiB shared by WebGPU and WASM), and inference runs in a Web Worker. The **Vector index** panel is redesigned and now reachable on tablets and narrow layouts too. -[v0.17.0 release notes (Simplified Chinese)](public/releaseNotes/RELEASE_NOTES_v0.17.0.md) · [Settings guide](public/help/en-US/settings-guide.md) +**For multiple devices, back up first, upgrade every device to v0.18.0, then sync them one at a time. The first sync migrates the whole Gist to v6. If you use local embeddings, the model must be downloaded again and vectors rebuilt.** + +[v0.18.0 release notes (Simplified Chinese)](public/releaseNotes/RELEASE_NOTES_v0.18.0.md) · [Settings guide](public/help/en-US/settings-guide.md) · [Local embeddings](public/help/en-US/local-embedding.md) ![Tsukuyomi Dashboard](public/screenshots/desktop-index.png) @@ -101,8 +103,9 @@ Tsukuyomi answers questions with the current book as context and can act through ### ☁️ Cross-Device Sync -- **Gist cloud sync**: optionally sync your data to your own GitHub Gist, with revision history and restore from usable snapshots. +- **Gist cloud sync**: optionally sync your data to your own GitHub Gist, with revision history grouped by book, restore from usable snapshots, and cleanup of leftover remote files the manifest no longer references. - **Incremental manifest sync**: `manifest.json` with SHA-256 hashes picks out changed entries, and conditional requests cut down downloads. ETags are re-checked before upload; concurrent changes trigger a re-merge and retry. +- **Chapter-group sync**: book metadata and chapter content are stored separately, and chapters are split into 16 fixed groups by ID, so editing one chapter uploads only its group. When the file list exceeds 300 files, missing files are filled in from the sync manifest, so large libraries sync completely. - **Consistent deletes**: the manifest carries deletions as tombstones, so an entry deleted on device A is not pushed back by device B. - **In-book deletion records**: terms, characters, and aliases have stable identities, and their deletion records stay with the book; deleted translation versions are recorded too, so devices returning from offline cannot resurrect them. - **Paragraph merging**: with a synced structural baseline, source edits and deletions made on one side are kept; translations are merged only for paragraphs whose source matches, and you are asked to review conflicts when both sides changed the structure. @@ -185,7 +188,7 @@ Use the [web app](https://tsukuyomi.rozx.moe/) or [download the desktop app](htt 4. In **Volume/chapter draft**, check the book details, chapter order, and text. 5. Generate an **Import plan**, check the target book, missing chapters, and translation impact, then choose **Confirm import**. When it finishes, choose **Open novel**. -See the [AI import workspace guide](public/help/en-US/import-guide.md) for more examples. You can also choose **Import from website** in the library to use the built-in rules for the Japanese novel sites `ncode.syosetu.com`, `novel18.syosetu.com`, `kakuyomu.jp`, and `syosetu.org`; other sites can be handed to the AI importer. App-format JSON book files can be added with **Import from JSON**, and full backups are restored from Settings. +See the [AI import workspace guide](public/help/en-US/import-guide.md) for more examples. You can also choose **Import from website** in the library to use the built-in rules for the Japanese novel sites `ncode.syosetu.com`, `novel18.syosetu.com`, `kakuyomu.jp`, and `syosetu.org`; other sites can be handed to the AI importer. When a site blocks proxy access, fetching falls back to Firecrawl (works without a key), and book update checks read only the catalog, comparing chapter text on demand. App-format JSON book files can be added with **Import from JSON**, and full backups are restored from Settings. ### 3. Run from source @@ -218,7 +221,7 @@ bun run dev | **Context** | [Terminology](public/help/en-US/book-details-terminology.md) \| [Character settings](public/help/en-US/book-details-characters.md) \| [Memory](public/help/en-US/book-details-memory.md) | | **AI import** | [Import workspace: steps, splitting, and adding chapters](public/help/en-US/import-guide.md) | | **Advanced** | [Chat assistant](public/help/en-US/chat-assistant-guide.md) \| [Local embeddings and chapter search](public/help/en-US/local-embedding.md) | -| **Release notes** | [v0.17.0 release notes (Simplified Chinese)](public/releaseNotes/RELEASE_NOTES_v0.17.0.md) | +| **Release notes** | [v0.18.0 release notes (Simplified Chinese)](public/releaseNotes/RELEASE_NOTES_v0.18.0.md) | > The in-app **Help** page has the user guides; docs on the main branch are synced to the [GitHub Wiki](https://github.com/rozx/Tsukuyomi/wiki) by a workflow. diff --git a/README.md b/README.md index 6c0deb23..1fdff9e6 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,19 @@ - [打开网页版](https://tsukuyomi.rozx.moe/) - [下载桌面版](https://github.com/rozx/Tsukuyomi/releases/latest) -## 🌐 v0.17 新增:三语言界面与按书目标语言 +## ☁️ v0.18 新增:按章节增量同步与更轻的本地嵌入 -界面、帮助文档与 README 提供简体中文、繁體中文和 English。每本书可以单独选择译文目标语言,原文不再限定日语。 +同步改为按章节小组上传与下载,大书库也能顺畅同步;本地嵌入换用更小的模型,并在独立 Worker 中运行,不再卡住界面。 -- **界面语言**:在「设置 → 通用设置 → 界面语言」切换,首次使用时匹配系统语言,偏好随设置同步。 -- **按书目标语言**:在书籍「翻译设置」选择简体中文、繁體中文或 English;新书默认取创建时的界面语言,各语言的译文、卷章标题和术语 / 角色译名分别保存,互不覆盖。 -- **原文不限语言**:AI 按段落判断原文语言,已经是目标语言的段落可原样保留。 -- **Firecrawl 抓取回退**:网站拦截代理访问时自动改用 Firecrawl(无需 Key 也可使用),取代原来的「自动切换代理服务」;书籍更新检查改为只读目录,按需逐章比对正文。 -- **同步协议 v6**:书籍目录与章节正文分开同步,正文按章节 ID 分成固定小组。新增或修改一章通常只上传所在小组、变化的目录和清单;不再重传整本书的所有分块。保留实体删除保护和记忆访问时间本地化。清单读取支持超过 300 个同步文件的多书库。**多设备同步请先备份,将所有设备升级到支持 v6 的版本,再逐台同步;首次升级会执行一次完整迁移。** +- **同步协议 v6**:书籍目录与章节正文分开存放,正文按章节 ID 固定分成 16 个小组。新增或修改一章通常只上传所在小组、变化的目录和清单,不再重传整本书。 +- **大书库同步**:GitHub 文件列表超过 300 个文件被截断时,按完整同步清单补齐文件继续同步;书库没有变化时跳过全库扫描。 +- **遗留文件清理与修订历史**:在「设置 → 同步设置」扫描并清理远端不再引用的旧文件;修订历史按书籍归组显示文件变化。 +- **设置同步更可靠**:Tavily / Firecrawl API Key 与各任务的默认模型分别按修改时间合并,不再被其他设置覆盖或在同步途中被清空。 +- **Bekko 本地嵌入**:默认模型换成 `bekko-embedding-v1-a25m`(384 维,WebGPU / WASM 共用约 190 MiB),推理移入 Web Worker;「向量索引」面板重做,平板与窄屏也有入口。 -[阅读 v0.17.0 发布说明](public/releaseNotes/RELEASE_NOTES_v0.17.0.md) · [设置说明](public/help/zh-CN/settings-guide.md) +**多设备同步请先备份,再把所有设备升级到 v0.18.0,最后逐台同步;首次同步会把 Gist 完整迁移到 v6。启用本地嵌入的用户需要重新下载模型并重建向量。** + +[阅读 v0.18.0 发布说明](public/releaseNotes/RELEASE_NOTES_v0.18.0.md) · [设置说明](public/help/zh-CN/settings-guide.md) · [本地嵌入](public/help/zh-CN/local-embedding.md) ![Tsukuyomi Dashboard](public/screenshots/desktop-index.png) @@ -101,8 +103,9 @@ Tsukuyomi 采用 Bring Your Own Key 模式,内置两种提供商: ### ☁️ 跨设备同步 -- **Gist 云同步**: 可选择将数据同步到自己的 GitHub Gist,支持查看修订历史和恢复可用快照。 +- **Gist 云同步**: 可选择将数据同步到自己的 GitHub Gist,支持查看按书籍归组的修订历史、恢复可用快照,并清理远端不再引用的遗留文件。 - **Manifest 增量同步**: 基于 `manifest.json` 与 SHA-256 哈希选择变化条目,使用条件请求减少下载;上传前复核 ETag,检测并发变化后重新合并重试。 +- **按章节小组同步**: 书籍目录与章节正文分开存放,正文按章节 ID 固定分成 16 组,修改一章只需上传所在小组;文件列表超过 300 个时按同步清单补齐,大书库也能完整同步。 - **跨端删除一致**: Manifest 使用墓碑(tombstones)传递删除语义,A 设备删除的条目不会被 B 设备重新推回。 - **书内实体删除记录**: 术语、角色、别名拥有稳定身份,删除记录随书长期保留;删除的译文版本同样留有记录,离线设备回流时不会复活。 - **段落合并**: 有同步结构基准时,保留单端的原文修订与删除;只有原文一致的段落才合并译文,两端都改过结构时提示检查冲突。 @@ -185,7 +188,7 @@ Tsukuyomi 采用 Bring Your Own Key 模式,内置两种提供商: 4. 在「卷章草稿」检查书籍资料、章节顺序和正文。 5. 生成「导入方案」,核对目标书籍、缺失章节和译文影响,再点击「确认导入」。完成后点「打开小说」。 -更多例子见 [AI 导入工作台指南](public/help/zh-CN/import-guide.md)。也可以在书库中选择「从网站导入」,使用内置的日文小说网站规则处理 `ncode.syosetu.com`、`novel18.syosetu.com`、`kakuyomu.jp`、`syosetu.org`;其他站点可转交 AI 导入器。应用格式的 JSON 书籍文件可通过「从 JSON 导入」添加,完整资料备份在设置中恢复。 +更多例子见 [AI 导入工作台指南](public/help/zh-CN/import-guide.md)。也可以在书库中选择「从网站导入」,使用内置的日文小说网站规则处理 `ncode.syosetu.com`、`novel18.syosetu.com`、`kakuyomu.jp`、`syosetu.org`;其他站点可转交 AI 导入器。网站拦截代理访问时会自动改用 Firecrawl 抓取(无需 Key 也可使用);书籍更新检查只读取目录,可按需逐章比对正文。应用格式的 JSON 书籍文件可通过「从 JSON 导入」添加,完整资料备份在设置中恢复。 ### 3. 从源码运行 @@ -218,7 +221,7 @@ bun run dev | **核心逻辑** | [术语管理](public/help/zh-CN/book-details-terminology.md) \| [角色设定](public/help/zh-CN/book-details-characters.md) \| [记忆系统](public/help/zh-CN/book-details-memory.md) | | **AI 导入** | [导入工作台:分步操作、拆章与补章](public/help/zh-CN/import-guide.md) | | **进阶工具** | [聊天助手实战](public/help/zh-CN/chat-assistant-guide.md) \| [本地嵌入与章节检索](public/help/zh-CN/local-embedding.md) | -| **更新日志** | [v0.17.0 发布说明](public/releaseNotes/RELEASE_NOTES_v0.17.0.md) | +| **更新日志** | [v0.18.0 发布说明](public/releaseNotes/RELEASE_NOTES_v0.18.0.md) | > 应用内「帮助」可查阅使用指南;主分支文档通过工作流同步到 [GitHub Wiki](https://github.com/rozx/Tsukuyomi/wiki)。 diff --git a/README.zh-TW.md b/README.zh-TW.md index edaddc65..d3662936 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -17,17 +17,19 @@ - [開啟網頁版](https://tsukuyomi.rozx.moe/) - [下載桌面版](https://github.com/rozx/Tsukuyomi/releases/latest) -## 🌐 v0.17 新增:三語言介面與按書目標語言 +## ☁️ v0.18 新增:按章節增量同步與更輕量的本機嵌入 -介面、說明文件與 README 提供簡體中文、繁體中文和 English。每本書可以單獨選擇譯文目標語言,原文不再限定日語。 +同步改為按章節小組上傳與下載,大型書庫也能順暢同步;本機嵌入改用更小的模型,並在獨立 Worker 中執行,不再卡住介面。 -- **介面語言**:在「設定 → 一般設定 → 介面語言」切換,首次使用時配對系統語言,偏好隨設定同步。 -- **按書目標語言**:在書籍「翻譯設定」選擇簡體中文、繁體中文或 English;新書預設取建立時的介面語言,各語言的譯文、卷章標題和術語 / 角色譯名分別保存,互不覆蓋。 -- **原文不限語言**:AI 按段落判斷原文語言,已經是目標語言的段落可原樣保留。 -- **Firecrawl 抓取備援**:網站攔截代理存取時自動改用 Firecrawl(無需 Key 也可使用),取代原來的「自動切換代理服務」;書籍更新檢查改為只讀目錄,按需逐章比對正文。 -- **同步協議 v6**:書籍目錄與章節正文分開同步,正文按章節 ID 分成固定小組。新增或修改一章通常只上傳所在小組、變更的目錄與清單;不再重傳整本書的所有分塊。保留實體刪除保護與記憶存取時間本機化。清單讀取支援超過 300 個同步檔案的多書庫。**多裝置同步請先備份,將所有裝置升級到支援 v6 的版本,再逐台同步;首次升級會執行一次完整遷移。** +- **同步協定 v6**:書籍目錄與章節正文分開存放,正文按章節 ID 固定分成 16 個小組。新增或修改一章通常只上傳所在小組、變更的目錄與清單,不再重傳整本書。 +- **大型書庫同步**:GitHub 檔案清單超過 300 個檔案被截斷時,依完整同步清單補齊檔案繼續同步;書庫沒有變化時略過全庫掃描。 +- **遺留檔案清理與修訂歷史**:在「設定 → 同步設定」掃描並清理遠端不再引用的舊檔案;修訂歷史按書籍歸組顯示檔案變化。 +- **設定同步更可靠**:Tavily / Firecrawl API Key 與各任務的預設模型分別按修改時間合併,不再被其他設定覆蓋或在同步途中被清空。 +- **Bekko 本機嵌入**:預設模型換成 `bekko-embedding-v1-a25m`(384 維,WebGPU / WASM 共用約 190 MiB),推論移入 Web Worker;「向量索引」面板重新設計,平板與窄螢幕也有入口。 -[閱讀 v0.17.0 發布說明(簡體中文)](public/releaseNotes/RELEASE_NOTES_v0.17.0.md) · [設定說明](public/help/zh-TW/settings-guide.md) +**多裝置同步請先備份,再將所有裝置升級到 v0.18.0,最後逐台同步;首次同步會將 Gist 完整遷移到 v6。啟用本機嵌入的使用者需要重新下載模型並重建向量。** + +[閱讀 v0.18.0 發布說明(簡體中文)](public/releaseNotes/RELEASE_NOTES_v0.18.0.md) · [設定說明](public/help/zh-TW/settings-guide.md) · [本機嵌入](public/help/zh-TW/local-embedding.md) ![Tsukuyomi Dashboard](public/screenshots/desktop-index.png) @@ -101,8 +103,9 @@ Tsukuyomi 採用 Bring Your Own Key 模式,內建兩種提供商: ### ☁️ 跨裝置同步 -- **Gist 雲端同步**: 可選擇將資料同步到自己的 GitHub Gist,支援查看修訂歷史和還原可用快照。 +- **Gist 雲端同步**: 可選擇將資料同步到自己的 GitHub Gist,支援查看按書籍歸組的修訂歷史、還原可用快照,並清理遠端不再引用的遺留檔案。 - **Manifest 增量同步**: 基於 `manifest.json` 與 SHA-256 雜湊選擇變化條目,使用條件請求減少下載;上傳前複核 ETag,偵測到並行變化後重新合併重試。 +- **按章節小組同步**: 書籍目錄與章節正文分開存放,正文按章節 ID 固定分成 16 組,修改一章只需上傳所在小組;檔案清單超過 300 個時依同步清單補齊,大型書庫也能完整同步。 - **跨端刪除一致**: Manifest 使用墓碑(tombstones)傳遞刪除語意,A 裝置刪除的條目不會被 B 裝置重新推回。 - **書內實體刪除記錄**: 術語、角色、別名擁有穩定身分,刪除記錄隨書長期保留;刪除的譯文版本同樣留有記錄,離線裝置回流時不會復活。 - **段落合併**: 有同步結構基準時,保留單端的原文修訂與刪除;只有原文一致的段落才合併譯文,兩端都改過結構時提示檢查衝突。 @@ -185,7 +188,7 @@ Tsukuyomi 採用 Bring Your Own Key 模式,內建兩種提供商: 4. 在「卷章草稿」檢查書籍資料、章節順序和正文。 5. 產生「匯入方案」,核對目標書籍、缺失章節和譯文影響,再點選「確認匯入」。完成後點「打開小說」。 -更多範例見 [AI 匯入工作台指南](public/help/zh-TW/import-guide.md)。也可以在書庫中選擇「從網站匯入」,使用內建的日文小說網站規則處理 `ncode.syosetu.com`、`novel18.syosetu.com`、`kakuyomu.jp`、`syosetu.org`;其他網站可轉交 AI 匯入器。應用程式格式的 JSON 書籍檔案可透過「從 JSON 匯入」添加,完整資料備份在設定中還原。 +更多範例見 [AI 匯入工作台指南](public/help/zh-TW/import-guide.md)。也可以在書庫中選擇「從網站匯入」,使用內建的日文小說網站規則處理 `ncode.syosetu.com`、`novel18.syosetu.com`、`kakuyomu.jp`、`syosetu.org`;其他網站可轉交 AI 匯入器。網站攔截代理存取時會自動改用 Firecrawl 抓取(無需 Key 也可使用);書籍更新檢查只讀取目錄,可按需逐章比對正文。應用程式格式的 JSON 書籍檔案可透過「從 JSON 匯入」添加,完整資料備份在設定中還原。 ### 3. 從原始碼執行 @@ -218,7 +221,7 @@ bun run dev | **核心邏輯** | [術語管理](public/help/zh-TW/book-details-terminology.md) \| [角色設定](public/help/zh-TW/book-details-characters.md) \| [記憶系統](public/help/zh-TW/book-details-memory.md) | | **AI 匯入** | [匯入工作台:分步操作、拆章與補章](public/help/zh-TW/import-guide.md) | | **進階工具** | [聊天助手實戰](public/help/zh-TW/chat-assistant-guide.md) \| [本地嵌入與章節檢索](public/help/zh-TW/local-embedding.md) | -| **更新紀錄** | [v0.17.0 發布說明(簡體中文)](public/releaseNotes/RELEASE_NOTES_v0.17.0.md) | +| **更新紀錄** | [v0.18.0 發布說明(簡體中文)](public/releaseNotes/RELEASE_NOTES_v0.18.0.md) | > 應用程式內「說明」可查閱使用指南;主分支文件透過工作流程同步到 [GitHub Wiki](https://github.com/rozx/Tsukuyomi/wiki)(簡體中文)。 diff --git a/public/help/en-US/ai-models-guide.md b/public/help/en-US/ai-models-guide.md index dd512844..28b548e2 100644 --- a/public/help/en-US/ai-models-guide.md +++ b/public/help/en-US/ai-models-guide.md @@ -12,14 +12,16 @@ The **AI model workbench** has three areas: - **Header**: search by name, provider, model ID, or default task; Add AI model; model counts below. - **Model list**: grouped by OpenAI or Google Gemini, with total and enabled counts. Cards support **Duplicate** (disabled initially), **Edit**, and **Delete** (confirmation required, with undo). -- **Task routing**: defaults for translation, proofreading/polishing, terminology translation, and the assistant. These settings are included in import/export. +- **Task routing**: defaults for translation, proofreading/polishing, terminology translation, and the assistant. "Select automatically" in the dropdown means no explicit choice. These settings are included in import/export. + +Task defaults also sync through Gist with the app settings. Each task's choice merges by its own modification time, so editing other settings does not override it. If the chosen model has not synced to this device yet or was deleted, the choice is kept and the task temporarily shows as unselected ("Select automatically" in the desktop dropdown, "Unset" in the tablet/mobile picker); it comes back once the model arrives. The choice is cleared only when that model is disabled or no longer used for the task. ### Mobile {#ai-models-guide-section-4} - AI models title and Add button. - With no models, an introductory card explains the two steps: add a connection, then select default tasks. - **My models**: provider groups with total/enabled counts. Tap a model to edit. -- **Task defaults**: tap a task to select from a bottom sheet. Choose **Not set** to leave it without a default. +- **Task defaults**: tap a task to select from a bottom sheet. Choose **Unset** to leave it without a default. Choices also sync through Gist; while the chosen model has not synced to this device, the task temporarily shows **Unset**. See the desktop notes above. - The model dialog is a bottom sheet with the same fields as desktop. --- @@ -95,7 +97,7 @@ These settings apply to both providers, including model listing, generation, and Each model controls its proxy independently: - **Use CORS proxy** is on by default and forwards public API requests through the configured proxy server. -- Same-origin URLs, local services such as Ollama, and LAN addresses always connect directly, regardless of the switch. +- Same-origin URLs, local services such as Ollama (`localhost`, `127.x.x.x`), and LAN addresses (such as `10.x.x.x`, `172.16–31.x.x`, `192.168.x.x`, `*.local`) always connect directly, even with "Use CORS proxy" on. - Turn it off for public self-hosted services with CORS configured or endpoints that do not need a proxy. - The switch applies only to Web. Electron always connects directly. diff --git a/public/help/en-US/book-details-memory.md b/public/help/en-US/book-details-memory.md index 2ae5ed09..352376fd 100644 --- a/public/help/en-US/book-details-memory.md +++ b/public/help/en-US/book-details-memory.md @@ -23,8 +23,8 @@ Core: Metadata: - Creation time -- Last access time for recency scoring -- Local semantic vectors +- Last access time for recency scoring. It is stored only on this device and never synced; Gist sync merges memories by content modification time, so reading, searching, or injecting a memory only updates the local access time and never triggers a sync or creates pending changes. +- Local semantic vectors, stored only on this device and never synced; each device with local embeddings generates its own. ## 🚪 Open the panel {#book-details-memory-section-4} @@ -124,13 +124,13 @@ Memories are selected in score order within the character budget, default 2000 c ### Semantic retrieval {#book-details-memory-section-18} -Enable Local embeddings in Settings to generate 384-dimensional vectors. See [Local embeddings](/help/local-embedding). +Turn on "Enable local embeddings" under **Settings → Local embeddings** to generate 384-dimensional vectors. See [Local embeddings](/help/local-embedding). - Model: `hotchpotch/bekko-embedding-v1-a25m`, about 123M total / 25M active parameters, 100+ languages, full 384-dimensional L2-normalized vectors and mean pooling. Queries and documents both encode raw text. - Long summary/content is split into segments up to 1200 characters and robustly aggregated. - Download: both backends share the compact default ONNX artifact, about 190 MiB, cached in the browser after first use. - Computation is local, without API usage; WebGPU is preferred with WASM fallback. -- Physical mobile devices disable embeddings but retain keyword/recency selection. +- Physical mobile devices (detected by user agent, regardless of window width) disable embeddings but retain keyword/recency selection. - Disabled/unavailable embeddings automatically use fallback weights. ## Search and filters {#book-details-memory-section-19} diff --git a/public/help/en-US/index.json b/public/help/en-US/index.json index 3f2ea5a8..d77a863f 100644 --- a/public/help/en-US/index.json +++ b/public/help/en-US/index.json @@ -539,6 +539,15 @@ "如何决定什么信息值得记录": "book-details-memory-section-35" } }, + { + "id": "v0.18.0", + "title": "v0.18.0", + "file": "RELEASE_NOTES_v0.18.0.md", + "path": "releaseNotes", + "category": "Release notes", + "description": "Sync protocol v6 with chapter-group incremental sync, large-library sync and leftover file cleanup, revision history grouped by book, local embeddings switched to Bekko in a Worker, and a redesigned Vector index panel", + "categoryId": "release-notes" + }, { "id": "v0.17.0", "title": "v0.17.0", diff --git a/public/help/en-US/local-embedding.md b/public/help/en-US/local-embedding.md index 71f3b3b4..1cdce91e 100644 --- a/public/help/en-US/local-embedding.md +++ b/public/help/en-US/local-embedding.md @@ -1,8 +1,8 @@ # 🧬 Local Embeddings {#local-embedding-section-1} -Local embeddings are Tsukuyomi's **offline semantic retrieval engine**. A browser model turns memories and chapters into vectors so AI can find context by meaning. Vector computation stays on the device and consumes no AI API usage. With Gist sync enabled, memory records and vectors are uploaded; context selected for AI still goes to the configured model provider. +Local embeddings are Tsukuyomi's **offline semantic retrieval engine**. A browser model turns memories and chapters into vectors so AI can find context by meaning rather than keywords. Vector computation stays on the device and consumes no AI API usage. Vectors are **local device state**: Gist sync uploads memory content but not vectors, so each device builds its own. Context selected for AI still goes to the configured model provider; see the privacy notes below. -> For controls, see [Settings → Local embeddings](/help/settings-guide#settings-guide-section-30). For memory workflows, see [Memories](/help/book-details-memory). +> This guide is for users who want to understand or troubleshoot local embeddings. For controls, see [Settings → Local embeddings](/help/settings-guide#settings-guide-section-30). For memory workflows, see [Story memories](/help/book-details-memory). --- @@ -10,8 +10,8 @@ Local embeddings are Tsukuyomi's **offline semantic retrieval engine**. A browse When enabled, background indexing covers: -- **Memories**: summary and content are split at paragraph/sentence boundaries into segments of at most 1200 characters and robustly aggregated, avoiding truncation of long memories. Semantic similarity supplies the 0.85-weight component of relevance scoring. `search_memories` accepts natural-language queries. -- **Chapters**: paragraphs form chunks targeting 100 characters; title plus first paragraph has its own chunk. `query_chapter` retrieves relevant passages without pre-generated summaries. +- **Memories**: summary and content are split at paragraph/sentence boundaries into segments of at most 1200 characters and robustly aggregated, avoiding semantic drift from truncating long memories. Semantic similarity supplies the 0.85-weight component of relevance scoring during translation. AI can also call `search_memories` with natural-language queries. +- **Chapters**: paragraphs form chunks targeting 100 characters; title plus first paragraph has its own chunk. `query_chapter` retrieves relevant passages in natural language without pre-generated summaries. Chapter semantic vectors consistently encode the source text so translated and untranslated chapters remain comparable; the target translation is used only when source text is missing. Keyword search, previews, and full-text indexes retain the source and the selected translation for the current target. Caches validate the target language and actual input signature, including translation changes that affect keywords and previews. A target change invalidates caches for background rebuild; old computations cannot overwrite the new cache. Retrieval may report rebuilding. Name expansion uses originals and current-target term/character/alias translations, never another language. Shared memories and memory vectors are retained across target changes. @@ -21,7 +21,7 @@ When disabled: - Relevance falls back to keyword/recency weights of 0.75/0.25. Similar meaning expressed differently may be missed. - `query_chapter` and its prompt guidance are removed. -- The Vector index entry is hidden and any open index panel closes. +- The "Vector index" entry is hidden and any open panel closes. --- @@ -34,64 +34,93 @@ When disabled: - **Dimensions**: full **384**, L2-normalized. - **Pooling**: mean pooling over valid tokens. Query and document use the same encoding path without task prefixes. - **Default artifact**: official `onnx/model.onnx`, about **190 MiB**. The static vocabulary table is int8-compressed while Transformer computation remains fp32. `dtype: fp32` selects this compact artifact, not a full fp32 vocabulary table. -- **Version**: `bekko-embedding-v1-a25m@384@mean@raw`. Changes to model ID, dimensions, pooling, or input scheme update the version; old memory and chapter vectors become stale and are recomputed in the background. +- **Version**: `bekko-embedding-v1-a25m@384@mean@raw`. Changes to model ID, dimensions, pooling, or input scheme update the version; old memory and chapter vectors become stale and must be recomputed (see section 5). + +> **Upgrading from GTE**: users who previously used the GTE model must download the Bekko model, and all existing vectors must be rebuilt. Until rebuilding finishes, memory scoring falls back to keywords and time decay, and chapter semantic search is unavailable. ### Backends {#local-embedding-section-5} -Model loading, tokenization, and inference run locally in a dedicated browser Worker, without a server. WASM uses one thread inside the Worker and does not require cross-origin isolation. The Worker first checks for an available WebGPU adapter and device. If none is available, it loads WASM directly. Both backends use the same default artifact. +Model loading, tokenization, and inference run in a dedicated browser Web Worker, off the page's main thread, so the interface does not freeze during download or inference. Everything still runs locally without a server. WASM uses one thread inside the Worker and does not require cross-origin isolation. The Worker first checks for an available WebGPU adapter and device. If none is available, it loads WASM directly; if WebGPU initialization fails, it also falls back to WASM automatically. Both backends use the same default artifact. | Backend | Artifact and computation | Size | Use | | :--------- | :----------------------------------------------- | :------- | :----------------------------------------------------- | | **WebGPU** | Default ONNX; int8 vocabulary / fp32 Transformer | ~190 MiB | Desktop browsers with WebGPU support | | **WASM** | The same default ONNX artifact | ~190 MiB | Browsers without an available WebGPU adapter or device | -> An unavailable adapter selects WASM for the session. Reload checks WebGPU again. Actual inference speed depends on hardware, input length, and backend. +> An unavailable adapter selects WASM for the session. Clicking "Reload" in settings checks WebGPU again. Actual inference speed depends on hardware, input length, and backend. ### Mobile restriction {#local-embedding-section-6} -Physical mobile platform detection locks the switch off. Browser WASM memory limits, typically around 2 GB, make a local model with several inference chunks prone to crashes. Keyword and recency retrieval remain available. +When Quasar detects a physical mobile device, the "Enable local embeddings" switch is **locked** off. The "Local embeddings" settings tab is still visible, but the switch is grayed out and the reason is shown. Browser WASM memory limits, typically around 2 GB, make loading the model and running several inference chunks prone to crashes. Keyword and recency retrieval remain available. Because vectors are not synced, vectors built on a desktop do not appear on a phone. + +This check uses the device type, not the window width: narrowing a desktop browser window does not count as a mobile device. --- ## 3. Enable embeddings {#local-embedding-section-7} -1. Open **Settings → Local embeddings** on a supported device. -2. Enable the main switch. -3. The first model download starts. Keep a stable connection and the window in the foreground; size depends on the backend. -4. Browser Cache Storage retains the model for later warmup without downloading again. -5. Wait for the banner to disappear and status to become `ready`. -6. Open a book and choose the right-rail batch/vector index entry to build chapter and memory vectors. This entry moved from the top bar in v0.12.1. +1. Open **Settings → Local embeddings**. +2. Turn on "Enable local embeddings". If the browser has not cached the model yet, the download **starts immediately** (about 190 MiB, the same for WebGPU and WASM) with no confirmation popup. If the status later shows "Not loaded", you can also click "Download model" to start manually. **Keep a stable connection and the window in the foreground.** +3. During the download, the settings card shows "Model loading progress" with a percentage, followed by the current phase: "Preparing model files…" → "Downloading model files…" → "Initializing model…". +4. Browser Cache Storage retains the model, so **later launches warm it up automatically without downloading again**. +5. Once the status shows "Ready", continue to the next step. +6. Open any book and click the "Vector index" entry (see section 4) to inspect or build that book's chapter and memory vectors. + +About the progress display: + +- The percentage counts only bytes of `.onnx` weight files, so finishing config or tokenizer files does not inflate it. +- While the total weight size is unknown, the bar is indeterminate (animated); it switches to a percentage once the size is known. +- The download phase tops out at 95%, leaving the rest for model initialization. When initialization finishes, the progress block collapses and the status becomes "Ready". +- Progress never goes backwards: automatic retries and the WebGPU → WASM fallback keep the value already shown. +- Leaving and reopening the settings page restores the current loading progress. --- -## 4. Batch embedding panel {#local-embedding-section-8} +## 4. Vector index panel {#local-embedding-section-8} + +The "Vector index" entry appears only on **book details pages** while local embeddings are effectively enabled; it is hidden when local embeddings are off or on a physical mobile device. Its position follows the layout: -The entry appears in book details and opens a drawer for inspecting and controlling the queue. +- Desktop layout: the right tool rail. +- Tablet layout: the right side rail. +- Narrow layouts: the top system bar. -Availability follows the effective local embeddings setting, independently of layout breakpoints. The entry is in the right tool rail on desktop and tablet layouts, and in the top bar on narrow layouts. Resizing a desktop browser does not disable it; physical mobile restrictions still apply. An open panel stays open when the layout changes. Mobile layouts use the same bottom sheet as the assistant and translation progress; desktop and tablet layouts use a right-side drawer. +Availability follows the effective local embeddings setting, independently of layout breakpoints, so narrowing a desktop browser keeps it usable. Desktop and tablet layouts open a right-side drawer; the mobile layout opens the same bottom sheet as the assistant and translation progress. An open panel stays open when the layout changes and closes when you switch to another book. + +The panel is titled "Local vector index" with the subtitle "Runs locally in your browser", followed by the current book title and chapter count. ### Status and counts {#local-embedding-section-9} -- Service: `idle` / `loading` / `ready` / `failed`, with `webgpu` or `wasm`. -- Book chapters: embedded / total / stale. -- Book memories: embedded / total / stale. +- **Status pill**: at the right of the title bar, showing Ready / Loading model / Could not load / Not ready / Disabled. +- **"Chapter vectors"**: shows "Embedded X / Y" (X counts chapters embedded with the current model version), a progress bar, and "Pending: N" (this book's chapters waiting in the queue). +- **"Memory vectors"**: likewise shows "Embedded X / Y", a progress bar, and "Pending: N". +- **Bottom status block**: model version, backend (WebGPU or "WASM (slow)"), and current status. + +When stale (version-mismatched) vectors exist, an "Embedding space upgraded" banner appears at the top of the panel, listing the outdated chapter and memory counts, with a "Rebuild now" button. Stale vectors are not counted as embedded, so totals may drop noticeably after an upgrade. -> Nonzero stale counts often follow a model upgrade. Old vectors enter the recomputation queue; embedded totals may temporarily decrease. +> Stale records are **not** recomputed automatically for every book. Missing and stale chapters and memories are queued automatically only for a book whose details page is opened while the model is ready (or when the model finishes loading while that page is open). For other books, open them to trigger this, or click "Rebuild now" / "Fill missing" in the panel. ### Actions {#local-embedding-section-10} -- **Re-embed book chapters/memories**: queue missing or stale records. -- **Batch recompute** in settings: stale records across books. -- **Test query**: enter natural language and inspect top chapter/memory results and similarity scores. -- **Pause/Resume**: manually suspend the queue. Sync adds its own temporary suspension and resumes afterward, as described below. +- **Chapters**: "Fill missing" queues this book's unembedded or stale chapters; "Rebuild all" queues every chapter in the book for recomputation. +- **Memories**: "Fill missing" queues this book's unembedded or stale memories. +- **"Rebuild now"**: appears only in the stale banner and queues this book's stale chapters and memories at once. +- These buttons are available only while the model is "Ready" and the queue is idle, avoiding conflicts with a running rebuild. +- **"Test vector search"**: opens the query dialog (see below). +- **"Pause" / "Resume"**: appear in the bottom status block only while the queue is running or paused, to suspend or continue it manually. + +**Test vector search dialog**: the intro reads "Describe a scene, character relationship, or keyword to search this book’s chapters and memories." Enter a query, then click "Query chapters" or "Query memories" (Enter runs a chapter query; memory queries match only items with the current model version). + +- Before a search, the dialog shows a "Find scenes and memories" prompt. +- While a search runs, a loading state appears. +- Results are ranked by relevance, each with its rank number and score (hover tooltip "Relevance"). Select a chapter result to open the chapter, or a memory result to view its details. +- This helps tune settings or investigate why a memory was not injected. -### Progress {#local-embedding-section-11} +### Progress and current task {#local-embedding-section-11} -The active queue shows: +While the queue runs, progress appears inside each section; there is no separate banner at the top of the panel: -- Completed/total, separately for memories and chapters -- ETA based on the latest five batch durations -- Current book ID, even while viewing another book +- "Chapter vectors" and "Memory vectors" each show "Pending: N", plus an ETA (based on the latest five batch durations) while items are pending. +- The current task card shows what the queue is processing: "This book · {kind} ×{count}" for this book, or, highlighted, "Another book · {kind} ×{count}" with that book's **title**, so you can see where the queue is busy even from another book's panel. Background memory and chapter inference uses groups of at most four inputs, split by length to reduce padding overhead. Interactive queries and paragraph reranking use groups of at most eight and take priority over waiting background jobs. Pauses between background batches reduce sustained CPU / GPU use, so whole-book rebuilds may take longer. Chapters can pause between batches; unfinished chapters remain queued and restart on resume, with results saved only after the whole chapter finishes. Panel statistics are read while the drawer is open, with updates from the same batch combined. @@ -99,28 +128,28 @@ Background memory and chapter inference uses groups of at most four inputs, spli ## 5. Versions and stale vectors {#local-embedding-section-12} -Model ID, dimensions, pooling, and input scheme define the embedding space. Changing any of them makes old/new cosine scores unreliable and requires recomputation. +`MODEL_ID + dimensions + pooling + input scheme` together define the embedding space. Changing any of them puts old and new vectors in different spaces, makes cosine scores meaningless, and requires recomputation. -`MODEL_VERSION` is stored on each memory/chunk. Shared stale checks classify: +`MODEL_VERSION` records the current space and is stored on each embedded memory/chunk. Shared `isMemoryEmbeddingStale` / `isChapterChunkStale` checks classify: - **Current**: eligible for semantic retrieval and counted as embedded. -- **Stale**: semantic scoring is skipped, keyword/recency remain, and backlog scanning queues a rebuild. +- **Stale**: semantic scoring is skipped (falling back to keywords and time decay), and backfill scanning queues a rebuild. -A model upgrade does not delete source data. Retrieval temporarily falls back until rebuilding finishes. +**A model upgrade does not lose data**: old records remain retrievable with temporarily reduced scoring until rebuilding finishes. -Startup also removes old model caches, such as `embeddinggemma` and `qwen3-embedding`, to reclaim disk space. +While local embeddings are enabled, startup removes **old model caches** such as `embeddinggemma` and `qwen3-embedding` from browser Cache Storage to reclaim disk space. The old GTE model cache (about 340–465 MB) is **intentionally kept** for now and not deleted automatically; to reclaim that space, clear this site's data in the browser (the current model then needs to be downloaded again). --- ## 6. Sync suspension {#local-embedding-section-13} -Sync and revision restoration temporarily suspend the queue: +Cloud sync and revision restoration temporarily suspend the queue through a **dedicated gate**: - Either `isSyncing` or `isRestoringSyncSnapshot` closes the gate. -- Reopening resumes only work suspended by the gate, preserving a manual Pause. -- This avoids vector writes racing snapshot replacement or indexing partially restored data. +- Reopening resumes **only** work suspended by the gate, preserving a manual "Pause". +- This avoids vector writes racing `overwriteFromSnapshot` or indexing partially restored data. -The panel shows a sync suspension banner until the gate opens. +In the UI: the panel has no dedicated sync message. During sync the queue is paused, so the bottom status block shows "Resume". When sync finishes, the queue continues automatically without a click. --- @@ -128,40 +157,40 @@ The panel shows a sync suspension banner until the gate opens. ### Download stays at 0% {#local-embedding-section-15} -- Check Hugging Face CDN connectivity. Automatic retries wait 3, 8, and 20 seconds, up to three attempts. Inspect browser Network requests for details. -- Corporate networks/VPNs may block the CDN. Check proxy/network allowlists. -- Browser Cache Storage usually needs more than 500 MB free; insufficient disk can interrupt download. +- **Check the network**: the model is hosted on the Hugging Face CDN. Automatic retries wait 3, 8, and 20 seconds, up to three attempts. Inspect requests in the browser's Network panel for details. +- **Proxies**: corporate networks/VPNs may block the CDN. Try "Proxies" settings (web only) or allowlist the CDN in the browser. +- **Disk space**: the model is about 190 MiB. Keep at least about 250 MB free for browser Cache Storage (config files and vector data add a little more); insufficient disk can interrupt the download. -### Status remains `failed` {#local-embedding-section-16} +### Status remains "Load failed" {#local-embedding-section-16} -- Read console errors prefixed `[EmbeddingService]`. - - WebGPU driver incompatibility falls back to WASM; if WASM fails too, memory may be insufficient. - - A long background-tab pause during first loading may time out. Reopen the app; cached startup is faster. -- Reload in the batch panel releases the old model and checks WebGPU again. After a Worker crash or request timeout, unfinished jobs remain queued and resume when the model recovers. Incomplete chapters never replace a saved chapter cache. +- Read console errors prefixed `[EmbeddingService]`. Common causes: + - WebGPU driver incompatibility falls back to WASM; if WASM fails too, browser memory may be insufficient. + - Switching away from the tab for a long time during the first load may time out. Reopen the app; cached startup is faster. +- In **Settings → Local embeddings**, click "Retry" when loading has failed or "Reload" when the model is ready; both release the old model and check WebGPU again. The vector index panel has no reload button. After a Worker crash or request timeout, unfinished jobs remain queued and resume when the model recovers. Incomplete chapters never replace a saved chapter cache. ### Poor retrieval {#local-embedding-section-17} -- Test the query directly and inspect top results. -- Check missing memories/chapters for stale status; re-embed and test again. -- Mixed-language queries are supported. Enter plain text without a prefix; query prefixes were removed in v0.14.3. +- Use the "Test vector search" dialog to run the query directly and inspect the results. +- If a memory/chapter that should match is missing, check whether it is stale (the "Embedding space upgraded" banner appears at the top of the panel). Click "Rebuild now" or "Fill missing", then test again. +- Mixed-language queries are supported natively. Enter plain text without a prefix; query prefixes were removed in v0.14.3. ### Queue still active after disabling {#local-embedding-section-18} -A running batch finishes before the queue stops. Forced interruption would discard that batch. The banner should disappear shortly. +This usually means a batch was running when you turned the switch off. The current batch finishes before the queue stops; forced interruption would discard that batch. The "Vector index" entry is hidden immediately; unprocessed items stay queued and continue automatically when you re-enable local embeddings. --- ## 8. Privacy and offline use {#local-embedding-section-19} -- Inference runs in the browser without an external embedding API. +- Inference runs in a browser Worker without an external embedding API. - Model weights download once into Cache Storage and can load offline. -- Enabled Gist sync uploads memory vectors as part of memory records to your secret Gist. Tsukuyomi has no intermediary sync server. See [Sync settings](/help/settings-guide#settings-guide-section-19). +- Gist sync uploads memory content, but **memory vectors and their model version tag are stripped before upload** (see [Sync settings](/help/settings-guide#settings-guide-section-19)). Vectors stay only in local IndexedDB, and each device with local embeddings enabled builds its own. **Tsukuyomi has no intermediary sync server.** --- ## Related guides {#local-embedding-section-20} -- [Settings](/help/settings-guide) -- [Memories](/help/book-details-memory) -- [AI translation](/help/book-details-translation) -- [System bar](/help/toolbar-guide) +- [Settings → Local embeddings](/help/settings-guide#settings-guide-section-30) +- [Story memories](/help/book-details-memory) +- [Translation workflow](/help/book-details-translation) +- [System bar and navigation](/help/toolbar-guide) diff --git a/public/help/en-US/settings-guide.md b/public/help/en-US/settings-guide.md index ec301aae..a078fb84 100644 --- a/public/help/en-US/settings-guide.md +++ b/public/help/en-US/settings-guide.md @@ -54,7 +54,9 @@ Configure default task models. - Selectors show only enabled models marked eligible for that task. - Each task can be **Not set**. -- A selection is cleared if its model is later disabled or made ineligible. +- Each task's selection has its own modification time and syncs with app settings, merging per task. Editing other settings does not override it. If two devices change the same task at the same moment, clearing the selection wins. +- If the selected model has not synced to this device yet or was deleted, the selection is kept and the selector temporarily shows **Not set**. The task then falls back to the first enabled model eligible for it. +- Only when the model still exists but is disabled or no longer eligible does the AI model page clear the selection. This check waits while sync or a revision snapshot restore is running, or while models are still loading. --- @@ -160,16 +162,24 @@ Configure and run Gist sync. - Interval: **1–1440 minutes**. - Conditional requests check for remote changes first. An unchanged response does not consume GitHub API quota, allowing shorter intervals. +### Sync progress {#settings-guide-sync-progress} + +While sync runs, the **Sync status** panel in the system bar (see [System bar and navigation](/help/toolbar-guide)) shows an **Overall progress** percentage and bar: + +- Progress never goes backwards and stays at 99% or below until the sync state is saved. +- The current stage appears below the bar: **Preparing local data**, **Downloading**, **Applying**, **Merging**, **Uploading**, or **Saving sync state**, followed by a description of the current step. + ### Incremental sync and concurrent changes {#settings-guide-section-23} Since v0.10.1, sync uses a **manifest**: -- **Changed uploads only**: editing one book uploads that book and the manifest; unchanged books, models, covers, and memories are not resent. +- **Changed uploads only**: only entries whose content hash changed are uploaded, plus the manifest; unchanged books, models, covers, and memories are not resent. Editing a book normally uploads only its changed chapter groups, its metadata if changed, and the manifest. See [Multilingual protocol and entity deletion](/help/settings-guide#settings-guide-language-sync). - **Selective download**: only changed entries are parsed and merged. -- **Concurrency protection**: before upload, remote state is checked again. Concurrent writes trigger pull/merge and up to three retries, then an error asks you to try later. +- **Concurrency protection**: before upload starts, remote state is checked again. Concurrent writes trigger pull/merge and another attempt, up to three attempts per sync. If the conflict persists, **Another device is writing frequently. Try again later.** appears. This retry applies only to the pre-upload check. +- **Stopping mid-upload**: uploads run in batches, and the remote is checked again before each batch. If the remote changes during upload, sync stops immediately with **Remote data changed during upload. Upload stopped; sync again.** It does not retry automatically. - **Cross-device matching**: chapters with different local IDs can match by source `webUrl`. Volumes can match by source title while preserving translations, reducing duplicates. - **Snapshot boundary**: edits after local packaging remain pending for the next sync; they are not treated as uploaded or remotely deleted. -- **Failed-entry retry**: download, parse, or application failures do not mark the entry synced. It is fetched again next time. +- **Failures abort the round**: if any entry fails to download, parse, or apply, or local data cannot be read, the whole sync round stops without uploading or advancing sync state. The data is fetched again next time, so a stale local copy cannot overwrite newer remote data. A remote read failure shows **Some remote data could not be read. Sync stopped; please try again later.**; an apply failure shows **Some remote data could not be applied. Sync state was not advanced; please try again later.** - **Deletion propagation**: manifest tombstones prevent another device from pushing deleted entries back. - **Pending changes**: the header counts books, models, covers, settings, memories, and deletion records. Open the sync popover to inspect individual changes; memory tracking was completed in v0.11.1. @@ -226,6 +236,13 @@ With sync enabled and a Gist ID: - Expand a revision's file changes - Restore a revision +An expanded revision shows **Revision contents** with an "N items · M files" summary at the top, and the hint **Grouped by book. Expand an item to view its files.** at the bottom: + +- **Grouped by book**: files for the same book are combined into one item whose summary counts each category: **Book data**, **Chapter data**, **Memories**, and **Chunk index**. If the book is not in the local library, the first 8 characters of its ID replace the title. +- **Other items**: app settings, AI model settings, and cover history are combined into **App configuration**. The manifest appears as **Sync manifest**; unrecognized files appear as **Other file**. +- **Size and change**: each item and file shows its size and a signed size change. Groups that were entirely added or removed are marked **Added** or **Removed**. Expand an item to see each file's category, name, size, and change. +- **Leftover files**: when the revision's manifest is complete and readable, book files outside that revision's restored library are grouped under **Leftover files** with "Excluded from this revision’s restored library · N files". If they still exist in the latest version, use [Clean up remote leftover files](/help/settings-guide#settings-guide-sync-cleanup) to remove them. + > Since v0.11.1, restoration supports the manifest layout fully, including models, cover history, and memories. Older aggregate revisions use the legacy parser. A local backup with chapter source and translations is created first. Failure to read any required entry aborts restoration and preserves current data. Application failure rolls back independently stored chapter content as well as metadata. @@ -234,6 +251,11 @@ A local backup with chapter source and translations is created first. Failure to In sync settings, select **Scan for leftover files**, review the files grouped by book and their sizes, then confirm removal. The scan uses the latest remote manifest, not your local library. Only previewed, unreferenced files are removed; active data and earlier revisions are preserved. +- **Availability**: shown when Gist sync is enabled and a Gist ID is set, above **Revision history**. +- **Preview**: after scanning, "N files to remove · size" appears; when some sizes are unknown, the size is prefixed with "≥". Files are grouped by book, followed by **Only the latest version is cleaned up. Earlier revisions keep their files.** The scan button becomes **Scan again**. +- **Removal**: select **Remove these N files** and confirm in the dialog. The remote revision is checked again before removal. +- When nothing can be removed, **No removable leftover files found in this scan.** appears. + Cleanup stops if the manifest is damaged, a format upgrade is incomplete, or required content is known to be missing. If the remote revision changes before confirmation, scan again. Gists with more than 300 files are marked as partially scanned: only returned, verified candidates are considered, and extra files for active entries are kept. You can scan again after cleanup, but this cannot guarantee that all leftovers are gone. A complete inventory requires fetching the entire Gist through Git. ### Delete Gist {#settings-guide-section-28} @@ -265,8 +287,11 @@ Local vectors support semantic memory search and `query_chapter`. See [Local emb - Automatic backend order: - **WebGPU** when a GPU adapter is available. - **WASM** when no adapter is available. Both backends use the same default artifact. +- If WebGPU fails to load, the model reloads on WASM automatically. Other load failures retry automatically up to three times. Completed download progress is kept during fallback and retries. +- Inference runs in a separate Web Worker and does not block the page. - Version: `bekko-embedding-v1-a25m@384@mean@raw`. Changes to model, dimensions, pooling, or input scheme update the version; stale vectors are recomputed in the background. - Automatic warmup requires embeddings enabled and an already cached model. The first download takes time. +- **Model loading progress**: while loading, a **Model loading progress** percentage and bar appear, with **Preparing model files…**, **Downloading model files…**, and **Initializing model…** below in turn, plus the current file name during download. The percentage tracks the model weight download and stays at 95% or below until initialization finishes. If the total file size is not known yet, the bar is indeterminate. ### Memory injection {#settings-guide-section-33} diff --git a/public/help/en-US/toolbar-guide.md b/public/help/en-US/toolbar-guide.md index b4ab7738..cb2f3536 100644 --- a/public/help/en-US/toolbar-guide.md +++ b/public/help/en-US/toolbar-guide.md @@ -1,8 +1,8 @@ # 🛠️ System Bar and Navigation {#toolbar-guide-section-1} -Tsukuyomi adapts its system bar to each device while keeping the same core functions: **navigation, AI thinking, sync, notifications, and right panels**. +Tsukuyomi adapts its system bar to each device while keeping the same core functions: **navigation, AI thinking, sync, notifications, right panels, and the vector index**. -> Since v0.12.1, desktop uses a workspace with icon rails and separate chat/progress panels. Batch embeddings moved from the top bar to the right rail. Tablet/mobile retain compact status chips. +> Since v0.12.1, desktop uses a workspace with icon rails and separate chat/progress panels. Tablet/mobile retain compact status chips. The Vector index entry is available in all three layouts: the desktop right rail, the tablet book-details side rail, and the mobile top system bar. --- @@ -30,20 +30,20 @@ From left to right: - **Menu (`☰`)**: expand/collapse the left rail. - **Brand**: logo, name, and Moonlit Translator subtitle. - **AI thinking**: - - ✨ when idle. - - A status pill and animated dots while thinking or processing. + - ✨ with "AI thinking" when idle. + - A status pill with "AI Thinking" or "AI Processing" and a pulsing dot while a task runs. - Opens the thinking panel: active model/type/status/time, reasoning messages, ten recently completed tasks, stop, and clear. - **Sync status**: | State | Display | Meaning | | :------- | :---------------------- | :------------------------------------------------------------------- | | Disabled | Cloud / Sync | No Gist configuration or automatic sync disabled | -| Unsynced | Cloud / Unsynced | Local and remote state have not aligned | +| Unsynced | Cloud / Not synced | Local and remote state have not aligned | | Synced | Cloud check / countdown | Time until the next sync | | Pending | Up arrow / N changes | Books, models, covers, settings, memories, or deletions await upload | -| Syncing | Spinner | Upload, download, or merge in progress | +| Syncing | Spinner / Syncing | Upload, download, or merge in progress | -Open the sync panel for upload/download/details, last and next sync times, remote statistics, and stage progress. Download is unavailable when disabled, already syncing, or missing a Gist ID. +Click to open the "Sync status" panel: "Last synced", "Next sync", the "Pending changes" list, and "Remote data" counts (books / AI models). While syncing, it shows an "Overall progress" percentage and bar; the percentage never goes backwards. The current stage appears under the bar: "Preparing local data" / "Downloading" / "Uploading" / "Applying" / "Merging" / "Saving sync state". The bottom of the panel has a "Sync" button; turning on "Force push local data to remote (replace remote)" changes it to "Force push to remote". The button is unavailable when Gist sync is disabled, a sync is running, or deleted items are being restored. - **Message history**: 🔔 opens previous notifications. Unread counts above 99 show `99+`. Filter by error/warning/success/info, delete one or all messages, and undo supported actions. @@ -51,12 +51,13 @@ Open the sync panel for upload/download/details, last and next sync times, remot ### 2) Left rail (`AppSideMenu`) {#toolbar-guide-section-4} -The narrow rail displays icons; hover or choose `☰` to expand. +The narrow rail displays icons; choose `☰` to expand it into a full menu that also shows the "Navigation" group and the "Favorite books" list. Main navigation: - 🏠 **Home** — `/` - 📚 **Books** — `/books` +- 📥 **AI import** — `/import` - ✨ **AI models** — `/ai` Bottom navigation: @@ -66,13 +67,13 @@ Bottom navigation: ### 3) Right rail (`AppRightPanelDesktop`) {#toolbar-guide-section-5} -A resizable panel with rail entries: +When collapsed, the right side is an icon rail. Selecting an entry expands its panel (drag to resize); the panel's close button folds it back into the rail. Rail entries: - 💬 **Tsukuyomi**: chat sessions, messages, input, book/chapter/paragraph context, todos, and context usage. - 📊 **Translation progress**: translation/polishing/proofreading progress. A badge counts active tasks; a new task activates its panel and can be canceled there. -- 🧬 **Vector index**, only in book details: chapter/memory counts, stale entries, batch rebuild, test query, pause/resume. Requires local embeddings; see [Local embeddings](/help/local-embedding). +- ⚡ **Vector index**, only in book details when local embeddings are actually available: opens the "Local vector index" panel. See [Local embedding access](/help/toolbar-guide#toolbar-guide-section-10) below. -> Selecting an entry activates that panel. Inactive panels do not mount their watchers. +> Selecting an entry activates that panel. Inactive panels do not mount their watchers. On the AI import page (`/import`), the right side always shows the import assistant chat. ### 4) Footer (`AppFooter`) {#toolbar-guide-section-6} @@ -91,59 +92,79 @@ See [Settings → About](/help/settings-guide). ## 📱 Tablet layout {#toolbar-guide-section-7} -A compact system bar: +A compact system bar plus a left navigation rail; the Books and book-details pages also have a vertical tool rail on the right: ``` +--------------------------------------------------------+ | TabletSysBar: brand | version | AI | Sync | Notify | -+--------------------------------------------------------+ -| | -| Workspace (route content) | -| | -+--------------------------------------------------------+ ++------+------------------------------------------+------+ +| Nav | | Side | +| rail | Workspace (route content) | rail | +| | | | ++------+------------------------------------------+------+ ``` ### System bar {#toolbar-guide-section-8} -- **Left**: brand and version. -- **Right**: AI thinking, sync, and message history chips, with shorter labels and the same behavior as desktop. +- **Left**: brand and current version. +- **Right**: AI thinking, sync, and notification chips, with the same behavior as desktop in a more compact form: "AI thinking" when idle, "AI is thinking" during a task; the sync chip shows "Sync", "Syncing", "N changes", or "Synced". ### Navigation and right panel {#toolbar-guide-section-9} -- The menu icon opens a Quasar drawer with Home, Books, AI, Settings, and Help. -- Separate chat/progress controls open the right panel as needed. Behavior matches desktop without an icon rail. +- **Left navigation rail** (`TabletNavRail`): the logo returns home; main entries are Home / Library / AI import / AI models, with Help / Settings pinned at the bottom. +- **Right tool rail** (`TabletSideRail`, on Books and book details): list/contents toggle, Tsukuyomi, and Translation progress (with a task badge). Book details also shows the Vector index button when local embeddings are available. +- Tsukuyomi and Translation progress slide in from the right as overlay panels; tap the mask to close. Behavior matches desktop. ### Local embedding access {#toolbar-guide-section-10} -Tablet has no dedicated Vector index button. A physical mobile device disables embeddings by platform detection. A desktop browser resized to tablet width can enable them under Settings, but the batch drawer renders only in desktop layout. +The Vector index button exists in all three layouts and appears only on **book details** (`/books/:id` and subroutes) when local embeddings are **actually available**: + +- Desktop: the right icon rail (⚡). +- Tablet: the book-details right tool rail. +- Mobile: the leftmost chip in the top system bar. + +It opens the "Local vector index" panel. The panel is mounted once in the main layout: a right drawer on desktop and tablet, a bottom sheet on mobile. It stays open across breakpoint changes and closes automatically when you switch to another book or local embeddings become unavailable. Panel contents: + +- Status: "Ready" / "Loading model" / "Could not load" / "Not ready" / "Disabled". +- "Chapter vectors": "Embedded X / Y", pending count, and progress bar, with "Fill missing" and "Rebuild all". +- "Memory vectors": the same progress display, with "Fill missing". +- When old vectors are detected, an "Embedding space upgraded" banner offers "Rebuild now". +- "Test vector search": search this book's chapters or memories in natural language. +- The footer shows the model, backend, and queue status, with "Pause" / "Resume" for the embedding queue. + +Availability is decided by the **device user agent** (`isMobileDevice()`), not window width: phones, tablets, and other mobile devices always have it disabled; a desktop browser narrowed to tablet or phone width can still use it after turning it on under **Settings → Local embeddings**. See [Local embeddings](/help/local-embedding). --- ## 📲 Mobile layout {#toolbar-guide-section-11} -A minimal system bar and full-width content: +A minimal system bar, full-width content, and a bottom tab bar: ``` -+----------------------------------+ -| MobileSysBar: | -| logo | AI | Sync | Help | -+----------------------------------+ -| | -| Route content (full width) | -| | -+----------------------------------+ ++----------------------------------------+ +| MobileSysBar: | +| logo | Vectors | AI | Sync | 🔔 | Help | ++----------------------------------------+ +| | +| Route content (full width) | +| | ++----------------------------------------+ +| MobileTabBar: Home | Library | Chat | +| AI | Settings | ++----------------------------------------+ ``` ### System bar {#toolbar-guide-section-12} - **Left**: logo, name, and version. - **Right**: - - AI thinking - - Sync - - Message history + - **Vector index** (⚡, only on book details when local embeddings are available; opens a bottom sheet) + - AI thinking ("AI is thinking" during a task) + - Sync ("Syncing", "N changes", or "Synced" when applicable) + - Notifications - **Help**, a mobile-specific chip opening `/help` -> Mobile navigation uses Home shortcuts, drawers, and routes, without a left rail. Local embeddings and `query_chapter` are unavailable on mobile devices to avoid WASM memory failures. +> The main mobile navigation is the bottom tab bar: Home / Library / Tsukuyomi / AI models / Settings. Tsukuyomi and Translation progress open as bottom sheets; there is no left rail. Local embeddings (including semantic search in `query_chapter`) are gated by the device user agent and disabled on phones and other mobile devices to avoid WASM memory failures; a desktop browser narrowed to phone width is not affected. --- @@ -166,7 +187,7 @@ Sync configuration, manual sync, queue pause, and message clearing update the sy A: Removed in v0.12. AI uses `query_chapter` for source retrieval without pre-generated summaries. **Q: Where is Vector index?** -A: It appears only on `/books/:id` and subroutes when local embeddings are enabled. See [Local embeddings](/help/local-embedding). +A: It appears only on `/books/:id` and subroutes when local embeddings are actually available: in the desktop right rail, the tablet book-details tool rail, or the mobile top system bar. Enable it under **Settings → Local embeddings** first; it cannot be enabled on mobile devices. See [Local embeddings](/help/local-embedding). **Q: Why does Syncing stay visible?** A: Check the Gist configuration, network, and token permissions. Open message history for the error. diff --git a/public/help/zh-CN/ai-models-guide.md b/public/help/zh-CN/ai-models-guide.md index 7dde8e0a..fb0e9cd5 100644 --- a/public/help/zh-CN/ai-models-guide.md +++ b/public/help/zh-CN/ai-models-guide.md @@ -12,14 +12,16 @@ - **顶部**:搜索框(名称 / 提供商 / 模型 ID / 默认任务)与「添加 AI 模型」按钮,下方是模型数量统计。 - **模型列表**:按提供商(OpenAI / Google Gemini)分组,组标题显示模型数与已启用数。每张模型卡片可 **复制**(副本默认禁用)、**编辑**、**删除**(确认后执行,支持撤销)。 -- **任务路由**:为翻译(初译)、校对 / 润色、术语翻译、AI 助手分别选择默认模型,设置会随导入导出一起保存。 +- **任务路由**:为翻译(初译)、校对 / 润色、术语翻译、AI 助手分别选择默认模型,下拉框中的「自动选择」表示不指定。设置会随导入导出一起保存。 + +任务默认模型还会随应用设置通过 Gist 同步。每个任务的选择按各自的修改时间单独合并,修改其他设置不会覆盖它。如果选中的模型尚未同步到本设备或已被删除,选择会被保留,期间该任务暂时显示为未选择(桌面版下拉框为「自动选择」,平板 / 手机版选择列表中为「未设置」),模型同步到达后自动恢复;只有当该模型被禁用或不再用于该任务时,选择才会被清除。 ### 手机版 {#ai-models-guide-section-4} - 顶部为「AI 模型」标题与「添加」按钮。 - 尚未配置模型时显示引导卡片:「添加 AI 模型」按钮和「两步开始使用」说明(添加模型连接 → 选择默认任务)。 - **我的模型**:按提供商分组,显示模型数和已启用数,点击模型即可编辑。 -- **任务默认模型**:点击任务行,从底部弹出的列表中选择模型,也可选择「未设置」让该任务不使用默认模型。 +- **任务默认模型**:点击任务行,从底部弹出的列表中选择模型,也可选择「未设置」让该任务不使用默认模型。选择同样随 Gist 同步;所选模型尚未同步到本设备时会暂时显示「未设置」,规则见上文桌面版说明。 - 模型对话框在手机上以底部抽屉形式打开,字段与桌面版一致。 --- @@ -95,7 +97,7 @@ 每个模型可独立控制是否使用 CORS 代理: - **使用 CORS 代理**(默认开启):公网 API 请求通过配置的 CORS 代理服务器转发。 -- 同源地址、本机服务(如 Ollama)和局域网地址始终直接访问,无需关闭开关。 +- 同源地址、本机服务(如 Ollama,`localhost`、`127.x.x.x`)和局域网地址(如 `10.x.x.x`、`172.16–31.x.x`、`192.168.x.x`、`*.local`)始终直接访问,即使开启「使用 CORS 代理」也不经过代理,无需关闭开关。 - 关闭后请求将直连模型服务,适用于: - 已配置好跨域策略的自建服务 - 不需要代理的 API 端点 diff --git a/public/help/zh-CN/book-details-memory.md b/public/help/zh-CN/book-details-memory.md index 85fa0384..f35a4395 100644 --- a/public/help/zh-CN/book-details-memory.md +++ b/public/help/zh-CN/book-details-memory.md @@ -27,8 +27,8 @@ **元数据**: - 📅 **创建时间**: 记忆创建的时间 -- 🕘 **最后访问时间**: 用于时间衰减评分 -- 🧬 **语义向量**: 本地嵌入模型生成的多维向量(用于语义检索) +- 🕘 **最后访问时间**: 用于时间衰减评分。只保存在本设备,不参与同步;Gist 同步按内容修改时间合并记忆,因此读取、检索或注入记忆只会更新本机访问时间,不会触发同步或产生待同步变更 +- 🧬 **语义向量**: 本地嵌入模型生成的多维向量(用于语义检索)。只保存在本设备,不参与同步,每台启用本地嵌入的设备各自生成 ## 🚪 访问记忆管理面板 {#book-details-memory-section-4} @@ -177,7 +177,7 @@ - **长记忆分段嵌入**:v0.14.3 起,单条记忆的"摘要 + 内容"按段落和句末标点切成 ≤1200 字的短段分别嵌入并稳健聚合,避免长记忆被截断后语义漂移。 - **下载体积**:两个后端共用约 190 MiB 的默认压缩 ONNX 文件;首次下载后复用浏览器缓存。 - **完全本地运行**,不消耗 API 额度;优先使用 WebGPU,回退到 WASM。 -- **移动端强制禁用**本地嵌入(浏览器 WASM 内存限制),但关键词匹配和时间衰减信号仍然生效。 +- **移动设备强制禁用**本地嵌入(浏览器 WASM 内存限制;按设备 UA 判定,与窗口宽度无关),但关键词匹配和时间衰减信号仍然生效。 - 即使不启用本地嵌入,关键词匹配和时间衰减信号仍会工作,系统会自动使用**退化评分权重**保证记忆依然有参考价值。 ## 记忆搜索与筛选 {#book-details-memory-section-19} diff --git a/public/help/zh-CN/index.json b/public/help/zh-CN/index.json index 1e63f81b..75a4a1d5 100644 --- a/public/help/zh-CN/index.json +++ b/public/help/zh-CN/index.json @@ -539,6 +539,15 @@ "如何决定什么信息值得记录": "book-details-memory-section-35" } }, + { + "id": "v0.18.0", + "title": "v0.18.0", + "file": "RELEASE_NOTES_v0.18.0.md", + "path": "releaseNotes", + "category": "更新日志", + "description": "同步协议 v6 按章节小组增量同步、大书库同步与遗留文件清理、修订历史按书归组、本地嵌入换用 Bekko 模型并移入 Worker、「向量索引」面板重做", + "categoryId": "release-notes" + }, { "id": "v0.17.0", "title": "v0.17.0", diff --git a/public/help/zh-CN/local-embedding.md b/public/help/zh-CN/local-embedding.md index cee9eac9..8f8bafcb 100644 --- a/public/help/zh-CN/local-embedding.md +++ b/public/help/zh-CN/local-embedding.md @@ -1,6 +1,6 @@ # 🧬 本地嵌入(Local Embedding) {#local-embedding-section-1} -本地嵌入是 Tsukuyomi 的**离线语义检索引擎**:完全在浏览器内运行嵌入模型,把记忆与章节内容映射成向量,让 AI 能按语义而不是关键词找到相关上下文。向量计算在本机完成,不消耗 AI API 额度。启用 Gist 同步后,记忆及其向量会随记忆条目上传;实际发送给 AI 的上下文仍按模型请求发送,详见本文隐私说明。 +本地嵌入是 Tsukuyomi 的**离线语义检索引擎**:完全在浏览器内运行嵌入模型,把记忆与章节内容映射成向量,让 AI 能按语义而不是关键词找到相关上下文。向量计算在本机完成,不消耗 AI API 额度。向量属于**设备本地状态**:Gist 同步只上传记忆内容,不上传向量,每台设备各自生成;实际发送给 AI 的上下文仍按模型请求发送,详见本文隐私说明。 > 本指南面向想了解或排查本地嵌入功能的用户。设置入口请见[设置说明](/help/settings-guide#settings-guide-section-30),记忆相关用法见[记忆管理](/help/book-details-memory)。 @@ -34,64 +34,93 @@ - **维度**:使用完整的 **384 维**向量,并做 L2 归一化。 - **Pooling**:mean pooling — 对有效 token 的表征求平均。query 与 document 共用同一编码路径,不添加任务前缀。 - **默认文件**:官方 `onnx/model.onnx`,约 **190 MiB**;静态词表已做 int8 压缩,Transformer 计算保持 fp32。`dtype: fp32` 选择这个已压缩文件,不表示下载完整 fp32 词表。 -- **模型版本号**:`bekko-embedding-v1-a25m@384@mean@raw`。模型 ID、维度、pooling 或输入方案变化都会更新版本号,旧记忆与章节向量会被判定为 stale,后台重新计算。 +- **模型版本号**:`bekko-embedding-v1-a25m@384@mean@raw`。模型 ID、维度、pooling 或输入方案变化都会更新版本号,旧记忆与章节向量会被判定为 stale,需要重新计算(见第 5 节)。 + +> **从 GTE 升级**:此前使用 GTE 模型的用户需要重新下载 Bekko 模型,已有向量全部需要重建。重建完成前,记忆打分降级为关键词 + 时间衰减,章节语义搜索暂不可用。 ### 运行后端 {#local-embedding-section-5} -模型加载、分词和推理都在独立浏览器 Worker 中运行,仍完全在本机完成,不需要服务器。WASM 在 Worker 内使用单线程,无需跨源隔离。Worker 内优先检查是否能取得 WebGPU 适配器并创建设备;不可用时直接使用 WASM。两个后端共用同一份默认模型文件。 +模型加载、分词和推理都在独立浏览器 Web Worker 中运行,不占用页面主线程,下载或推理期间界面不会卡顿;整个过程仍完全在本机完成,不需要服务器。WASM 在 Worker 内使用单线程,无需跨源隔离。Worker 内优先检查是否能取得 WebGPU 适配器并创建设备;不可用时直接使用 WASM;WebGPU 初始化失败时也会自动回落到 WASM。两个后端共用同一份默认模型文件。 | 后端 | 文件与计算 | 大小 | 适用场景 | | :--------- | :-------------------------------------- | :------- | :-------------------------------- | | **WebGPU** | 默认 ONNX,词表 int8 / Transformer fp32 | ~190 MiB | 支持 WebGPU 的桌面浏览器 | | **WASM** | 同一默认 ONNX 文件 | ~190 MiB | WebGPU 适配器或设备不可用的浏览器 | -> 适配器不可用时,本会话使用 WASM;手动「重新加载」会重新检查 WebGPU。实际推理速度取决于设备、输入长度和运行后端。 +> 适配器不可用时,本会话使用 WASM;在设置中点击「重新加载」会重新检查 WebGPU。实际推理速度取决于设备、输入长度和运行后端。 ### 移动端强制禁用 {#local-embedding-section-6} -Quasar 检测到移动平台时会**锁定**「启用本地嵌入」开关为关闭,并在 UI 上灰显。原因是浏览器 WASM 的内存上限(一般 ~2GB)在模型加载与多 chunk 推理时极易触发崩溃。移动端依然能正常使用关键词匹配 + 时间衰减信号。 +Quasar 检测到物理移动设备时会**锁定**「启用本地嵌入」开关为关闭:「本地嵌入」设置页仍然可见,但开关灰显不可操作,并显示不支持原因。原因是浏览器 WASM 的内存上限(一般 ~2GB)在模型加载与多 chunk 推理时极易触发崩溃。移动端依然能正常使用关键词匹配 + 时间衰减信号。由于向量不随同步上传,桌面端生成的向量也不会出现在手机上。 + +该判断只看设备类型,不看窗口宽度:桌面浏览器缩窄窗口不会被当成移动设备。 --- ## 3. 启用步骤 {#local-embedding-section-7} -1. 进入 **设置 → 本地嵌入**(移动端不可见)。 -2. 打开「启用本地嵌入」总开关。 -3. 系统弹出模型下载提示,开始首次下载(**需要稳定网络,请保留窗口在前台**;下载体积取决于运行后端,见上表)。 +1. 进入 **设置 → 本地嵌入**。 +2. 打开「启用本地嵌入」总开关。若浏览器尚未缓存模型,会**直接开始下载**(约 190 MiB,WebGPU 与 WASM 相同),不会弹出确认窗口;之后若状态为「未加载」,也可点击「下载模型」手动开始。**下载需要稳定网络,请保留窗口在前台。** +3. 下载期间设置卡片内显示「模型加载进度」及百分比,下方提示当前阶段:「正在准备模型文件…」→「正在下载模型文件…」→「正在初始化模型…」。 4. 下载完成后模型会被浏览器 Cache Storage 持久化,**之后启动会自动预热,无需再次下载**。 -5. 进度横幅消失、状态徽章变为 `ready` 后,便可进入下一步。 -6. 打开任意书籍,点击右栏图标轨道里的「批量嵌入」入口(v0.12.1 起从顶部工具栏迁移到此),按需触发本书的章节 / 记忆向量化。 +5. 状态变为「已就绪」后,便可进入下一步。 +6. 打开任意书籍,点击「向量索引」入口(见第 4 节),查看或按需补建本书的章节 / 记忆向量。 + +关于进度显示: + +- 百分比只统计 `.onnx` 权重文件的下载字节,配置、分词器等小文件完成不会让进度虚高。 +- 权重总大小未知时进度条显示为不确定状态(来回滚动),拿到大小后切换为百分比。 +- 下载阶段最多显示到 95%,剩余部分留给模型初始化;初始化完成后进度块收起,状态变为「已就绪」。 +- 进度不会倒退:自动重试或 WebGPU 回落到 WASM 时,进度条沿用已显示的数值。 +- 关闭再重新打开设置页时,会恢复当前加载进度。 --- -## 4. 批量嵌入面板 {#local-embedding-section-8} +## 4. 向量索引面板 {#local-embedding-section-8} + +「向量索引」入口只在**书籍详情页**、且本地嵌入有效启用时显示;关闭本地嵌入或在物理移动设备上时入口隐藏。入口位置随布局变化: -进入书籍详情页后,右栏图标轨道会出现「批量嵌入」入口(书籍详情页之外不显示)。点击展开抽屉式面板,可在此查看与控制嵌入队列。 +- 桌面布局:右侧工具栏。 +- 平板布局:右侧侧轨。 +- 窄屏布局:顶部工具栏。 -入口按本地嵌入是否有效启用显示,不以布局断点判断:桌面和平板布局位于右侧工具栏,窄屏布局位于顶部工具栏。桌面浏览器缩窄窗口仍可使用,物理移动设备的禁用规则不变。已打开的面板在布局切换时保留。手机布局使用与助手、翻译进度一致的底部抽屉;桌面和平板使用右侧抽屉。 +入口按本地嵌入是否有效启用显示,不以布局断点判断,桌面浏览器缩窄窗口仍可使用。点击后,桌面和平板布局打开右侧抽屉,手机布局打开与助手、翻译进度一致的底部抽屉。已打开的面板在布局切换时保留;切换到另一本书时自动关闭。 + +面板标题为「本地向量索引」,副标题「浏览器本地运行」,下方显示当前书名和章节数。 ### 状态徽章与统计 {#local-embedding-section-9} -- **嵌入服务状态**:`idle` / `loading` / `ready` / `failed`,并显示当前运行后端(`webgpu` 或 `wasm`)。 -- **本书章节**:已嵌入 / 总数 / stale(版本不匹配,待重算)。 -- **本书记忆**:已嵌入 / 总数 / stale。 +- **状态徽章**:标题栏右侧显示 就绪 / 模型加载中 / 加载失败 / 未就绪 / 已禁用。 +- **「章节向量」**:显示「已嵌入 X / Y」(X 为当前模型版本已嵌入的章节数)、进度条,以及「待处理:N」(队列中本书等待处理的章节数)。 +- **「记忆向量」**:同样显示「已嵌入 X / Y」、进度条和「待处理:N」。 +- **底部状态区**:显示模型版本号、运行后端(WebGPU 或「WASM (慢)」)和当前状态。 + +存在 stale 向量(版本不匹配)时,面板顶部显示「Embedding 空间已升级」横幅,列出旧版章节与记忆数量,并提供「立即重建」按钮。stale 向量不计入"已嵌入",因此升级后数字可能明显回落。 -> "stale" 计数非零时通常意味着模型版本最近做了升级(例如从 GTE 切到 Bekko),所有旧版本 chunk 都会被判定为 stale 自动进入重算队列,期间统计可能短暂回落。 +> stale 条目**不会**在所有书籍中自动重算:只有在模型就绪时打开某本书的详情页(或详情页打开期间模型加载完成),才会自动把本书缺失和 stale 的章节 / 记忆入队。其他书籍请打开后自动触发,或在面板中点击「立即重建」/「回填缺失」。 ### 操作 {#local-embedding-section-10} -- **重新嵌入本书章节** / **重新嵌入本书记忆**:把所有未嵌入或 stale 的条目入队。 -- **批量重算**(设置弹窗):跨书籍触发全部 stale 条目重算。 -- **测试查询**:直接输入任意自然语言查询,立刻看到相似度最高的 N 条章节 / 记忆,相似度分数实时显示,便于调参或排查"为什么这条记忆没被注入"。 -- **暂停 / 继续**:暂停按钮直接挂起队列;同步进行中时面板会显示「同步期间已暂停」提示,结束后自动恢复(详见第 6 节 sync gate)。 +- **章节**:「回填缺失」把本书未嵌入或 stale 的章节入队;「全部重算」把本书所有章节重新入队计算。 +- **记忆**:「回填缺失」把本书未嵌入或 stale 的记忆入队。 +- **「立即重建」**:仅在 stale 横幅中出现,一次性把本书 stale 的章节和记忆入队。 +- 以上按钮只在模型「就绪」且队列空闲时可用,避免与正在进行的重建冲突。 +- **「测试向量查询」**:打开查询对话框(见下文)。 +- **「暂停」/「恢复」**:只在队列运行或已暂停时出现在底部状态区,用于手动挂起或继续队列。 + +**测试向量查询对话框**:顶部说明「用自然语言描述剧情、人物关系或关键词,检索本书章节与记忆。」输入查询语句后点击「查询章节」或「查询记忆」(回车默认执行章节查询;记忆查询仅匹配当前模型版本的条目)。 + +- 未查询时显示「查找书中的情节和记忆」提示。 +- 查询中显示「正在检索章节…」或「正在检索记忆…」加载状态。 +- 结果按相关度排序,每条显示排名序号和相关度分数(鼠标悬停提示「相关度」);点击章节结果跳转到对应章节,点击记忆结果查看详情。 +- 便于调参或排查"为什么这条记忆没被注入"。 -### 进度横幅 {#local-embedding-section-11} +### 进度与当前任务 {#local-embedding-section-11} -队列活跃时面板顶部显示进度条,包含: +队列运行时,进度直接显示在各分区内,面板顶部没有单独的横幅: -- 已完成 / 总数(按 `memory` 与 `chapter` 分别拆分)。 -- ETA:基于最近 5 个批次的平均耗时估算。 -- 当前批次正在处理的书籍 ID(即便你切换到另一本书的面板,也能看到队列在哪儿忙)。 +- 「章节向量」与「记忆向量」各自显示「待处理:N」,有待处理条目时同时显示 ETA(基于最近 5 个批次的平均耗时估算)。 +- 当前任务卡片显示队列正在处理的内容:处理本书时显示「本书 {类型} ×{数量}」;处理其他书籍时以醒目颜色显示「其它书籍 · {类型} ×{数量}」并附上该书**书名**,即便你在另一本书的面板里也能看到队列在哪儿忙。 后台记忆和章节推理每组最多 4 条输入,并按长度拆组,降低长短文本混合时的计算开销;前台查询及段落重排每组最多 8 条,优先于等待中的后台任务。后台批次之间会留出计算空档,因此整书重算可能更慢,以减少 CPU / GPU 持续占用。章节在批次之间响应暂停;未完成的章节保留在队列,继续后重新计算,完成整章后再统一保存。面板关闭时暂停统计读取,打开后合并同批刷新。 @@ -104,11 +133,11 @@ Quasar 检测到移动平台时会**锁定**「启用本地嵌入」开关为关 应用通过 `MODEL_VERSION` 常量记录当前空间身份,每条已嵌入的记忆 / chunk 都会同时存这个版本号。检索与统计逻辑会调用统一的 `isMemoryEmbeddingStale` / `isChapterChunkStale` 判断版本是否匹配: - 匹配(current):参与语义检索 / 计入"已嵌入"。 -- 不匹配(stale):跳过语义评分(自动降级到关键词 + 时间衰减),并被 backlog 扫描进入重算队列。 +- 不匹配(stale):跳过语义评分(自动降级到关键词 + 时间衰减),并在回填扫描时进入重算队列。 -**这意味着升级模型不会丢数据**:旧条目仍能被检索,只是暂时降级使用,等队列处理完会自动回到完整能力。 +**这意味着升级模型不会丢数据**:旧条目仍能被检索,只是暂时降级使用,等重建完成后回到完整能力。 -应用还会在启动时清理浏览器 Cache Storage 中的**历史模型残留**(如 `embeddinggemma`、`qwen3-embedding`),回收磁盘空间。 +启用本地嵌入时,应用启动会清理浏览器 Cache Storage 中的**历史模型残留**(如 `embeddinggemma`、`qwen3-embedding`),回收磁盘空间。旧 GTE 模型的缓存(约 340–465 MB)目前**有意保留**,暂不自动删除;如需回收空间,可在浏览器中清除本站点数据(之后需要重新下载当前模型)。 --- @@ -120,7 +149,7 @@ Quasar 检测到移动平台时会**锁定**「启用本地嵌入」开关为关 - 闸门解锁后**只**恢复"由闸门自身挂起"的任务 — 用户在 UI 上主动点击的「暂停」状态完整保留,不会被同步流程意外解除。 - 这避免了同步过程中嵌入写入与 `overwriteFromSnapshot` 的覆盖竞态,也防止恢复一半的数据上面再叠上半新的向量。 -UI 表现:同步期间面板会显示「同步进行中,嵌入已暂停」横幅,`isSyncing` 结束后自动消失。 +UI 表现:面板没有专门的同步提示;同步期间队列处于暂停,底部状态区显示「恢复」按钮。同步结束后队列自动继续,无需手动点击。 --- @@ -130,38 +159,38 @@ UI 表现:同步期间面板会显示「同步进行中,嵌入已暂停」 - **检查网络**:模型托管在 Hugging Face CDN,若网络不可达会进入自动重试(递增延迟 3s / 8s / 20s,最多 3 次)。可在浏览器开发者工具的 Network 面板看具体请求。 - **代理 / 反代**:企业网络 / VPN 可能拦截 CDN,可在「代理设置」(仅 Web 端)尝试切换或在浏览器层面配置允许列表。 -- **磁盘空间**:浏览器 Cache Storage 通常需要 500MB 以上空间,磁盘紧张时下载会中途失败。 +- **磁盘空间**:模型约 190 MiB,请为浏览器 Cache Storage 预留至少约 250 MB 可用空间(另有少量配置文件和向量数据),磁盘紧张时下载会中途失败。 -### 状态停在 `failed` {#local-embedding-section-16} +### 状态停在「加载失败」 {#local-embedding-section-16} - 查看浏览器控制台 `[EmbeddingService]` 前缀的错误日志,常见原因: - WebGPU 驱动不兼容 → 应用会自动回落到 WASM;如果连 WASM 也失败,多半是浏览器内存不足。 - 首次加载时切走标签页太久导致超时 → 重新打开应用即可(模型缓存命中后第二次启动很快)。 -- 在批量嵌入面板点击「重新加载」按钮会释放旧模型并重新检查 WebGPU。Worker 崩溃或请求超时后,未完成任务保留在队列;模型恢复后继续,已保存的章节缓存不会被半成品覆盖。 +- 在 **设置 → 本地嵌入** 中,失败状态下点击「重试」,就绪状态下点击「重新加载」,都会释放旧模型并重新检查 WebGPU。向量索引面板中没有重新加载按钮。Worker 崩溃或请求超时后,未完成任务保留在队列;模型恢复后继续,已保存的章节缓存不会被半成品覆盖。 ### 检索结果质量不佳 {#local-embedding-section-17} -- 用「测试查询」对话框直接输入查询语句,看返回 Top-N 是否合理。 -- 如果某条记忆/章节"明明应该被找到"却没出现,确认它的状态是否为 stale:先重新嵌入再测试。 +- 用「测试向量查询」对话框直接输入查询语句,看返回结果是否合理。 +- 如果某条记忆/章节"明明应该被找到"却没出现,确认它是否为 stale(面板顶部有「Embedding 空间已升级」横幅时):先点击「立即重建」或「回填缺失」,再测试。 - 中日英混合查询:模型原生多语言,直接输入纯净查询即可(v0.14.3 起不再为 query 端添加任何前缀)。 ### 已禁用但提示"嵌入队列还在跑" {#local-embedding-section-18} -通常是关闭总开关时正在处理某批次。队列会在当前批次完成后自然停止,强制中断会丢弃这一批结果。等待几秒进度横幅会自动消失。 +通常是关闭总开关时正在处理某批次。当前批次完成后队列自然停止,强制中断会丢弃这一批结果。关闭后「向量索引」入口立即隐藏;尚未处理的条目保留在队列中,重新启用后自动继续。 --- ## 8. 隐私与离线性 {#local-embedding-section-19} -- 嵌入模型推理在浏览器内执行,不调用外部嵌入 API。 +- 嵌入模型推理在浏览器 Worker 内执行,不调用外部嵌入 API。 - 模型本身一次性下载到 Cache Storage,之后离线启动也能用。 -- Gist 同步会把记忆向量作为记忆条目的一部分上传到你的私有 Gist(详见 [设置说明 → 数据同步](/help/settings-guide#settings-guide-section-19)),**Tsukuyomi 不持有任何中转服务器**。 +- Gist 同步会上传记忆内容,但**上传前会剥离记忆向量与模型版本标记**(详见 [设置说明 → 数据同步](/help/settings-guide#settings-guide-section-19))。向量只保存在本机 IndexedDB 中,每台启用本地嵌入的设备各自生成;**Tsukuyomi 不持有任何中转服务器**。 --- ## 相关文档 {#local-embedding-section-20} -- [设置说明 → 本地嵌入](/help/settings-guide) +- [设置说明 → 本地嵌入](/help/settings-guide#settings-guide-section-30) - [记忆管理](/help/book-details-memory) - [书籍详情页 → AI 翻译功能](/help/book-details-translation) -- [顶部工具栏](/help/toolbar-guide) +- [系统栏与导航](/help/toolbar-guide) diff --git a/public/help/zh-CN/settings-guide.md b/public/help/zh-CN/settings-guide.md index 46e8eeaf..e0f95442 100644 --- a/public/help/zh-CN/settings-guide.md +++ b/public/help/zh-CN/settings-guide.md @@ -58,7 +58,9 @@ - 已启用的模型; - 且该模型在对应任务上被标记为支持默认任务。 - 每个任务都可以选择 `未设置`。 -- 如果已选模型后续被禁用或不再支持该任务,设置会自动清空。 +- 每个任务的选择单独记录修改时间,随应用设置同步并按任务合并;修改其他设置不会覆盖它。两台设备在同一时刻修改同一任务时,取消选择优先。 +- 已选模型尚未同步到本设备或已被删除时,选择会保留,下拉框暂时显示「未设置」;此时该任务回退到第一个已启用且支持该任务的模型。 +- 只有模型仍存在、但已被禁用或不再支持该任务时,AI 模型页面才会清空该选择。同步、修订快照恢复进行中或模型尚未加载完成时,会暂缓这项检查。 --- @@ -165,16 +167,24 @@ - 同步间隔范围:**1~1440 分钟**。 - 自动同步会先用条件请求检查远端是否有变化。远端无变化时不消耗 GitHub API 配额,可以放心设置较短的同步间隔。 +### 同步进度 {#settings-guide-sync-progress} + +同步进行时,顶部系统栏的「同步状态」面板(见[系统栏与导航](/help/toolbar-guide))会显示「整体进度」百分比和进度条: + +- 进度只增不减;同步状态保存完成前最多显示 99%。 +- 进度条下方显示当前阶段:「准备本地数据」「下载中」「应用中」「合并中」「上传中」「保存同步状态」,再下方是当前操作的说明。 + ### 增量同步与并发冲突处理 {#settings-guide-section-23} 从 v0.10.1 起,同步采用基于 **manifest(清单)** 的增量策略: -- **只上传变化的内容**:编辑一本书后触发同步,仅该书与清单文件会被上传;其他书籍、AI 模型、封面、记忆库都不会被重复推送。 +- **只上传变化的内容**:只上传内容哈希发生变化的条目和清单文件;未变化的书籍、AI 模型、封面、记忆库都不会被重复推送。编辑一本书时,通常只上传有变化的章节小组、有变化时的目录和清单,详见[多语言协议与实体删除](/help/settings-guide#settings-guide-language-sync)。 - **选择性下载**:远端有更新时,只反序列化并合并发生变化的条目,未变更的数据不会被重新处理。 -- **多设备并发保护**:上传前会再次检查远端是否被其他设备修改。如发现并发写入,自动重新拉取合并后重试(最多 3 次)。超过后会提示“其他设备正在频繁写入,请稍后再试”。 +- **多设备并发保护**:开始上传前会再次检查远端是否被其他设备修改。如发现并发写入,自动重新拉取合并后再试,整轮同步最多尝试 3 次;仍然冲突时提示「其他设备正在频繁写入,请稍后再试」。这一重试只针对上传前的检查。 +- **上传中途停止**:上传分批进行,每批写入前都会重新检查远端。上传期间发现远端发生变化时会立即停止,提示「远程数据在上传期间发生变化,已停止上传,请重新同步」,不会自动重试。 - **跨设备智能配对**:同一在线章节即使在不同设备生成了不同本地 ID,也会优先按来源 `webUrl` 配对;卷在 ID 不一致时会按原文标题回退匹配并保留已有译名,减少重复卷与重复章节。 - **同步快照边界**:同步以本地数据打包前的快照时刻为边界。上传期间继续产生的编辑会保留到下一轮同步,不会被误判为已经上传或被远端删除。 -- **失败条目重试**:单条下载、解析或应用失败时,不会把该条目标记为已同步;下轮会重新拉取,避免陈旧本地副本覆盖远端新数据。 +- **失败即中止本轮**:任一条目下载、解析或应用失败,或读取本地数据失败时,整轮同步中止,不上传,也不推进同步状态;下轮会重新拉取,避免陈旧本地副本覆盖远端新数据。远程数据读取失败时提示「部分远程数据读取失败,已中止同步,请稍后重试」,应用失败提示「部分远程数据应用失败,未更新同步状态,请稍后重试」。 - **删除跨端传播**:manifest 中保留墓碑(tombstones)记录,在 A 设备上删除的条目不会被 B 设备重新推回。 - **头部待同步提示**:Header 会显示当前待同步变更的数量,点击同步按钮旁的小 Popover 可以查看每一项的具体修改。徽标覆盖书籍、AI 模型、封面、应用设置、**记忆库条目**以及跨端删除墓碑(自 v0.11.1 起补齐记忆变更)。 @@ -231,6 +241,13 @@ - 展开单个修订查看文件变更 - 恢复到指定修订 +展开一个修订后,顶部显示「修订内容」和「N 个条目 · M 个文件」,底部提示「按书籍归组,点击条目查看文件详情。」: + +- **按书籍归组**:同一本书的文件合并为一个条目,摘要按类别计数:「书籍资料」「章节数据」「记忆」「分块索引」。本地书库没有该书时,以书籍 ID 前 8 位代替书名。 +- **其他条目**:应用设置、AI 模型配置和封面历史合并为「应用配置」;清单文件显示为「同步清单」;无法识别的文件归为「其他文件」。 +- **大小与变化**:每个条目和文件显示大小及带正负号的大小变化;整组新增或移除时标注「新增」或「已移除」。点击条目可查看每个文件的类别、文件名、大小与变化。 +- **历史遗留文件**:该修订的同步清单完整可读时,不属于该修订恢复书库的书籍文件会归入「历史遗留文件」,并显示「不在此修订的恢复书库中 · N 个文件」。这些文件如仍留在最新版本,可用[清理远端遗留文件](/help/settings-guide#settings-guide-sync-cleanup)清理。 + > 自 v0.11.1 起,从修订历史恢复**完整支持** manifest 新布局:AI 模型、封面历史、记忆库都会随修订快照一并还原,不再只恢复书籍主体。若修订来自旧版本聚合布局,会自动回退到旧解析路径。 恢复前会创建包含章节正文与译文的本地备份。若修订快照的任一必要条目读取失败,恢复会整体中止并保留当前数据;若应用阶段失败,回滚也会还原独立存储的章节内容,而不只是书籍元数据。 @@ -239,6 +256,11 @@ 在同步设置中点击“扫描遗留文件”,预览按书籍归组的文件和大小,再确认清理。扫描以最新远端清单为准,不按本地书库推断删除。只删除预览中的未引用文件,保留当前有效数据和历史修订。 +- **显示条件**:启用 Gist 同步且已填写 Gist ID 时显示,位于「修订历史」上方。 +- **预览**:扫描后显示「可清理 N 个文件 · 大小」,部分文件大小未知时,大小前加「≥」;文件列表按书籍归组,下方提示「只清理最新版本,历史修订仍保留原文件。」扫描按钮变为「重新扫描」。 +- **清理**:点击「清理这 N 个文件」,在确认对话框中再次确认后执行;执行前会重新核对远端版本。 +- 没有可清理的文件时显示「本次扫描未发现可清理的遗留文件。」 + 清单损坏、格式升级未完成或已知正文缺失时停止清理。确认前远端版本变化时,需重新扫描。超过 300 个文件时会标为部分扫描,只处理 API 返回且能确认的候选,并保留活动条目的额外文件;清理后可再次扫描,但不能据此保证全部遗留文件都已清除。完整盘点需要通过 Git 获取整个 Gist。 ### 删除 Gist {#settings-guide-section-28} @@ -270,8 +292,11 @@ - 运行后端自动选择: - **WebGPU** — 能取得 GPU 适配器时优先使用。 - **WASM** — 适配器不可用时直接使用;两个后端共用同一默认文件。 +- WebGPU 加载失败时自动改用 WASM 重新加载;其他加载失败会自动重试最多 3 次,回退与重试期间已完成的下载进度不会归零。 +- 模型推理在独立的 Web Worker 中运行,不阻塞页面操作。 - 模型版本号:`bekko-embedding-v1-a25m@384@mean@raw`。模型 / 维度 / pooling / 输入方案变化时更新版本号,旧向量作为 stale 在后台重算。 - 仅在启用后且曾经缓存过模型时才会自动预热,首次开启需下载模型,请耐心等待。 +- **模型加载进度**:加载期间显示「模型加载进度」百分比和进度条,下方依次显示「正在准备模型文件…」「正在下载模型文件…」「正在初始化模型…」,下载时还显示当前文件名。百分比按模型权重文件的下载量计算,初始化完成前最多显示 95%;尚未得知文件总大小时,进度条显示为不确定状态。 ### 记忆注入 {#settings-guide-section-33} diff --git a/public/help/zh-CN/toolbar-guide.md b/public/help/zh-CN/toolbar-guide.md index 2f63671d..9c6272b4 100644 --- a/public/help/zh-CN/toolbar-guide.md +++ b/public/help/zh-CN/toolbar-guide.md @@ -1,8 +1,8 @@ # 🛠️ 系统栏与导航 {#toolbar-guide-section-1} -Tsukuyomi 在不同设备上采用不同的系统栏(System Bar)布局,但功能集合一致:**导航 / AI 思考 / 同步 / 消息 / 右栏面板**。本文按设备变体逐一说明各栏的位置与入口。 +Tsukuyomi 在不同设备上采用不同的系统栏(System Bar)布局,但功能集合一致:**导航 / AI 思考 / 同步 / 消息 / 右栏面板 / 向量索引**。本文按设备变体逐一说明各栏的位置与入口。 -> v0.12.1 起桌面端整体迁移到工作台(Workbench)语言,左/右栏改成图标轨道、聊天与翻译进度独立面板、批量嵌入面板从顶栏改放到右栏;平板与移动端继续沿用 chip-based sysbar。功能本身没有删减。 +> v0.12.1 起桌面端整体迁移到工作台(Workbench)语言,左/右栏改成图标轨道、聊天与翻译进度独立面板;平板与移动端继续沿用 chip-based sysbar。「向量索引」入口在三种布局下都可用:桌面右栏、平板书籍详情页侧轨、手机顶部 sysbar。 --- @@ -31,7 +31,7 @@ chip 风格的状态栏,从左到右: - **品牌** — Logo + 应用名 + Moonlit Translator 副标题。 - **AI 思考过程** - 图标 ✨,无任务时显示「AI 思考」。 - - 有任务时变为 pill 状态,显示「AI 思考中 / 处理中」并加载动画点。 + - 有任务时变为 pill 状态,显示「AI 思考中」或「AI 处理中」并带脉冲圆点。 - 点击打开思考面板:进行中任务(模型 / 类型 / 状态 / 用时)、思考消息、最近完成 10 条、停止 / 清空操作。 - **同步状态** — 图标 + 状态 chip,状态见下表: @@ -43,7 +43,7 @@ chip 风格的状态栏,从左到右: | 待同步变更 | ↑「N 项变更」 | 检测到本地有未上传的变更(含书籍 / AI 模型 / 封面 / 设置 / 记忆 / 删除墓碑) | | 同步中 | spinner「同步中」 | 正在执行 push / pull / merge | -点击进入同步面板:上传 / 下载 / 详情,可看最后同步时间、下次同步时间、远端统计、阶段百分比。下载按钮在未启用、同步中、缺 Gist ID 时不可用。 +点击打开「同步状态」面板:显示「最后同步时间」「下次同步时间」、「待同步变更」列表和「远程数据」统计(书籍 / AI 模型数量)。同步进行中会显示「整体进度」百分比与进度条,百分比只增不减;进度条下方是当前阶段:「准备本地数据」/「下载中」/「上传中」/「应用中」/「合并中」/「保存同步状态」。面板底部是「同步」按钮;打开「强制推送本地数据到远程(覆盖远程)」开关后,按钮变为「强制推送到远程」。未启用 Gist 同步、同步进行中或正在恢复已删除项目时按钮不可用。 - **消息历史** — 🔔,点击查看 toast 历史;未读角标超过 99 显示 `99+`。可按严重程度筛选(错误 / 警告 / 成功 / 信息),单条删除或清空,部分操作支持撤销。 @@ -51,13 +51,14 @@ chip 风格的状态栏,从左到右: ### 2) 左栏图标轨道(`AppSideMenu`) {#toolbar-guide-section-4} -默认是窄轨道,只显示图标;鼠标悬停或按 `☰` 展开成完整菜单。 +默认是窄轨道,只显示图标;按 `☰` 展开成完整菜单,展开后还会显示「导航」分组和「收藏小说」列表。 主要导航: - 🏠 **首页** — `/` - 📚 **书籍列表** — `/books` -- ✨ **AI列表** — `/ai` +- 📥 **AI 导入** — `/import` +- ✨ **AI 列表** — `/ai` 辅助导航(轨道底部): @@ -66,13 +67,13 @@ chip 风格的状态栏,从左到右: ### 3) 右栏图标轨道(`AppRightPanelDesktop`) {#toolbar-guide-section-5} -固定宽度 + 拖拽 resize 手柄;展开后显示当前激活面板。轨道入口: +右栏收起时是一条图标轨道;点击入口会展开对应面板(宽度可拖拽调整),面板上的关闭按钮会把它收回轨道。轨道入口: - 💬 **月詠(聊天助手)** — 对应 `AppChatPanelDesktop`,包含会话列表、消息流、输入区、上下文(书籍 / 章节 / 段落)、待办、上下文用量进度条。月詠是应用内 AI 助手化身(月下学者人格)。 -- 📊 **翻译进度** — 对应 `AppProgressPanelDesktop`,显示翻译 / 润色 / 校对任务进度条;活跃任务数量以 tab 数字角标显示,开始新任务时面板自动切到该任务,可在面板内取消。 -- 🧬 **向量索引**(仅书籍详情页路由显示) — 打开 `BatchEmbeddingsPanel` 抽屉,查看本书章节 / 记忆嵌入统计(已嵌入 / 总数 / stale)、批量重算、测试查询、暂停 / 继续。**依赖本地嵌入开启**,详见 [本地嵌入](/help/local-embedding)。 +- 📊 **翻译进度** — 对应 `AppProgressPanelDesktop`,显示翻译 / 润色 / 校对任务进度条;活跃任务数量以数字角标显示,开始新任务时面板自动切到该任务,可在面板内取消。 +- ⚡ **向量索引**(仅书籍详情页,且本地嵌入实际可用时显示) — 打开「本地向量索引」面板,详见下文 [关于本地嵌入入口](/help/toolbar-guide#toolbar-guide-section-10)。 -> 切换轨道入口会自动激活对应面板;未激活面板**不挂载**任何 watcher,避免跨面板的副作用。 +> 切换轨道入口会自动激活对应面板;未激活面板**不挂载**任何 watcher,避免跨面板的副作用。在 AI 导入页(`/import`),右栏固定显示导入助手对话面板。 ### 4) 底部 Footer(`AppFooter`) {#toolbar-guide-section-6} @@ -91,59 +92,79 @@ Electron 桌面版在版本号右侧额外显示**更新徽标**(v0.16.1 起 ## 📱 平板端布局 {#toolbar-guide-section-7} -平板端(`tablet` 断点)使用紧凑型 sysbar: +平板端(`tablet` 断点)使用紧凑型 sysbar + 左侧导航轨道;书籍列表与书籍详情页右侧另有竖向工具轨道: ``` +--------------------------------------------------------+ | TabletSysBar: brand | version | AI | Sync | Notify | -+--------------------------------------------------------+ -| | -| Workspace (route content) | -| | -+--------------------------------------------------------+ ++------+------------------------------------------+------+ +| Nav | | Side | +| rail | Workspace (route content) | rail | +| | | | ++------+------------------------------------------+------+ ``` ### Sysbar 结构 {#toolbar-guide-section-8} -- **左侧**:品牌名 + 版本号(`v0.12.1`)。 -- **右侧 chips**:AI 思考过程 / 同步状态 / 消息历史 — 各项行为与桌面端一致,只是 chip 文字更紧凑(如「AI 思考中 → AI 思考过程」,「N 项变更 / 已同步 / 同步中」)。 +- **左侧**:品牌名 + 当前版本号。 +- **右侧 chips**:AI 思考过程 / 同步状态 / 通知 — 各项行为与桌面端一致,只是 chip 更紧凑:空闲时显示「AI 思考过程」,有任务时显示「AI 思考中」;同步 chip 显示「同步」「同步中」「N 项变更」或「已同步」。 ### 导航与右栏 {#toolbar-guide-section-9} -- 主导航走 Quasar 抽屉(点击 sysbar 左侧的菜单图标),包含首页 / 书籍 / AI / 设置 / 帮助。 -- 右栏由独立的「聊天 / 进度」开关控制(路由进入书籍详情页时按需展开),交互细节与桌面端一致,只是没有图标轨道。 +- **左侧导航轨道**(`TabletNavRail`):顶部 Logo(返回首页),主导航为首页 / 书库 / AI 导入 / AI 模型,底部固定帮助 / 设置。 +- **右侧工具轨道**(`TabletSideRail`,书籍列表与书籍详情页):列表 / 目录开关、月詠、翻译进度(带任务数角标);书籍详情页在本地嵌入可用时还会显示「向量索引」按钮。 +- 月詠与翻译进度以浮层面板从右侧滑入,点击遮罩即可关闭,交互细节与桌面端一致。 ### 关于本地嵌入入口 {#toolbar-guide-section-10} -平板端**没有**专用的「向量索引」按钮:物理移动设备 UA 会强制锁住本地嵌入;如果是桌面浏览器缩到平板宽度,则可以在**设置 → 本地嵌入**配置开关,但批量嵌入抽屉仅在桌面布局下渲染。 +「向量索引」按钮在三种布局下都有入口,只在**书籍详情页**(`/books/:id` 及其子路由)且本地嵌入**实际可用**时显示: + +- 桌面:右栏图标轨道(⚡)。 +- 平板:书籍详情页右侧工具轨道。 +- 手机:顶部 sysbar 最左侧的 chip。 + +点击打开「本地向量索引」面板。面板只在主布局中挂载一份:桌面与平板以右侧抽屉打开,手机以底部抽屉打开;窗口断点切换时面板保持打开,切换到另一本书或本地嵌入变为不可用时自动关闭。面板内容: + +- 顶部状态:「就绪」/「模型加载中」/「加载失败」/「未就绪」/「已禁用」。 +- 「章节向量」:「已嵌入 X / Y」、待处理数量与进度条,提供「回填缺失」和「全部重算」。 +- 「记忆向量」:同样显示嵌入进度,提供「回填缺失」。 +- 检测到旧版向量时显示「Embedding 空间已升级」横幅,可点「立即重建」。 +- 「测试向量查询」:用自然语言检索本书章节或记忆。 +- 底部显示模型、后端与队列状态,可「暂停」/「恢复」嵌入队列。 + +本地嵌入是否可用由**设备 UA** 判定(`isMobileDevice()`),与窗口宽度无关:手机、平板等移动设备上始终禁用;桌面浏览器缩窄到平板或手机宽度时,只要在**设置 → 本地嵌入**中开启,仍可正常使用。详见 [本地嵌入](/help/local-embedding)。 --- ## 📲 移动端布局 {#toolbar-guide-section-11} -移动端(`mobile` 断点)使用极简 sysbar + 主体内容: +移动端(`mobile` 断点)使用极简 sysbar + 主体内容 + 底部标签栏: ``` -+----------------------------------+ -| MobileSysBar: | -| logo | AI | Sync | Help | -+----------------------------------+ -| | -| Route content (full width) | -| | -+----------------------------------+ ++----------------------------------------+ +| MobileSysBar: | +| logo | Vectors | AI | Sync | 🔔 | Help | ++----------------------------------------+ +| | +| Route content (full width) | +| | ++----------------------------------------+ +| MobileTabBar: Home | Library | Chat | +| AI | Settings | ++----------------------------------------+ ``` ### Sysbar 结构 {#toolbar-guide-section-12} - **左侧**:Logo + 应用名 + 版本号。 - **右侧 chips**: - - AI 思考过程 - - 同步状态 - - 消息历史 + - **向量索引**(⚡,仅书籍详情页且本地嵌入可用时显示,打开底部抽屉) + - AI 思考过程(有任务时显示「AI 思考中」) + - 同步状态(同步中 / 有变更 / 已同步时显示「同步中」「N 项变更」「已同步」) + - 通知 - **帮助**(移动端独有 chip,直接跳到 `/help`) -> 移动端导航主要靠主页快捷入口、底部抽屉与路由 push;没有左栏轨道。`useResponsiveLayout()` 判定为 `mobile` 时,本地嵌入功能(含 `query_chapter` 工具)会被禁用,避免浏览器 WASM 内存上限触发崩溃。 +> 移动端主导航在底部标签栏:首页 / 书库 / 月詠 / AI 模型 / 设置;月詠与翻译进度以底部抽屉打开,没有左栏轨道。本地嵌入(含 `query_chapter` 的语义检索)按设备 UA 判定,在手机等移动设备上被禁用,避免浏览器 WASM 内存上限触发崩溃;桌面浏览器缩到手机宽度不受影响。 --- @@ -165,8 +186,8 @@ Electron 桌面版在版本号右侧额外显示**更新徽标**(v0.16.1 起 **Q: 桌面端为什么找不到「批量摘要」按钮?** A: 章节摘要功能已在 v0.12 移除。AI 现在通过 `query_chapter` 工具按需对原文做语义检索,不再需要预生成摘要。 -**Q: 桌面端右栏的「向量索引」按钮去哪了?** -A: 该按钮仅在书籍详情页路由(`/books/:id` 及其子路由)下出现,且依赖本地嵌入开启。请先在**设置 → 本地嵌入**启用功能;详见 [本地嵌入](/help/local-embedding)。 +**Q: 「向量索引」按钮去哪了?** +A: 该按钮仅在书籍详情页路由(`/books/:id` 及其子路由)下出现,且依赖本地嵌入实际可用:桌面在右栏轨道,平板在书籍详情页右侧工具轨道,手机在顶部 sysbar。请先在**设置 → 本地嵌入**启用功能;移动设备上无法启用。详见 [本地嵌入](/help/local-embedding)。 **Q: 同步按钮一直显示「同步中」?** A: 检查 Gist 配置、网络连通性与 Token 权限;点开 sysbar 上的消息历史 🔔 查看具体错误日志。 diff --git a/public/help/zh-TW/ai-models-guide.md b/public/help/zh-TW/ai-models-guide.md index 7ded1dfc..a821d91a 100644 --- a/public/help/zh-TW/ai-models-guide.md +++ b/public/help/zh-TW/ai-models-guide.md @@ -12,14 +12,16 @@ - **頂部**:搜索框(名稱 / 提供商 / 模型 ID / 預設任務)與「新增 AI 模型」按鈕,下方是模型數量統計。 - **模型列表**:按提供商(OpenAI / Google Gemini)分組,組標題顯示模型數與已啟用數。每張模型卡片可 **複製**(副本預設禁用)、**編輯**、**刪除**(確認後執行,支持撤銷)。 -- **任務路由**:為翻譯(初譯)、校對 / 潤色、術語翻譯、AI 助手分別選擇預設模型,設定會隨匯入匯出一起儲存。 +- **任務路由**:為翻譯(初譯)、校對 / 潤色、術語翻譯、AI 助手分別選擇預設模型,下拉框中的「自動選擇」表示不指定。設定會隨匯入匯出一起儲存。 + +任務預設模型還會隨應用設定透過 Gist 同步。每個任務的選擇按各自的修改時間單獨合併,修改其他設定不會覆蓋它。如果選中的模型尚未同步到本裝置或已被刪除,選擇會被保留,期間該任務暫時顯示為未選擇(桌面版下拉框為「自動選擇」,平板 / 手機版選擇列表中為「未設定」),模型同步到達後自動恢復;只有當該模型被停用或不再用於該任務時,選擇才會被清除。 ### 手機版 {#ai-models-guide-section-4} -- 頂部為「AI 模型」標題與「添加」按鈕。 -- 尚未配置模型時顯示引導卡片:「新增 AI 模型」按鈕和「兩步開始使用」說明(添加模型連線 → 選擇預設任務)。 +- 頂部為「AI 模型」標題與「新增」按鈕。 +- 尚未配置模型時顯示引導卡片:「新增 AI 模型」按鈕和「兩步開始使用」說明(新增模型連線 → 選擇預設任務)。 - **我的模型**:按提供商分組,顯示模型數和已啟用數,點選模型即可編輯。 -- **任務預設模型**:點選任務行,從底部彈出的列表中選擇模型,也可選擇「未設定」讓該任務不使用預設模型。 +- **任務預設模型**:點選任務行,從底部彈出的列表中選擇模型,也可選擇「未設定」讓該任務不使用預設模型。選擇同樣隨 Gist 同步;所選模型尚未同步到本裝置時會暫時顯示「未設定」,規則見上文桌面版說明。 - 模型對話框在手機上以底部抽屜形式打開,字段與桌面版一致。 --- @@ -95,7 +97,7 @@ 每個模型可獨立控制是否使用 CORS 代理: - **使用 CORS 代理**(預設開啟):公網 API 請求通過設定的 CORS 代理服務器轉發。 -- 同源位址、本機服務(如 Ollama)和區域網路位址始終直接訪問,無需關閉開關。 +- 同源位址、本機服務(如 Ollama,`localhost`、`127.x.x.x`)和區域網路位址(如 `10.x.x.x`、`172.16–31.x.x`、`192.168.x.x`、`*.local`)始終直接訪問,即使開啟「使用 CORS 代理」也不經過代理,無需關閉開關。 - 關閉後請求將直連模型服務,適用於: - 已配置好跨域策略的自建服務 - 不需要代理的 API 端點 diff --git a/public/help/zh-TW/book-details-memory.md b/public/help/zh-TW/book-details-memory.md index 28bd2d9c..1b6c6a96 100644 --- a/public/help/zh-TW/book-details-memory.md +++ b/public/help/zh-TW/book-details-memory.md @@ -27,8 +27,8 @@ **元資料**: - 📅 **建立時間**: 記憶建立的時間 -- 🕘 **最後訪問時間**: 用於時間衰減評分 -- 🧬 **語義向量**: 本地嵌入模型生成的多維向量(用於語義檢索) +- 🕘 **最後訪問時間**: 用於時間衰減評分。只保存在本裝置,不參與同步;Gist 同步按內容修改時間合併記憶,因此讀取、檢索或注入記憶只會更新本機訪問時間,不會觸發同步或產生待同步變更 +- 🧬 **語義向量**: 本地嵌入模型生成的多維向量(用於語義檢索)。只保存在本裝置,不參與同步,每台啟用本地嵌入的裝置各自生成 ## 🚪 訪問記憶管理面板 {#book-details-memory-section-4} @@ -171,13 +171,13 @@ ### 語義檢索 {#book-details-memory-section-18} -在**設定 → 本地嵌入**中開啟"啟用本地嵌入"後,系統會使用本地嵌入模型為每條記憶生成 384 維語義向量。這使得 AI 能夠理解記憶的語義含義,而不僅僅是關鍵詞匹配。原理與排查請見獨立文檔:[本地嵌入](/help/local-embedding)。 +在**設定 → 本機嵌入**中開啟"啟用本地嵌入"後,系統會使用本地嵌入模型為每條記憶生成 384 維語義向量。這使得 AI 能夠理解記憶的語義含義,而不僅僅是關鍵詞匹配。原理與排查請見獨立文檔:[本地嵌入](/help/local-embedding)。 - **預設模型**:`hotchpotch/bekko-embedding-v1-a25m`,約 123M 總參數、25M 活躍參數的多語言編碼器,涵蓋 100+ 語言;使用完整 384 維向量、mean pooling 和 L2 歸一化,query / document 都直接編碼原文。 - **長記憶分段嵌入**:v0.14.3 起,單條記憶的"摘要 + 內容"按段落和句末標點切成 ≤1200 字的短段分別嵌入並穩健聚合,避免長記憶被截斷後語義漂移。 - **下載體積**:兩個後端共用約 190 MiB 的預設壓縮 ONNX 檔案;首次下載後復用瀏覽器快取。 - **完全本地運行**,不消耗 API 額度;優先使用 WebGPU,回退到 WASM。 -- **移動端強制禁用**本地嵌入(瀏覽器 WASM 記憶體限制),但關鍵詞匹配和時間衰減信號仍然生效。 +- **行動裝置強制停用**本地嵌入(瀏覽器 WASM 記憶體限制;按裝置 UA 判定,與視窗寬度無關),但關鍵詞匹配和時間衰減信號仍然生效。 - 即使不啟用本地嵌入,關鍵詞匹配和時間衰減信號仍會工作,系統會自動使用**退化評分權重**保證記憶依然有參考價值。 ## 記憶搜索與篩選 {#book-details-memory-section-19} diff --git a/public/help/zh-TW/index.json b/public/help/zh-TW/index.json index 1eadd448..b2e2cbb1 100644 --- a/public/help/zh-TW/index.json +++ b/public/help/zh-TW/index.json @@ -539,6 +539,15 @@ "如何决定什么信息值得记录": "book-details-memory-section-35" } }, + { + "category": "更新紀錄", + "categoryId": "release-notes", + "description": "同步協定 v6 依章節小組增量同步、大型書庫同步與遺留檔案清理、修訂歷史依書歸組、本機嵌入改用 Bekko 模型並移入 Worker、「向量索引」面板重做", + "file": "RELEASE_NOTES_v0.18.0.md", + "id": "v0.18.0", + "path": "releaseNotes", + "title": "v0.18.0" + }, { "category": "更新紀錄", "categoryId": "release-notes", diff --git a/public/help/zh-TW/local-embedding.md b/public/help/zh-TW/local-embedding.md index eb411eeb..14fd5065 100644 --- a/public/help/zh-TW/local-embedding.md +++ b/public/help/zh-TW/local-embedding.md @@ -1,6 +1,6 @@ # 🧬 本地嵌入(Local Embedding) {#local-embedding-section-1} -本地嵌入是 Tsukuyomi 的**離線語義檢索引擎**:完全在瀏覽器內運行嵌入模型,把記憶與章節內容映射成向量,讓 AI 能按語義而不是關鍵詞找到相關上下文。向量計算在本機完成,不消耗 AI API 額度。啟用 Gist 同步後,記憶及其向量會隨記憶條目上傳;實際發送給 AI 的上下文仍按模型請求發送,詳見本文隱私說明。 +本地嵌入是 Tsukuyomi 的**離線語意檢索引擎**:完全在瀏覽器內執行嵌入模型,把記憶與章節內容映射成向量,讓 AI 能依語意而不是關鍵詞找到相關上下文。向量計算在本機完成,不消耗 AI API 額度。向量屬於**裝置本機狀態**:Gist 同步只上傳記憶內容,不上傳向量,每台裝置各自產生;實際傳送給 AI 的上下文仍依模型請求傳送,詳見本文隱私說明。 > 本指南面向想瞭解或排查本地嵌入功能的使用者。設定入口請見[設定說明](/help/settings-guide#settings-guide-section-30),記憶相關用法見[記憶管理](/help/book-details-memory)。 @@ -10,158 +10,187 @@ 啟用本地嵌入後,系統會在背景為以下資料建立向量索引: -- **記憶庫**:每條記憶的"摘要 + 內容"按段落和句末標點切成 ≤1200 字的短段分別嵌入,再做多分段穩健聚合,避免長記憶被截斷後語義漂移。翻譯時三信號評分中的「語義相似度」分量(嵌入可用時權重 0.85)由此提供;AI 也能調用 `search_memories` 工具按自然語言搜索記憶。 -- **章節內容**:每章按段落(目標 100 字/chunk)和「標題 + 首段」拆成多個 chunk 分別嵌入,AI 通過 `query_chapter` 工具按自然語言找到相關段落,無需依賴預先生成的章節摘要。 +- **記憶庫**:每條記憶的「摘要 + 內容」依段落和句末標點切成 ≤1200 字的短段分別嵌入,再做多分段穩健聚合,避免長記憶被截斷後語意漂移。翻譯時三訊號評分中的「語意相似度」分量(嵌入可用時權重 0.85)由此提供;AI 也能呼叫 `search_memories` 工具以自然語言搜尋記憶。 +- **章節內容**:每章依段落(目標 100 字/chunk)和「標題 + 首段」拆成多個 chunk 分別嵌入,AI 透過 `query_chapter` 工具以自然語言找到相關段落,無需依賴預先產生的章節摘要。 -章節語義向量統一編碼原文,避免已譯與未譯章節的語言差異影響排名;原文缺失時使用目標譯文。關鍵字、預覽和全文索引保留原文與目前目標的選用譯文。快取按目標語言及實際輸入簽名校驗,譯文變化也會刷新關鍵字與預覽。切換目標後,失效快取會在背景重建;舊目標的遲到計算不會覆蓋新快取,未完成時可能顯示重建狀態。術語、角色和別名檢索只擴展原名與本次目標譯名,缺譯名不借用其他語言。記憶內容與記憶向量跨目標共享,不因目標切換清空。 +章節語意向量統一編碼原文,避免已譯與未譯章節的語言差異影響排名;原文缺失時使用目標譯文。關鍵字、預覽和全文索引保留原文與目前目標的選用譯文。快取依目標語言及實際輸入簽章校驗,譯文變化也會更新關鍵字與預覽。切換目標後,失效快取會在背景重建;舊目標的遲到計算不會覆蓋新快取,未完成時可能顯示重建狀態。術語、角色和別名檢索只擴展原名與本次目標譯名,缺譯名不借用其他語言。記憶內容與記憶向量跨目標共用,不因目標切換清空。 -模糊情節查詢會對少量候選的原文段落進行本地重排,避免關鍵情節被前後文稀釋。已有明確匹配的查詢保留整塊上下文,重複查詢重用向量快取;重排計算失敗時保留原排名。整個過程在瀏覽器內完成,不增加 AI API 請求。 +模糊情節查詢會對少量候選的原文段落進行本機重排,避免關鍵情節被前後文稀釋。已有明確符合的查詢保留整塊上下文,重複查詢重用向量快取;重排計算失敗時保留原排名。整個過程在瀏覽器內完成,不增加 AI API 請求。 未啟用時: -- 三信號評分自動退化為關鍵詞 + 時間衰減(權重 0.75 / 0.25,仍然有效,但語義層面的"意義相近但措辭不同"會漏召)。 -- AI 不再暴露 `query_chapter` 工具,翻譯提示詞也會移除對應引導。 +- 三訊號評分自動退化為關鍵詞 + 時間衰減(權重 0.75 / 0.25,仍然有效,但語意層面的「意義相近但措辭不同」會漏召)。 +- AI 不再提供 `query_chapter` 工具,翻譯提示詞也會移除對應引導。 - 「向量索引」入口隱藏,已開啟的面板關閉。 --- -## 2. 模型與運行方式 {#local-embedding-section-3} +## 2. 模型與執行方式 {#local-embedding-section-3} ### 預設嵌入模型 {#local-embedding-section-4} - **模型**:`hotchpotch/bekko-embedding-v1-a25m`(Bekko a25m 多語言編碼器)。 - **架構**:約 123M 總參數、25M 活躍參數,支援中 / 日 / 英等 100+ 語言。 -- **維度**:使用完整的 **384 維**向量,並做 L2 歸一化。 -- **Pooling**:mean pooling — 對有效 token 的表徵求平均。query 與 document 共用同一編碼路徑,不添加任務前綴。 +- **維度**:使用完整的 **384 維**向量,並做 L2 正規化。 +- **Pooling**:mean pooling — 對有效 token 的表徵求平均。query 與 document 共用同一編碼路徑,不加任務前綴。 - **預設檔案**:官方 `onnx/model.onnx`,約 **190 MiB**;靜態詞表已做 int8 壓縮,Transformer 計算保持 fp32。`dtype: fp32` 選擇這個已壓縮檔案,不表示下載完整 fp32 詞表。 -- **模型版本號**:`bekko-embedding-v1-a25m@384@mean@raw`。模型 ID、維度、pooling 或輸入方案變化都會更新版本號,舊記憶與章節向量會被判定為 stale,在背景重新計算。 +- **模型版本號**:`bekko-embedding-v1-a25m@384@mean@raw`。模型 ID、維度、pooling 或輸入方案變化都會更新版本號,舊記憶與章節向量會被判定為 stale,需要重新計算(見第 5 節)。 + +> **從 GTE 升級**:先前使用 GTE 模型的使用者需要重新下載 Bekko 模型,既有向量全部需要重建。重建完成前,記憶評分降級為關鍵詞 + 時間衰減,章節語意搜尋暫時無法使用。 ### 執行後端 {#local-embedding-section-5} -模型載入、分詞與推理都在獨立瀏覽器 Worker 中執行,仍完全在本機完成,不需要伺服器。WASM 在 Worker 內使用單執行緒,無需跨來源隔離。Worker 內優先檢查是否能取得 WebGPU 介面卡並建立裝置;不可用時直接使用 WASM。兩個後端共用同一份預設模型檔案。 +模型載入、分詞與推理都在獨立瀏覽器 Web Worker 中執行,不佔用頁面主執行緒,下載或推理期間介面不會卡頓;整個過程仍完全在本機完成,不需要伺服器。WASM 在 Worker 內使用單執行緒,無需跨來源隔離。Worker 內優先檢查是否能取得 WebGPU 介面卡並建立裝置;不可用時直接使用 WASM;WebGPU 初始化失敗時也會自動回落到 WASM。兩個後端共用同一份預設模型檔案。 | 後端 | 檔案與計算 | 大小 | 適用情境 | | :--------- | :-------------------------------------- | :------- | :-------------------------------- | | **WebGPU** | 預設 ONNX,詞表 int8 / Transformer fp32 | ~190 MiB | 支援 WebGPU 的桌面瀏覽器 | | **WASM** | 同一預設 ONNX 檔案 | ~190 MiB | WebGPU 介面卡或裝置不可用的瀏覽器 | -> 介面卡不可用時,本次工作階段使用 WASM;手動「重新載入」會重新檢查 WebGPU。實際推理速度取決於裝置、輸入長度和執行後端。 +> 介面卡不可用時,本次工作階段使用 WASM;在設定中點選「重新載入」會重新檢查 WebGPU。實際推理速度取決於裝置、輸入長度和執行後端。 + +### 行動裝置強制停用 {#local-embedding-section-6} -### 移動端強制禁用 {#local-embedding-section-6} +Quasar 偵測到實體行動裝置時會**鎖定**「啟用本地嵌入」開關為關閉:「本機嵌入」設定頁仍然可見,但開關呈灰色無法操作,並顯示不支援的原因。原因是瀏覽器 WASM 的記憶體上限(一般 ~2GB)在模型載入與多 chunk 推理時極易觸發當機。行動裝置依然能正常使用關鍵詞比對 + 時間衰減訊號。由於向量不隨同步上傳,桌面端產生的向量也不會出現在手機上。 -Quasar 檢測到移動平台時會**鎖定**「啟用本地嵌入」開關為關閉,並在 UI 上灰顯。原因是瀏覽器 WASM 的記憶體上限(一般 ~2GB)在模型載入與多 chunk 推理時極易觸發崩潰。移動端依然能正常使用關鍵詞匹配 + 時間衰減信號。 +此判斷只看裝置類型,不看視窗寬度:桌面瀏覽器縮窄視窗不會被當成行動裝置。 --- ## 3. 啟用步驟 {#local-embedding-section-7} -1. 進入 **設定 → 本地嵌入**(移動端不可見)。 -2. 打開「啟用本地嵌入」總開關。 -3. 系統彈出模型下載提示,開始首次下載(**需要穩定網路,請保留視窗在前景**;下載體積取決於運行後端,見上表)。 -4. 下載完成後模型會被瀏覽器 Cache Storage 持久化,**之後啟動會自動預熱,無需再次下載**。 -5. 進度橫幅消失、狀態徽章變為 `ready` 後,便可進入下一步。 -6. 打開任意書籍,點選右欄圖標軌道里的「批量嵌入」入口(v0.12.1 起從頂部工具欄遷移到此),按需觸發本書的章節 / 記憶向量化。 +1. 進入 **設定 → 本機嵌入**。 +2. 開啟「啟用本地嵌入」總開關。若瀏覽器尚未快取模型,會**直接開始下載**(約 190 MiB,WebGPU 與 WASM 相同),不會跳出確認視窗;之後若狀態為「未載入」,也可點選「下載模型」手動開始。**下載需要穩定網路,請保持視窗在前景。** +3. 下載期間設定卡片內顯示「模型載入進度」及百分比,下方提示目前階段:「正在準備模型檔案…」→「正在下載模型檔案…」→「正在初始化模型…」。 +4. 下載完成後模型會被瀏覽器 Cache Storage 持久保存,**之後啟動會自動預熱,無需再次下載**。 +5. 狀態變為「已就緒」後,便可進入下一步。 +6. 開啟任意書籍,點選「向量索引」入口(見第 4 節),查看或依需要補建本書的章節 / 記憶向量。 + +關於進度顯示: + +- 百分比只統計 `.onnx` 權重檔案的下載位元組,設定檔、分詞器等小檔案完成不會讓進度虛高。 +- 權重總大小未知時進度條顯示為不確定狀態(來回捲動),取得大小後切換為百分比。 +- 下載階段最多顯示到 95%,剩餘部分留給模型初始化;初始化完成後進度區塊收起,狀態變為「已就緒」。 +- 進度不會倒退:自動重試或 WebGPU 回落到 WASM 時,進度條沿用已顯示的數值。 +- 關閉再重新開啟設定頁時,會恢復目前的載入進度。 --- -## 4. 批量嵌入面板 {#local-embedding-section-8} +## 4. 向量索引面板 {#local-embedding-section-8} + +「向量索引」入口只在**書籍詳情頁**、且本地嵌入有效啟用時顯示;關閉本地嵌入或在實體行動裝置上時入口隱藏。入口位置隨版面變化: -進入書籍詳情頁後,右欄圖標軌道會出現「批量嵌入」入口(書籍詳情頁之外不顯示)。點選展開抽屜式面板,可在此查看與控制嵌入隊列。 +- 桌面版面:右側工具列。 +- 平板版面:右側側軌。 +- 窄螢幕版面:頂部工具列。 -入口依本機嵌入是否有效啟用顯示,不以版面斷點判斷:桌面和平板版面位於右側工具列,窄螢幕版面位於頂部工具列。桌面瀏覽器縮窄視窗仍可使用,實體行動裝置的停用規則不變。已開啟的面板在版面切換時保留。手機版面使用與助手、翻譯進度一致的底部抽屜;桌面和平板使用右側抽屜。 +入口依本地嵌入是否有效啟用顯示,不以版面斷點判斷,桌面瀏覽器縮窄視窗仍可使用。點選後,桌面和平板版面開啟右側抽屜,手機版面開啟與助手、翻譯進度一致的底部抽屜。已開啟的面板在版面切換時保留;切換到另一本書時自動關閉。 + +面板標題為「本機向量索引」,副標題「瀏覽器本機執行」,下方顯示目前書名和章節數。 ### 狀態徽章與統計 {#local-embedding-section-9} -- **嵌入服務狀態**:`idle` / `loading` / `ready` / `failed`,並顯示當前運行後端(`webgpu` 或 `wasm`)。 -- **本書章節**:已嵌入 / 總數 / stale(版本不匹配,待重算)。 -- **本書記憶**:已嵌入 / 總數 / stale。 +- **狀態徽章**:標題列右側顯示 就緒 / 模型載入中 / 載入失敗 / 未就緒 / 已停用。 +- **「章節向量」**:顯示「已嵌入 X / Y」(X 為目前模型版本已嵌入的章節數)、進度條,以及「待處理:N」(佇列中本書等待處理的章節數)。 +- **「記憶向量」**:同樣顯示「已嵌入 X / Y」、進度條和「待處理:N」。 +- **底部狀態區**:顯示模型版本號、執行後端(WebGPU 或「WASM(慢)」)和目前狀態。 + +存在 stale 向量(版本不符)時,面板頂部顯示「Embedding 空間已升級」橫幅,列出舊版章節與記憶數量,並提供「立即重建」按鈕。stale 向量不計入「已嵌入」,因此升級後數字可能明顯回落。 -> "stale" 計數非零時通常意味著模型版本最近做了升級(例如從 GTE 切到 Bekko),所有舊版本 chunk 都會被判定為 stale 自動進入重算隊列,期間統計可能短暫回落。 +> stale 項目**不會**在所有書籍中自動重算:只有在模型就緒時開啟某本書的詳情頁(或詳情頁開啟期間模型載入完成),才會自動把本書缺失和 stale 的章節 / 記憶加入佇列。其他書籍請開啟後自動觸發,或在面板中點選「立即重建」/「補齊缺失」。 ### 操作 {#local-embedding-section-10} -- **重新嵌入本書章節** / **重新嵌入本書記憶**:把所有未嵌入或 stale 的條目入隊。 -- **批量重算**(設定對話框):跨書籍觸發全部 stale 條目重算。 -- **測試查詢**:直接輸入任意自然語言查詢,立刻看到相似度最高的 N 條章節 / 記憶,相似度分數實時顯示,便於調參或排查"為什麼這條記憶沒被注入"。 -- **暫停 / 繼續**:暫停按鈕直接掛起隊列;同步進行中時面板會顯示「同步期間已暫停」提示,結束後自動恢復(詳見第 6 節 sync gate)。 +- **章節**:「補齊缺失」把本書未嵌入或 stale 的章節加入佇列;「全部重算」把本書所有章節重新加入佇列計算。 +- **記憶**:「補齊缺失」把本書未嵌入或 stale 的記憶加入佇列。 +- **「立即重建」**:僅在 stale 橫幅中出現,一次把本書 stale 的章節和記憶加入佇列。 +- 以上按鈕只在模型「就緒」且佇列閒置時可用,避免與進行中的重建衝突。 +- **「測試向量查詢」**:開啟查詢對話框(見下文)。 +- **「暫停」/「恢復」**:只在佇列執行中或已暫停時出現在底部狀態區,用於手動暫停或繼續佇列。 + +**測試向量查詢對話框**:頂部說明「以自然語言描述劇情、人物關係或關鍵詞,檢索本書章節與記憶。」輸入查詢語句後點選「查詢章節」或「查詢記憶」(Enter 預設執行章節查詢;記憶查詢僅比對目前模型版本的項目)。 + +- 尚未查詢時顯示「尋找書中的情節與記憶」提示。 +- 查詢中顯示「正在檢索章節…」或「正在檢索記憶…」載入狀態。 +- 結果依相關度排序,每條顯示排名序號和相關度分數(滑鼠懸停提示「相關度」);點選章節結果跳至對應章節,點選記憶結果查看詳情。 +- 便於調整參數或排查「為什麼這條記憶沒被注入」。 -### 進度橫幅 {#local-embedding-section-11} +### 進度與目前任務 {#local-embedding-section-11} -隊列活躍時面板頂部顯示進度條,包含: +佇列執行時,進度直接顯示在各分區內,面板頂部沒有單獨的橫幅: -- 已完成 / 總數(按 `memory` 與 `chapter` 分別拆分)。 -- ETA:基於最近 5 個批次的平均耗時估算。 -- 當前批次正在處理的書籍 ID(即便你切換到另一本書的面板,也能看到隊列在哪兒忙)。 +- 「章節向量」與「記憶向量」各自顯示「待處理:N」,有待處理項目時同時顯示 ETA(依最近 5 個批次的平均耗時估算)。 +- 目前任務卡片顯示佇列正在處理的內容:處理本書時顯示「本書 {類型} ×{數量}」;處理其他書籍時以醒目顏色顯示「其他書籍 · {類型} ×{數量}」並附上該書**書名**,即使你在另一本書的面板裡也能看到佇列在忙哪本書。 -背景記憶與章節推理每組最多 4 條輸入,並按長度拆組,降低長短文字混合時的計算開銷;前台查詢與段落重排每組最多 8 條,優先於等待中的背景任務。背景批次之間會留出計算空檔,因此整書重算可能更慢,以減少 CPU / GPU 持續佔用。章節在批次之間回應暫停;未完成的章節保留在佇列,繼續後重新計算,完成整章後再統一儲存。面板關閉時暫停統計讀取,開啟後合併同批更新。 +背景記憶與章節推理每組最多 4 條輸入,並依長度拆組,降低長短文字混合時的計算開銷;前台查詢與段落重排每組最多 8 條,優先於等待中的背景任務。背景批次之間會留出計算空檔,因此整書重算可能更慢,以減少 CPU / GPU 持續佔用。章節在批次之間回應暫停;未完成的章節保留在佇列,繼續後重新計算,完成整章後再統一儲存。面板關閉時暫停統計讀取,開啟後合併同批更新。 --- ## 5. 模型版本與 stale 重算 {#local-embedding-section-12} -`MODEL_ID + 截取維度 + pooling 方案 + 輸入方案`一起構成「embedding 空間身份」。任意一項變化,舊向量與新向量就落在不同空間,余弦相似度會退化成噪聲,必須重算。 +`MODEL_ID + 截取維度 + pooling 方案 + 輸入方案`一起構成「embedding 空間身份」。任意一項變化,舊向量與新向量就落在不同空間,餘弦相似度會退化成雜訊,必須重算。 -應用通過 `MODEL_VERSION` 常量記錄當前空間身份,每條已嵌入的記憶 / chunk 都會同時存這個版本號。檢索與統計邏輯會調用統一的 `isMemoryEmbeddingStale` / `isChapterChunkStale` 判斷版本是否匹配: +應用程式透過 `MODEL_VERSION` 常數記錄目前空間身份,每條已嵌入的記憶 / chunk 都會同時儲存這個版本號。檢索與統計邏輯會呼叫統一的 `isMemoryEmbeddingStale` / `isChapterChunkStale` 判斷版本是否相符: -- 匹配(current):參與語義檢索 / 計入"已嵌入"。 -- 不匹配(stale):跳過語義評分(自動降級到關鍵詞 + 時間衰減),並被 backlog 掃描進入重算隊列。 +- 相符(current):參與語意檢索 / 計入「已嵌入」。 +- 不相符(stale):跳過語意評分(自動降級到關鍵詞 + 時間衰減),並在補齊掃描時進入重算佇列。 -**這意味著升級模型不會丟資料**:舊條目仍能被檢索,只是暫時降級使用,等隊列處理完會自動回到完整能力。 +**這表示升級模型不會遺失資料**:舊項目仍能被檢索,只是暫時降級使用,等重建完成後回到完整能力。 -應用還會在啟動時清理瀏覽器 Cache Storage 中的**歷史模型殘留**(如 `embeddinggemma`、`qwen3-embedding`),回收磁盤空間。 +啟用本地嵌入時,應用程式啟動會清理瀏覽器 Cache Storage 中的**歷史模型殘留**(如 `embeddinggemma`、`qwen3-embedding`),回收磁碟空間。舊 GTE 模型的快取(約 340–465 MB)目前**刻意保留**,暫不自動刪除;如需回收空間,可在瀏覽器中清除本網站資料(之後需要重新下載目前的模型)。 --- ## 6. 同步期間的 sync gate {#local-embedding-section-13} -雲同步與修訂恢復期間,嵌入隊列會被一道**專用閘門**臨時掛起: +雲端同步與修訂還原期間,嵌入佇列會被一道**專用閘門**暫時掛起: -- `isSyncing` 或 `isRestoringSyncSnapshot` 任一為 true,閘門鎖住隊列。 -- 閘門解鎖後**只**恢復"由閘門自身掛起"的任務 — 使用者在 UI 上主動點選的「暫停」狀態完整保留,不會被同步流程意外解除。 -- 這避免了同步過程中嵌入寫入與 `overwriteFromSnapshot` 的覆蓋競態,也防止恢復一半的資料上面再疊上半新的向量。 +- `isSyncing` 或 `isRestoringSyncSnapshot` 任一為 true,閘門鎖住佇列。 +- 閘門解鎖後**只**恢復「由閘門自身掛起」的任務 — 使用者在 UI 上主動點選的「暫停」狀態完整保留,不會被同步流程意外解除。 +- 這避免了同步過程中嵌入寫入與 `overwriteFromSnapshot` 的覆蓋競態,也防止還原一半的資料上再疊上半新的向量。 -UI 表現:同步期間面板會顯示「同步進行中,嵌入已暫停」橫幅,`isSyncing` 結束後自動消失。 +UI 表現:面板沒有專門的同步提示;同步期間佇列處於暫停,底部狀態區顯示「恢復」按鈕。同步結束後佇列自動繼續,無需手動點選。 --- -## 7. 故障排查 {#local-embedding-section-14} +## 7. 疑難排解 {#local-embedding-section-14} ### 進度卡在 0% {#local-embedding-section-15} -- **檢查網路**:模型托管在 Hugging Face CDN,若網路不可達會進入自動重試(遞增延遲 3s / 8s / 20s,最多 3 次)。可在瀏覽器開發者工具的 Network 面板看具體請求。 -- **代理 / 反代**:企業網路 / VPN 可能攔截 CDN,可在「代理設定」(僅 Web 端)嘗試切換或在瀏覽器層面配置允許列表。 -- **磁盤空間**:瀏覽器 Cache Storage 通常需要 500MB 以上空間,磁盤緊張時下載會中途失敗。 +- **檢查網路**:模型託管在 Hugging Face CDN,若網路無法連線會進入自動重試(遞增延遲 3s / 8s / 20s,最多 3 次)。可在瀏覽器開發者工具的 Network 面板查看具體請求。 +- **代理 / 反向代理**:企業網路 / VPN 可能攔截 CDN,可在「代理設定」(僅 Web 端)嘗試切換,或在瀏覽器層面設定允許清單。 +- **磁碟空間**:模型約 190 MiB,請為瀏覽器 Cache Storage 預留至少約 250 MB 可用空間(另有少量設定檔與向量資料),磁碟空間不足時下載會中途失敗。 -### 狀態停在 `failed` {#local-embedding-section-16} +### 狀態停在「載入失敗」 {#local-embedding-section-16} -- 查看瀏覽器控制台 `[EmbeddingService]` 前綴的錯誤紀錄,常見原因: - - WebGPU 驅動不兼容 → 應用會自動回落到 WASM;如果連 WASM 也失敗,多半是瀏覽器記憶體不足。 - - 首次載入時切走標籤頁太久導致超時 → 重新打開應用即可(模型快取命中後第二次啟動很快)。 -- 在批量嵌入面板點選「重新載入」按鈕會釋放舊模型並重新檢查 WebGPU。Worker 崩潰或請求逾時後,未完成任務保留在佇列;模型恢復後繼續,已儲存的章節快取不會被半成品覆蓋。 +- 查看瀏覽器主控台 `[EmbeddingService]` 前綴的錯誤紀錄,常見原因: + - WebGPU 驅動程式不相容 → 應用程式會自動回落到 WASM;如果連 WASM 也失敗,多半是瀏覽器記憶體不足。 + - 首次載入時切走分頁太久導致逾時 → 重新開啟應用程式即可(模型快取命中後第二次啟動很快)。 +- 在 **設定 → 本機嵌入** 中,失敗狀態下點選「重試」,就緒狀態下點選「重新載入」,都會釋放舊模型並重新檢查 WebGPU。向量索引面板中沒有重新載入按鈕。Worker 當機或請求逾時後,未完成任務保留在佇列;模型恢復後繼續,已儲存的章節快取不會被半成品覆蓋。 -### 檢索結果質量不佳 {#local-embedding-section-17} +### 檢索結果品質不佳 {#local-embedding-section-17} -- 用「測試查詢」對話框直接輸入查詢語句,看返回 Top-N 是否合理。 -- 如果某條記憶/章節"明明應該被找到"卻沒出現,確認它的狀態是否為 stale:先重新嵌入再測試。 -- 中日英混合查詢:模型原生多語言,直接輸入純淨查詢即可(v0.14.3 起不再為 query 端添加任何前綴)。 +- 用「測試向量查詢」對話框直接輸入查詢語句,看回傳結果是否合理。 +- 如果某條記憶/章節「明明應該被找到」卻沒出現,確認它是否為 stale(面板頂部有「Embedding 空間已升級」橫幅時):先點選「立即重建」或「補齊缺失」,再測試。 +- 中日英混合查詢:模型原生多語言,直接輸入純淨查詢即可(v0.14.3 起不再為 query 端加上任何前綴)。 -### 已禁用但提示"嵌入隊列還在跑" {#local-embedding-section-18} +### 已停用但提示「嵌入佇列還在跑」 {#local-embedding-section-18} -通常是關閉總開關時正在處理某批次。隊列會在當前批次完成後自然停止,強制中斷會丟棄這一批結果。等待幾秒進度橫幅會自動消失。 +通常是關閉總開關時正在處理某批次。目前批次完成後佇列自然停止,強制中斷會丟棄這一批結果。關閉後「向量索引」入口立即隱藏;尚未處理的項目保留在佇列中,重新啟用後自動繼續。 --- ## 8. 隱私與離線性 {#local-embedding-section-19} -- 嵌入模型推理在瀏覽器內執行,不調用外部嵌入 API。 -- 模型本身一次性下載到 Cache Storage,之後離線啟動也能用。 -- Gist 同步會把記憶向量作為記憶條目的一部分上傳到你的私有 Gist(詳見 [設定說明 → 資料同步](/help/settings-guide#settings-guide-section-19)),**Tsukuyomi 不持有任何中轉服務器**。 +- 嵌入模型推理在瀏覽器 Worker 內執行,不呼叫外部嵌入 API。 +- 模型本身一次性下載到 Cache Storage,之後離線啟動也能使用。 +- Gist 同步會上傳記憶內容,但**上傳前會剝離記憶向量與模型版本標記**(詳見 [設定說明 → 資料同步](/help/settings-guide#settings-guide-section-19))。向量只儲存在本機 IndexedDB 中,每台啟用本地嵌入的裝置各自產生;**Tsukuyomi 不持有任何中轉伺服器**。 --- -## 相關文檔 {#local-embedding-section-20} +## 相關文件 {#local-embedding-section-20} -- [設定說明 → 本地嵌入](/help/settings-guide) +- [設定說明 → 本地嵌入](/help/settings-guide#settings-guide-section-30) - [記憶管理](/help/book-details-memory) - [書籍詳情頁 → AI 翻譯功能](/help/book-details-translation) -- [頂部工具欄](/help/toolbar-guide) +- [系統欄與導航](/help/toolbar-guide) diff --git a/public/help/zh-TW/settings-guide.md b/public/help/zh-TW/settings-guide.md index 3a071d9e..75b3a4d6 100644 --- a/public/help/zh-TW/settings-guide.md +++ b/public/help/zh-TW/settings-guide.md @@ -58,7 +58,9 @@ - 已啟用的模型; - 且該模型在對應任務上被標記為支持預設任務。 - 每個任務都可以選擇 `未設定`。 -- 如果已選模型後續被禁用或不再支持該任務,設定會自動清空。 +- 每個任務的選擇單獨記錄修改時間,隨應用程式設定同步並依任務合併;修改其他設定不會覆蓋它。兩台裝置在同一時刻修改同一任務時,取消選擇優先。 +- 已選模型尚未同步到本裝置或已被刪除時,選擇會保留,下拉選單暫時顯示「未設定」;此時該任務回退到第一個已啟用且支援該任務的模型。 +- 只有模型仍存在、但已被停用或不再支援該任務時,AI 模型頁面才會清空該選擇。同步、修訂快照還原進行中或模型尚未載入完成時,會暫緩這項檢查。 --- @@ -165,16 +167,24 @@ - 同步間隔範圍:**1~1440 分鐘**。 - 自動同步會先用條件請求檢查遠端是否有變化。遠端無變化時不消耗 GitHub API 配額,可以放心設定較短的同步間隔。 +### 同步進度 {#settings-guide-sync-progress} + +同步進行時,頂部系統欄的「同步狀態」面板(見[系統欄與導航](/help/toolbar-guide))會顯示「整體進度」百分比和進度條: + +- 進度只增不減;同步狀態儲存完成前最多顯示 99%。 +- 進度條下方顯示目前階段:「準備本機資料」「下載中」「套用中」「合併中」「上傳中」「儲存同步狀態」,再下方是目前操作的說明。 + ### 增量同步與並行衝突處理 {#settings-guide-section-23} 從 v0.10.1 起,同步採用基於 **manifest(清單)** 的增量策略: -- **只上傳變化的內容**:編輯一本書後觸發同步,僅該書與清單檔案會被上傳;其他書籍、AI 模型、封面、記憶庫都不會被重復推送。 -- **選擇性下載**:遠端有更新時,只反序列化並合並行生變化的條目,未變更的資料不會被重新處理。 -- **多設備並行保護**:上傳前會再次檢查遠端是否被其他設備修改。如發現並行寫入,自動重新拉取合併後重試(最多 3 次)。超過後會提示“其他裝置正在頻繁寫入,請稍後再試”。 +- **只上傳變化的內容**:只上傳內容雜湊發生變化的條目和清單檔案;未變化的書籍、AI 模型、封面、記憶庫都不會被重複推送。編輯一本書時,通常只上傳有變化的章節小組、有變化時的目錄和清單,詳見[多語言協議與實體刪除](/help/settings-guide#settings-guide-language-sync)。 +- **選擇性下載**:遠端有更新時,只反序列化並合併發生變化的條目,未變更的資料不會被重新處理。 +- **多設備並行保護**:開始上傳前會再次檢查遠端是否被其他設備修改。如發現並行寫入,自動重新拉取合併後再試,整輪同步最多嘗試 3 次;仍然衝突時提示「其他裝置正在頻繁寫入,請稍後再試」。這一重試只針對上傳前的檢查。 +- **上傳中途停止**:上傳分批進行,每批寫入前都會重新檢查遠端。上傳期間發現遠端發生變化時會立即停止,提示「遠端資料在上傳期間發生變更,已停止上傳,請重新同步」,不會自動重試。 - **跨設備智能配對**:同一在線章節即使在不同設備生成了不同本地 ID,也會優先按來源 `webUrl` 配對;卷在 ID 不一致時會按原文標題回退匹配並保留已有譯名,減少重復卷與重復章節。 - **同步快照邊界**:同步以本地資料打包前的快照時刻為邊界。上傳期間繼續產生的編輯會保留到下一輪同步,不會被誤判為已經上傳或被遠端刪除。 -- **失敗條目重試**:單條下載、解析或應用失敗時,不會把該條目標記為已同步;下輪會重新拉取,避免陳舊本地副本覆蓋遠端新資料。 +- **失敗即中止本輪**:任一條目下載、解析或套用失敗,或讀取本機資料失敗時,整輪同步中止,不上傳,也不推進同步狀態;下輪會重新拉取,避免陳舊本機副本覆蓋遠端新資料。遠端資料讀取失敗時提示「部分遠端資料讀取失敗,已中止同步,請稍後重試」,套用失敗提示「部分遠端資料套用失敗,未更新同步狀態,請稍後重試」。 - **刪除跨端傳播**:manifest 中保留墓碑(tombstones)記錄,在 A 設備上刪除的條目不會被 B 設備重新推回。 - **頭部待同步提示**:Header 會顯示當前待同步變更的數量,點選同步按鈕旁的小 Popover 可以查看每一項的具體修改。徽標覆蓋書籍、AI 模型、封面、應用設定、**記憶庫條目**以及跨端刪除墓碑(自 v0.11.1 起補齊記憶變更)。 @@ -227,10 +237,17 @@ 啟用且有 Gist ID 時可查看修訂歷史: -- 刷新曆史 +- 重新整理歷史 - 展開單個修訂查看檔案變更 - 恢復到指定修訂 +展開一個修訂後,頂部顯示「修訂內容」和「N 個項目 · M 個檔案」,底部提示「依書籍分組,點擊項目查看檔案詳情。」: + +- **依書籍分組**:同一本書的檔案合併為一個項目,摘要依類別計數:「書籍資料」「章節資料」「記憶」「分塊索引」。本機書庫沒有該書時,以書籍 ID 前 8 位代替書名。 +- **其他項目**:應用程式設定檔、AI 模型設定和封面歷史合併為一個「應用程式設定」項目;清單檔案顯示為「同步清單」;無法辨識的檔案歸為「其他檔案」。 +- **大小與變化**:每個項目和檔案顯示大小及帶正負號的大小變化;整組新增或移除時標註「新增」或「已移除」。點擊項目可查看每個檔案的類別、檔名、大小與變化。 +- **歷史遺留檔案**:該修訂的同步清單完整可讀時,不屬於該修訂還原書庫的書籍檔案會歸入「歷史遺留檔案」,並顯示「不在此修訂的還原書庫中 · N 個檔案」。這些檔案如仍留在最新版本,可用[清理遠端遺留檔案](/help/settings-guide#settings-guide-sync-cleanup)清理。 + > 自 v0.11.1 起,從修訂歷史恢復**完整支持** manifest 新佈局:AI 模型、封面歷史、記憶庫都會隨修訂快照一並還原,不再只恢復書籍主體。若修訂來自舊版本聚合佈局,會自動回退到舊解析路徑。 恢復前會建立包含章節正文與譯文的本地備份。若修訂快照的任一必要條目讀取失敗,恢復會整體中止並保留當前資料;若應用階段失敗,回滾也會還原獨立存儲的章節內容,而不只是書籍元資料。 @@ -239,6 +256,11 @@ 在同步設定中點擊「掃描遺留檔案」,預覽按書籍分組的檔案和大小,再確認清理。掃描以最新遠端清單為準,不依本機書庫推斷刪除。只刪除預覽中未被引用的檔案,保留目前有效資料和歷史修訂。 +- **顯示條件**:啟用 Gist 同步且已填寫 Gist ID 時顯示,位於「修訂歷史」上方。 +- **預覽**:掃描後顯示「可清理 N 個檔案 · 大小」,部分檔案大小未知時,大小前加「≥」;檔案清單依書籍分組,下方提示「只清理最新版本,歷史修訂仍保留原檔案。」掃描按鈕變為「重新掃描」。 +- **清理**:點擊「清理這 N 個檔案」,在確認對話框中再次確認後執行;執行前會重新核對遠端版本。 +- 沒有可清理的檔案時顯示「本次掃描未發現可清理的遺留檔案。」 + 清單損壞、格式升級未完成或已知正文缺失時停止清理。確認前遠端版本變更時,需重新掃描。超過 300 個檔案時會標示為部分掃描,只處理 API 傳回且能確認的候選,並保留使用中項目的額外檔案;清理後可再次掃描,但不能據此保證全部遺留檔案都已清除。完整盤點需透過 Git 取得整個 Gist。 ### 刪除 Gist {#settings-guide-section-28} @@ -270,8 +292,11 @@ - 執行後端自動選擇: - **WebGPU** — 能取得 GPU 介面卡時優先使用。 - **WASM** — 介面卡不可用時直接使用;兩個後端共用同一預設檔案。 +- WebGPU 載入失敗時自動改用 WASM 重新載入;其他載入失敗會自動重試最多 3 次,回退與重試期間已完成的下載進度不會歸零。 +- 模型推論在獨立的 Web Worker 中執行,不阻塞頁面操作。 - 模型版本號:`bekko-embedding-v1-a25m@384@mean@raw`。模型 / 維度 / pooling / 輸入方案變化時更新版本號,舊向量作為 stale 在背景重算。 - 僅在啟用後且曾經快取過模型時才會自動預熱,首次開啟需下載模型,請耐心等待。 +- **模型載入進度**:載入期間顯示「模型載入進度」百分比和進度條,下方依序顯示「正在準備模型檔案…」「正在下載模型檔案…」「正在初始化模型…」,下載時還顯示目前檔名。百分比依模型權重檔案的下載量計算,初始化完成前最多顯示 95%;尚未得知檔案總大小時,進度條顯示為不確定狀態。 ### 記憶注入 {#settings-guide-section-33} diff --git a/public/help/zh-TW/toolbar-guide.md b/public/help/zh-TW/toolbar-guide.md index 10e63878..87ab9ac0 100644 --- a/public/help/zh-TW/toolbar-guide.md +++ b/public/help/zh-TW/toolbar-guide.md @@ -1,8 +1,8 @@ # 🛠️ 系統欄與導航 {#toolbar-guide-section-1} -Tsukuyomi 在不同設備上採用不同的系統欄(System Bar)佈局,但功能集合一致:**導航 / AI 思考 / 同步 / 訊息 / 右欄面板**。本文按設備變體逐一說明各欄的位置與入口。 +Tsukuyomi 在不同設備上採用不同的系統欄(System Bar)佈局,但功能集合一致:**導航 / AI 思考 / 同步 / 訊息 / 右欄面板 / 向量索引**。本文按設備變體逐一說明各欄的位置與入口。 -> v0.12.1 起桌面端整體遷移到工作台(Workbench)語言,左/右欄改成圖標軌道、聊天與翻譯進度獨立面板、批量嵌入面板從頂欄改放到右欄;平板與移動端繼續沿用 chip-based sysbar。功能本身沒有刪減。 +> v0.12.1 起桌面端整體遷移到工作台(Workbench)語言,左/右欄改成圖標軌道、聊天與翻譯進度獨立面板;平板與移動端繼續沿用 chip-based sysbar。「向量索引」入口在三種佈局下都可用:桌面右欄、平板書籍詳情頁側軌、手機頂部 sysbar。 --- @@ -31,7 +31,7 @@ chip 風格的狀態欄,從左到右: - **品牌** — Logo + 應用名 + Moonlit Translator 副標題。 - **AI 思考過程** - 圖標 ✨,無任務時顯示「AI 思考」。 - - 有任務時變為 pill 狀態,顯示「AI 思考中 / 處理中」並載入動畫點。 + - 有任務時變為 pill 狀態,顯示「AI 思考中」或「AI 處理中」並帶脈衝圓點。 - 點選打開思考面板:進行中任務(模型 / 類型 / 狀態 / 用時)、思考訊息、最近完成 10 條、停止 / 清空操作。 - **同步狀態** — 圖標 + 狀態 chip,狀態見下表: @@ -43,7 +43,7 @@ chip 風格的狀態欄,從左到右: | 待同步變更 | ↑「N 項變更」 | 檢測到本地有未上傳的變更(含書籍 / AI 模型 / 封面 / 設定 / 記憶 / 刪除墓碑) | | 同步中 | spinner「同步中」 | 正在執行 push / pull / merge | -點選進入同步面板:上傳 / 下載 / 詳情,可看最後同步時間、下次同步時間、遠端統計、階段百分比。下載按鈕在未啟用、同步中、缺 Gist ID 時不可用。 +點選打開「同步狀態」面板:顯示「最後同步時間」「下次同步時間」、「待同步變更」列表和「遠端資料」統計(書籍 / AI 模型數量)。同步進行中會顯示「整體進度」百分比與進度條,百分比只增不減;進度條下方是目前階段:「準備本機資料」/「下載中」/「上傳中」/「套用中」/「合併中」/「儲存同步狀態」。面板底部是「同步」按鈕;開啟「強制推送本機資料到遠端(覆寫遠端)」開關後,按鈕變為「強制推送到遠端」。未啟用 Gist 同步、同步進行中或正在恢復已刪除項目時按鈕不可用。 - **訊息紀錄** — 🔔,點選查看 toast 歷史;未讀角標超過 99 顯示 `99+`。可按嚴重程度篩選(錯誤 / 警告 / 成功 / 資訊),單條刪除或清空,部分操作支持撤銷。 @@ -51,28 +51,29 @@ chip 風格的狀態欄,從左到右: ### 2) 左欄圖標軌道(`AppSideMenu`) {#toolbar-guide-section-4} -預設是窄軌道,只顯示圖標;滑鼠懸停或按 `☰` 展開成完整菜單。 +預設是窄軌道,只顯示圖標;按 `☰` 展開成完整選單,展開後還會顯示「導覽」分組和「收藏小說」列表。 主要導航: - 🏠 **首頁** — `/` - 📚 **書籍列表** — `/books` -- ✨ **AI列表** — `/ai` +- 📥 **AI 匯入** — `/import` +- ✨ **AI 列表** — `/ai` 輔助導航(軌道底部): - ⚙️ **設定** — `/settings` -- ❓ **幫助** — `/help` +- ❓ **說明** — `/help` ### 3) 右欄圖標軌道(`AppRightPanelDesktop`) {#toolbar-guide-section-5} -固定寬度 + 拖曳 resize 手柄;展開後顯示當前激活面板。軌道入口: +右欄收起時是一條圖標軌道;點選入口會展開對應面板(寬度可拖曳調整),面板上的關閉按鈕會把它收回軌道。軌道入口: - 💬 **月詠(聊天助手)** — 對應 `AppChatPanelDesktop`,包含會話列表、訊息流、輸入區、上下文(書籍 / 章節 / 段落)、待辦、上下文用量進度條。月詠是應用內 AI 助手化身(月下學者人格)。 -- 📊 **翻譯進度** — 對應 `AppProgressPanelDesktop`,顯示翻譯 / 潤色 / 校對任務進度條;活躍任務數量以 tab 數字角標顯示,開始新任務時面板自動切到該任務,可在面板內取消。 -- 🧬 **向量索引**(僅書籍詳情頁路由顯示) — 打開 `BatchEmbeddingsPanel` 抽屜,查看本書章節 / 記憶嵌入統計(已嵌入 / 總數 / stale)、批量重算、測試查詢、暫停 / 繼續。**依賴本地嵌入開啟**,詳見 [本地嵌入](/help/local-embedding)。 +- 📊 **翻譯進度** — 對應 `AppProgressPanelDesktop`,顯示翻譯 / 潤色 / 校對任務進度條;活躍任務數量以數字角標顯示,開始新任務時面板自動切到該任務,可在面板內取消。 +- ⚡ **向量索引**(僅書籍詳情頁,且本地嵌入實際可用時顯示) — 打開「本機向量索引」面板,詳見下文 [關於本地嵌入入口](/help/toolbar-guide#toolbar-guide-section-10)。 -> 切換軌道入口會自動激活對應面板;未激活面板**不掛載**任何 watcher,避免跨面板的副作用。 +> 切換軌道入口會自動激活對應面板;未激活面板**不掛載**任何 watcher,避免跨面板的副作用。在 AI 匯入頁(`/import`),右欄固定顯示匯入助手對話面板。 ### 4) 底部 Footer(`AppFooter`) {#toolbar-guide-section-6} @@ -91,59 +92,79 @@ Electron 桌面版在版本號右側額外顯示**更新徽標**(v0.16.1 起 ## 📱 平板端佈局 {#toolbar-guide-section-7} -平板端(`tablet` 斷點)使用緊湊型 sysbar: +平板端(`tablet` 斷點)使用緊湊型 sysbar + 左側導航軌道;書籍列表與書籍詳情頁右側另有直向工具軌道: ``` +--------------------------------------------------------+ | TabletSysBar: brand | version | AI | Sync | Notify | -+--------------------------------------------------------+ -| | -| Workspace (route content) | -| | -+--------------------------------------------------------+ ++------+------------------------------------------+------+ +| Nav | | Side | +| rail | Workspace (route content) | rail | +| | | | ++------+------------------------------------------+------+ ``` ### Sysbar 結構 {#toolbar-guide-section-8} -- **左側**:品牌名 + 版本號(`v0.12.1`)。 -- **右側 chips**:AI 思考過程 / 同步狀態 / 訊息紀錄 — 各項行為與桌面端一致,只是 chip 文字更緊湊(如「AI 思考中 → AI 思考過程」,「N 項變更 / 已同步 / 同步中」)。 +- **左側**:品牌名 + 目前版本號。 +- **右側 chips**:AI 思考過程 / 同步狀態 / 通知 — 各項行為與桌面端一致,只是 chip 更緊湊:閒置時顯示「AI 思考過程」,有任務時顯示「AI 思考中」;同步 chip 顯示「同步」「同步中」「N 項變更」或「已同步」。 ### 導航與右欄 {#toolbar-guide-section-9} -- 主導航走 Quasar 抽屜(點選 sysbar 左側的菜單圖標),包含首頁 / 書籍 / AI / 設定 / 幫助。 -- 右欄由獨立的「聊天 / 進度」開關控制(路由進入書籍詳情頁時按需展開),交互細節與桌面端一致,只是沒有圖標軌道。 +- **左側導航軌道**(`TabletNavRail`):頂部 Logo(返回首頁),主導航為首頁 / 書庫 / AI 匯入 / AI 模型,底部固定說明 / 設定。 +- **右側工具軌道**(`TabletSideRail`,書籍列表與書籍詳情頁):列表 / 目錄開關、月詠、翻譯進度(帶任務數角標);書籍詳情頁在本地嵌入可用時還會顯示「向量索引」按鈕。 +- 月詠與翻譯進度以浮層面板從右側滑入,點選遮罩即可關閉,交互細節與桌面端一致。 ### 關於本地嵌入入口 {#toolbar-guide-section-10} -平板端**沒有**專用的「向量索引」按鈕:物理移動設備 UA 會強制鎖住本地嵌入;如果是桌面瀏覽器縮到平板寬度,則可以在**設定 → 本地嵌入**配置開關,但批量嵌入抽屜僅在桌面佈局下渲染。 +「向量索引」按鈕在三種佈局下都有入口,只在**書籍詳情頁**(`/books/:id` 及其子路由)且本地嵌入**實際可用**時顯示: + +- 桌面:右欄圖標軌道(⚡)。 +- 平板:書籍詳情頁右側工具軌道。 +- 手機:頂部 sysbar 最左側的 chip。 + +點選打開「本機向量索引」面板。面板只在主佈局中掛載一份:桌面與平板以右側抽屜打開,手機以底部抽屜打開;視窗斷點切換時面板保持打開,切換到另一本書或本地嵌入變為不可用時自動關閉。面板內容: + +- 頂部狀態:「就緒」/「模型載入中」/「載入失敗」/「未就緒」/「已停用」。 +- 「章節向量」:「已嵌入 X / Y」、待處理數量與進度條,提供「補齊缺失」和「全部重算」。 +- 「記憶向量」:同樣顯示嵌入進度,提供「補齊缺失」。 +- 偵測到舊版向量時顯示「Embedding 空間已升級」橫幅,可點「立即重建」。 +- 「測試向量查詢」:用自然語言檢索本書章節或記憶。 +- 底部顯示模型、後端與隊列狀態,可「暫停」/「恢復」嵌入隊列。 + +本地嵌入是否可用由**設備 UA** 判定(`isMobileDevice()`),與視窗寬度無關:手機、平板等行動裝置上始終停用;桌面瀏覽器縮窄到平板或手機寬度時,只要在**設定 → 本機嵌入**中開啟,仍可正常使用。詳見 [本地嵌入](/help/local-embedding)。 --- ## 📲 移動端佈局 {#toolbar-guide-section-11} -移動端(`mobile` 斷點)使用極簡 sysbar + 主體內容: +移動端(`mobile` 斷點)使用極簡 sysbar + 主體內容 + 底部標籤列: ``` -+----------------------------------+ -| MobileSysBar: | -| logo | AI | Sync | Help | -+----------------------------------+ -| | -| Route content (full width) | -| | -+----------------------------------+ ++----------------------------------------+ +| MobileSysBar: | +| logo | Vectors | AI | Sync | 🔔 | Help | ++----------------------------------------+ +| | +| Route content (full width) | +| | ++----------------------------------------+ +| MobileTabBar: Home | Library | Chat | +| AI | Settings | ++----------------------------------------+ ``` ### Sysbar 結構 {#toolbar-guide-section-12} - **左側**:Logo + 應用名 + 版本號。 - **右側 chips**: - - AI 思考過程 - - 同步狀態 - - 訊息紀錄 - - **幫助**(移動端獨有 chip,直接跳到 `/help`) + - **向量索引**(⚡,僅書籍詳情頁且本地嵌入可用時顯示,打開底部抽屜) + - AI 思考過程(有任務時顯示「AI 思考中」) + - 同步狀態(同步中 / 有變更 / 已同步時顯示「同步中」「N 項變更」「已同步」) + - 通知 + - **說明**(移動端獨有 chip,直接跳到 `/help`) -> 移動端導航主要靠主頁快捷入口、底部抽屜與路由 push;沒有左欄軌道。`useResponsiveLayout()` 判定為 `mobile` 時,本地嵌入功能(含 `query_chapter` 工具)會被禁用,避免瀏覽器 WASM 記憶體上限觸發崩潰。 +> 移動端主導航在底部標籤列:首頁 / 書庫 / 月詠 / AI 模型 / 設定;月詠與翻譯進度以底部抽屜打開,沒有左欄軌道。本地嵌入(含 `query_chapter` 的語意檢索)按設備 UA 判定,在手機等行動裝置上被停用,避免瀏覽器 WASM 記憶體上限觸發崩潰;桌面瀏覽器縮到手機寬度不受影響。 --- @@ -165,8 +186,8 @@ Electron 桌面版在版本號右側額外顯示**更新徽標**(v0.16.1 起 **Q: 桌面端為什麼找不到「批量摘要」按鈕?** A: 章節摘要功能已在 v0.12 移除。AI 現在通過 `query_chapter` 工具按需對原文做語義檢索,不再需要預生成摘要。 -**Q: 桌面端右欄的「向量索引」按鈕去哪了?** -A: 該按鈕僅在書籍詳情頁路由(`/books/:id` 及其子路由)下出現,且依賴本地嵌入開啟。請先在**設定 → 本地嵌入**啟用功能;詳見 [本地嵌入](/help/local-embedding)。 +**Q: 「向量索引」按鈕去哪了?** +A: 該按鈕僅在書籍詳情頁路由(`/books/:id` 及其子路由)下出現,且依賴本地嵌入實際可用:桌面在右欄軌道,平板在書籍詳情頁右側工具軌道,手機在頂部 sysbar。請先在**設定 → 本機嵌入**啟用功能;行動裝置上無法啟用。詳見 [本地嵌入](/help/local-embedding)。 **Q: 同步按鈕一直顯示「同步中」?** A: 檢查 Gist 配置、網路連通性與 Token 權限;點開 sysbar 上的訊息紀錄 🔔 查看具體錯誤紀錄。 diff --git a/public/releaseNotes/RELEASE_NOTES_v0.18.0.md b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md new file mode 100644 index 00000000..b5c33ccc --- /dev/null +++ b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md @@ -0,0 +1,101 @@ +# 发布说明 - v0.18.0 + +## 版本信息 + +- **版本号**: 0.18.0 +- **发布日期**: 2026 年 10 月 9 日 +- **基于版本**: v0.17.0 + +本版本主要改进同步和本地嵌入。同步协议升级到 v6,书籍目录和章节正文分开存放,正文按章节固定分成 16 个小组;新增或修改一章通常只需上传一个小组、目录和清单,不再重传整本书。超过 300 个文件的大书库可以正常同步,没有变化的书库会跳过全库扫描。另外新增远端遗留文件清理,修订历史改为按书籍归组。本地嵌入换用更小的 Bekko 模型(约 190 MiB),推理移到独立 Worker 中运行,并重做了「向量索引」面板。**多设备同步用户以及开启了本地嵌入的用户,请先阅读文末的升级说明。** + +--- + +## ☁️ 同步协议 v6 (Chapter-level Incremental Sync) + +- **按章节小组增量同步**:书籍资料和目录单独保存,不含正文;章节正文按章节 ID 固定分到 16 个小组,插入或重排章节不会让其他章节换组。新增或修改一章时,通常只上传所在小组、目录(有变化时才传)和清单;只改正文时目录也会跳过。下载时同样只拉取有变化的小组。 +- **大书库不再受 300 个文件限制**:GitHub 返回的文件列表最多只有 300 个文件。以前列表被截断就会停止同步,现在会按当前修订读取完整的同步清单,补齐没有列出的文件,并继续校验哈希。只有既被截断、又读不到完整清单时才会停止。 +- **书库没有变化时同步更快**:本机记录一个只存在本地的同步检查点。书库、设置和删除记录都没有变化时,不再读取全部章节正文和记忆来重算清单;远端的变化仍会照常检查。冷启动时章节正文改为批量读取,远端文件最多 4 个并发下载。 +- **记忆访问时间只在本地**:记忆新增内容修改时间。「最后访问时间」只在本机用于排序和时间衰减,不再上传。因此打开或被 AI 读取的记忆不会再触发同步,也不会覆盖其他设备上较新的修改,或让已删除的记忆恢复。 +- **出错时整体停止**:任意条目下载、校验、应用失败,或本地读取某本书的记忆失败时,整轮同步都会中止,不上传、不更新同步状态,下次同步会重新拉取,避免上传不完整的数据。分批上传期间如果检测到其他设备写入,会停止上传,并提示「远程数据在上传期间发生变化,已停止上传,请重新同步」。 +- **同步进度**:同步面板改为显示「整体进度」百分比,进度不会回退;当前阶段(「准备本地数据」「下载中」「应用中」「上传中」「保存同步状态」等)显示在进度条下方。 + +--- + +## 🧹 遗留文件清理与修订历史 (Gist Cleanup & Revision History) + +- **清理远端遗留文件**:**设置 → 同步设置** 中,「修订历史」上方新增「清理远端遗留文件」。点「扫描遗留文件」后,以最新的远端清单为准,列出不再被引用的旧格式文件、孤立文件和多余分片,并显示「可清理 {count} 个文件 · {size}」。扫描只读取数据,不做修改。确认「清理这 {count} 个文件」后,会重新扫描、核对远端版本,再一次性删除。清理只影响最新版本,历史修订中的文件仍然保留;不属于月詠的文件不会被删除。远端清单损坏、格式升级没有完成或远端版本已变化时,会停止清理。 +- **修订历史按书籍归组**:展开某个修订后,「修订内容」按书籍汇总「书籍资料」「章节数据」「记忆」和「分块索引」,并显示文件数和大小变化,标记「新增」和「已移除」;点开条目可以看到原始文件。不属于该修订书库的文件会单独归入「历史遗留文件」。 + +--- + +## 🔑 设置同步更可靠 (Settings Sync) + +- **API Key 独立同步**:Tavily 和 Firecrawl 的 API Key 分别记录修改时间。以前本机修改过其他设置(甚至只是切换过标签页)后,会拒收远端较新的 Key;现在不会了。清空 Key 也会同步到其他设备。「API Keys」页的输入框会自动显示同步来的新值,但不会覆盖你正在编辑、还没保存的内容。 +- **任务默认模型不再被清空**:「任务默认模型」中每个任务的选择分别记录修改时间并同步合并。同步过程中,设置有时会比 AI 模型先到达本机,以前这时选择会被当作无效而清空;现在会保留,等模型同步到本机后自动恢复显示。只有模型确实已被禁用、或不再支持该任务时,选择才会被清空。 + +--- + +## 🧠 本地嵌入换用 Bekko 模型 (Bekko Embeddings) + +- **更小的模型**:默认嵌入模型从 `onnx-community/gte-multilingual-base`(768 维)换成 `hotchpotch/bekko-embedding-v1-a25m`(384 维)。WebGPU 和 WASM 共用同一个约 190 MiB 的 ONNX 文件(以前约 340–465 MB),向量占用的空间减半。 +- **章节检索更稳定**:章节向量只使用原文计算,已翻译和未翻译的章节可以公平比较。描述比较模糊时,会用候选章节的原文段落重新排序结果;排序期间书籍如果发生变化,会提示稍后重试,而不是返回过期结果。 +- **WebGPU 更稳定**:选择 WebGPU 之前会先检测设备是否可用,不可用时直接改用 WASM。WebGPU 失败一次后,回退到 WASM 也能正常恢复,不会持续报同一个错误。 + +--- + +## ⚡ 嵌入推理移到 Worker (Embedding Worker) + +- **界面不再卡顿**:模型加载、分词和推理改在独立的 Web Worker 中运行,下载模型或批量生成向量时,页面仍可正常操作。 +- **后台任务限速**:后台批次更小,每批之间有休息时间;你发起的查询会插队优先执行。全书重建可能会比以前慢一些,但 CPU / GPU 占用更平稳。 +- **崩溃与超时恢复**:Worker 长时间无响应时自动重启。正在处理的批次会重新排队;章节只在全部向量生成后才写入,不会留下不完整的数据。 +- **下载进度更准确**:**设置 → 本地嵌入** 显示「模型加载进度」,以及「正在准备模型文件…」「正在下载模型文件…」「正在初始化模型…」等阶段。进度只按模型权重文件计算,不再在下载小配置文件时闪到 100%,也不会回退;自动重试或从 WebGPU 回退到 WASM 时进度会保留。重新打开设置页时,会恢复当前进度。 + +--- + +## 🔍 「向量索引」面板重做 (Vector Index Panel) + +- **入口**:「向量索引」只在书籍详情页、且启用了本地嵌入时显示。桌面端在右侧图标栏,平板端在侧边栏(新增),窄屏布局在顶部工具栏(新增)。面板在桌面和平板上从右侧滑出,在手机布局中以底部抽屉打开;切换布局时保持打开状态,切换书籍时自动关闭。 +- **新的面板样式**:标题旁显示状态标签(就绪 / 模型加载中 / 加载失败 / 未就绪 / 已禁用),并改名为「章节向量」「记忆向量」,副标题改为「浏览器本地运行」。 +- **测试查询对话框**:新增说明文字「用自然语言描述剧情、人物关系或关键词,检索本书章节与记忆。」、空状态和加载状态;结果显示排名和「相关度」标签。 + +--- + +## 🧩 记忆注入与检索 (Memory Injection & RAG) + +- **字符预算严格执行**:记忆注入的字符预算包含标题、ID 和换行,第一条记忆也不会再超出预算。放不下的长记忆会被跳过,后面较短的记忆仍可注入;出错时的兜底注入也遵守预算。 +- **关闭本地嵌入后立即降级**:关闭「启用本地嵌入」后,即使模型仍在内存中,自动注入和 `search_memories` 也会只使用关键词检索。 +- **检索更快**:查询向量缓存由自动注入、AI 工具和预览共用;`query_chapter` 会缓存当前书籍的向量快照,并并行读取章节正文。章节向量按小批次生成,暂停或关闭后能及时停止。 + +--- + +## 📝 问题修复 + +- 修复:网页版的封面图片改为直接从原网站加载,不再经过 CORS 代理,不受代理故障或限流影响。 +- 修复:网页版访问本机或局域网地址(例如 Ollama 等本地模型服务)时自动直连,即使开启了「使用 CORS 代理」也一样;上传封面也不再经过代理。 +- 修复:从修订历史或备份恢复大书库时,内存占用明显降低。 +- 修复:同步时偶尔把自己上一批上传误判为「其他设备写入」,并且错误提示显示成翻译 key 的问题。 + +--- + +## 📌 升级说明 (Upgrade Notes) + +- **多设备同步请按顺序升级**:本版本把同步协议从 v4 升级到 v6(中间的 v5 没有正式发布)。新版本可以读取旧协议 1–5,第一次同步时会把整个 Gist 迁移到 v6 的新文件布局,上传量可能比较大;数据太多时会分批上传,中断后再次同步会继续完成。v0.17.0 及更早的客户端遇到 v6 数据时会停止同步,并提示升级。请先在每台设备上导出备份,再把所有设备升级到 v0.18.0,最后逐台完成同步。**旧版本的强制推送仍会覆盖共享 Gist**,升级之前请先关闭旧设备的同步,不要用旧版强制推送。 +- **迁移后可清理旧文件**:迁移完成后,可以在「清理远端遗留文件」中扫描、清理不再使用的旧格式文件。历史修订仍可恢复。 +- **需要重新下载嵌入模型(约 190 MiB)**:启动时不会自动下载新模型。请在 **设置 → 本地嵌入** 点「下载模型」,或者等嵌入任务(例如保存记忆)自动触发下载。旧 GTE 模型的文件(约 340–465 MB)会暂时保留在浏览器缓存中,如需释放空间,可以清除本站点的数据。 +- **已有向量需要重建**:旧的 768 维向量和章节向量都会失效,但不会丢失数据。重建之前,记忆检索只使用关键词和时间衰减,章节搜索暂时不可用。模型就绪后打开书籍详情页,会自动重建这本书的向量;也可以在「向量索引」面板中点「立即重建」、「回填缺失」或「全部重算」。没有打开过的书不会自动重建。 +- **「向量索引」按钮**:没有启用本地嵌入时,「向量索引」按钮会隐藏(以前会显示一个禁用提示)。 +- 新增 `@noble/hashes` 依赖(用于恢复签名),并为 `@huggingface/transformers` 4.3.0 打了补丁(`patches/`)。开发者需要重新运行 `bun install`。 + +--- + +## 📚 相关文档 + +- **设置说明(同步协议 v6、遗留文件清理、修订历史、API Key 同步、任务默认模型)**: [`help/zh-CN/settings-guide.md`](../help/zh-CN/settings-guide.md) +- **本地嵌入(Bekko 模型、Worker、向量索引面板)**: [`help/zh-CN/local-embedding.md`](../help/zh-CN/local-embedding.md) +- **记忆管理(字符预算)**: [`help/zh-CN/book-details-memory.md`](../help/zh-CN/book-details-memory.md) +- **AI 模型配置(本机服务直连、任务默认模型)**: [`help/zh-CN/ai-models-guide.md`](../help/zh-CN/ai-models-guide.md) +- **顶部工具栏(同步进度、向量索引入口)**: [`help/zh-CN/toolbar-guide.md`](../help/zh-CN/toolbar-guide.md) + +--- + +_本文档基于 git changes v0.17.0..v0.18.0_ diff --git a/src/constants/version.ts b/src/constants/version.ts index c799c9df..50737136 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.18.0.1'; +export const APP_VERSION = '0.18.0.2'; From 8701f80b5bfd83c86669cc4dece34b35e9837ac5 Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 19:56:57 -0400 Subject: [PATCH 03/16] docs(release): correct fallback model display, global queue counts and worker failure recovery --- public/help/en-US/ai-models-guide.md | 4 ++-- public/help/en-US/local-embedding.md | 2 +- public/help/zh-CN/ai-models-guide.md | 4 ++-- public/help/zh-CN/local-embedding.md | 2 +- public/help/zh-TW/ai-models-guide.md | 4 ++-- public/help/zh-TW/local-embedding.md | 2 +- public/releaseNotes/RELEASE_NOTES_v0.18.0.md | 2 +- src/constants/version.ts | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/public/help/en-US/ai-models-guide.md b/public/help/en-US/ai-models-guide.md index 28b548e2..21e2f339 100644 --- a/public/help/en-US/ai-models-guide.md +++ b/public/help/en-US/ai-models-guide.md @@ -14,14 +14,14 @@ The **AI model workbench** has three areas: - **Model list**: grouped by OpenAI or Google Gemini, with total and enabled counts. Cards support **Duplicate** (disabled initially), **Edit**, and **Delete** (confirmation required, with undo). - **Task routing**: defaults for translation, proofreading/polishing, terminology translation, and the assistant. "Select automatically" in the dropdown means no explicit choice. These settings are included in import/export. -Task defaults also sync through Gist with the app settings. Each task's choice merges by its own modification time, so editing other settings does not override it. If the chosen model has not synced to this device yet or was deleted, the choice is kept and the task temporarily shows as unselected ("Select automatically" in the desktop dropdown, "Unset" in the tablet/mobile picker); it comes back once the model arrives. The choice is cleared only when that model is disabled or no longer used for the task. +Task defaults also sync through Gist with the app settings. Each task's choice merges by its own modification time, so editing other settings does not override it. If the chosen model has not synced to this device yet or was deleted, the choice is kept. Meanwhile the desktop dropdown shows "Select automatically", while the tablet/mobile picker shows the fallback model actually used (the first enabled model that supports the task), or "Unset" if there is none. The choice comes back once the model arrives. The choice is cleared only when that model is disabled or no longer used for the task. ### Mobile {#ai-models-guide-section-4} - AI models title and Add button. - With no models, an introductory card explains the two steps: add a connection, then select default tasks. - **My models**: provider groups with total/enabled counts. Tap a model to edit. -- **Task defaults**: tap a task to select from a bottom sheet. Choose **Unset** to leave it without a default. Choices also sync through Gist; while the chosen model has not synced to this device, the task temporarily shows **Unset**. See the desktop notes above. +- **Task defaults**: tap a task to select from a bottom sheet. Choose **Unset** to leave it without a default. Choices also sync through Gist; while the chosen model has not synced to this device, the task temporarily shows the fallback model (or **Unset** if no model is available). See the desktop notes above. - The model dialog is a bottom sheet with the same fields as desktop. --- diff --git a/public/help/en-US/local-embedding.md b/public/help/en-US/local-embedding.md index 1cdce91e..38be93a9 100644 --- a/public/help/en-US/local-embedding.md +++ b/public/help/en-US/local-embedding.md @@ -91,7 +91,7 @@ The panel is titled "Local vector index" with the subtitle "Runs locally in your ### Status and counts {#local-embedding-section-9} - **Status pill**: at the right of the title bar, showing Ready / Loading model / Could not load / Not ready / Disabled. -- **"Chapter vectors"**: shows "Embedded X / Y" (X counts chapters embedded with the current model version), a progress bar, and "Pending: N" (this book's chapters waiting in the queue). +- **"Chapter vectors"**: shows "Embedded X / Y" (X counts chapters embedded with the current model version), a progress bar, and "Pending: N" (chapters waiting in the embedding queue; the queue is shared across books, so this can include other books' tasks). - **"Memory vectors"**: likewise shows "Embedded X / Y", a progress bar, and "Pending: N". - **Bottom status block**: model version, backend (WebGPU or "WASM (slow)"), and current status. diff --git a/public/help/zh-CN/ai-models-guide.md b/public/help/zh-CN/ai-models-guide.md index fb0e9cd5..9e4f718b 100644 --- a/public/help/zh-CN/ai-models-guide.md +++ b/public/help/zh-CN/ai-models-guide.md @@ -14,14 +14,14 @@ - **模型列表**:按提供商(OpenAI / Google Gemini)分组,组标题显示模型数与已启用数。每张模型卡片可 **复制**(副本默认禁用)、**编辑**、**删除**(确认后执行,支持撤销)。 - **任务路由**:为翻译(初译)、校对 / 润色、术语翻译、AI 助手分别选择默认模型,下拉框中的「自动选择」表示不指定。设置会随导入导出一起保存。 -任务默认模型还会随应用设置通过 Gist 同步。每个任务的选择按各自的修改时间单独合并,修改其他设置不会覆盖它。如果选中的模型尚未同步到本设备或已被删除,选择会被保留,期间该任务暂时显示为未选择(桌面版下拉框为「自动选择」,平板 / 手机版选择列表中为「未设置」),模型同步到达后自动恢复;只有当该模型被禁用或不再用于该任务时,选择才会被清除。 +任务默认模型还会随应用设置通过 Gist 同步。每个任务的选择按各自的修改时间单独合并,修改其他设置不会覆盖它。如果选中的模型尚未同步到本设备或已被删除,选择会被保留,期间桌面版下拉框暂时显示「自动选择」;平板 / 手机版会显示实际使用的回退模型(第一个已启用且支持该任务的模型),没有可用回退模型时才显示「未设置」。模型同步到达后自动恢复;只有当该模型被禁用或不再用于该任务时,选择才会被清除。 ### 手机版 {#ai-models-guide-section-4} - 顶部为「AI 模型」标题与「添加」按钮。 - 尚未配置模型时显示引导卡片:「添加 AI 模型」按钮和「两步开始使用」说明(添加模型连接 → 选择默认任务)。 - **我的模型**:按提供商分组,显示模型数和已启用数,点击模型即可编辑。 -- **任务默认模型**:点击任务行,从底部弹出的列表中选择模型,也可选择「未设置」让该任务不使用默认模型。选择同样随 Gist 同步;所选模型尚未同步到本设备时会暂时显示「未设置」,规则见上文桌面版说明。 +- **任务默认模型**:点击任务行,从底部弹出的列表中选择模型,也可选择「未设置」让该任务不使用默认模型。选择同样随 Gist 同步;所选模型尚未同步到本设备时,会暂时显示回退模型(没有可用模型时显示「未设置」),规则见上文桌面版说明。 - 模型对话框在手机上以底部抽屉形式打开,字段与桌面版一致。 --- diff --git a/public/help/zh-CN/local-embedding.md b/public/help/zh-CN/local-embedding.md index 8f8bafcb..96935171 100644 --- a/public/help/zh-CN/local-embedding.md +++ b/public/help/zh-CN/local-embedding.md @@ -91,7 +91,7 @@ Quasar 检测到物理移动设备时会**锁定**「启用本地嵌入」开关 ### 状态徽章与统计 {#local-embedding-section-9} - **状态徽章**:标题栏右侧显示 就绪 / 模型加载中 / 加载失败 / 未就绪 / 已禁用。 -- **「章节向量」**:显示「已嵌入 X / Y」(X 为当前模型版本已嵌入的章节数)、进度条,以及「待处理:N」(队列中本书等待处理的章节数)。 +- **「章节向量」**:显示「已嵌入 X / Y」(X 为当前模型版本已嵌入的章节数)、进度条,以及「待处理:N」(嵌入队列中等待处理的章节数;队列为全局共享,可能包含其他书籍的任务)。 - **「记忆向量」**:同样显示「已嵌入 X / Y」、进度条和「待处理:N」。 - **底部状态区**:显示模型版本号、运行后端(WebGPU 或「WASM (慢)」)和当前状态。 diff --git a/public/help/zh-TW/ai-models-guide.md b/public/help/zh-TW/ai-models-guide.md index a821d91a..4d1f24a8 100644 --- a/public/help/zh-TW/ai-models-guide.md +++ b/public/help/zh-TW/ai-models-guide.md @@ -14,14 +14,14 @@ - **模型列表**:按提供商(OpenAI / Google Gemini)分組,組標題顯示模型數與已啟用數。每張模型卡片可 **複製**(副本預設禁用)、**編輯**、**刪除**(確認後執行,支持撤銷)。 - **任務路由**:為翻譯(初譯)、校對 / 潤色、術語翻譯、AI 助手分別選擇預設模型,下拉框中的「自動選擇」表示不指定。設定會隨匯入匯出一起儲存。 -任務預設模型還會隨應用設定透過 Gist 同步。每個任務的選擇按各自的修改時間單獨合併,修改其他設定不會覆蓋它。如果選中的模型尚未同步到本裝置或已被刪除,選擇會被保留,期間該任務暫時顯示為未選擇(桌面版下拉框為「自動選擇」,平板 / 手機版選擇列表中為「未設定」),模型同步到達後自動恢復;只有當該模型被停用或不再用於該任務時,選擇才會被清除。 +任務預設模型還會隨應用設定透過 Gist 同步。每個任務的選擇按各自的修改時間單獨合併,修改其他設定不會覆蓋它。如果選中的模型尚未同步到本裝置或已被刪除,選擇會被保留,期間桌面版下拉框暫時顯示「自動選擇」;平板 / 手機版會顯示實際使用的備援模型(第一個已啟用且支援該任務的模型),沒有可用備援模型時才顯示「未設定」。模型同步到達後自動恢復;只有當該模型被停用或不再用於該任務時,選擇才會被清除。 ### 手機版 {#ai-models-guide-section-4} - 頂部為「AI 模型」標題與「新增」按鈕。 - 尚未配置模型時顯示引導卡片:「新增 AI 模型」按鈕和「兩步開始使用」說明(新增模型連線 → 選擇預設任務)。 - **我的模型**:按提供商分組,顯示模型數和已啟用數,點選模型即可編輯。 -- **任務預設模型**:點選任務行,從底部彈出的列表中選擇模型,也可選擇「未設定」讓該任務不使用預設模型。選擇同樣隨 Gist 同步;所選模型尚未同步到本裝置時會暫時顯示「未設定」,規則見上文桌面版說明。 +- **任務預設模型**:點選任務行,從底部彈出的列表中選擇模型,也可選擇「未設定」讓該任務不使用預設模型。選擇同樣隨 Gist 同步;所選模型尚未同步到本裝置時,會暫時顯示備援模型(沒有可用模型時顯示「未設定」),規則見上文桌面版說明。 - 模型對話框在手機上以底部抽屜形式打開,字段與桌面版一致。 --- diff --git a/public/help/zh-TW/local-embedding.md b/public/help/zh-TW/local-embedding.md index 14fd5065..527bd32c 100644 --- a/public/help/zh-TW/local-embedding.md +++ b/public/help/zh-TW/local-embedding.md @@ -91,7 +91,7 @@ Quasar 偵測到實體行動裝置時會**鎖定**「啟用本地嵌入」開關 ### 狀態徽章與統計 {#local-embedding-section-9} - **狀態徽章**:標題列右側顯示 就緒 / 模型載入中 / 載入失敗 / 未就緒 / 已停用。 -- **「章節向量」**:顯示「已嵌入 X / Y」(X 為目前模型版本已嵌入的章節數)、進度條,以及「待處理:N」(佇列中本書等待處理的章節數)。 +- **「章節向量」**:顯示「已嵌入 X / Y」(X 為目前模型版本已嵌入的章節數)、進度條,以及「待處理:N」(嵌入佇列中等待處理的章節數;佇列為全域共用,可能包含其他書籍的任務)。 - **「記憶向量」**:同樣顯示「已嵌入 X / Y」、進度條和「待處理:N」。 - **底部狀態區**:顯示模型版本號、執行後端(WebGPU 或「WASM(慢)」)和目前狀態。 diff --git a/public/releaseNotes/RELEASE_NOTES_v0.18.0.md b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md index b5c33ccc..01869d2f 100644 --- a/public/releaseNotes/RELEASE_NOTES_v0.18.0.md +++ b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md @@ -47,7 +47,7 @@ - **界面不再卡顿**:模型加载、分词和推理改在独立的 Web Worker 中运行,下载模型或批量生成向量时,页面仍可正常操作。 - **后台任务限速**:后台批次更小,每批之间有休息时间;你发起的查询会插队优先执行。全书重建可能会比以前慢一些,但 CPU / GPU 占用更平稳。 -- **崩溃与超时恢复**:Worker 长时间无响应时自动重启。正在处理的批次会重新排队;章节只在全部向量生成后才写入,不会留下不完整的数据。 +- **崩溃与超时处理**:Worker 崩溃或长时间无响应时会被终止,状态显示为「加载失败」,不会卡住页面。正在处理的批次会放回队列,在 **设置 → 本地嵌入** 点「重试」或有新的嵌入任务触发重新加载后继续;章节只在全部向量生成后才写入,不会留下不完整的数据。 - **下载进度更准确**:**设置 → 本地嵌入** 显示「模型加载进度」,以及「正在准备模型文件…」「正在下载模型文件…」「正在初始化模型…」等阶段。进度只按模型权重文件计算,不再在下载小配置文件时闪到 100%,也不会回退;自动重试或从 WebGPU 回退到 WASM 时进度会保留。重新打开设置页时,会恢复当前进度。 --- diff --git a/src/constants/version.ts b/src/constants/version.ts index 50737136..589769b2 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.18.0.2'; +export const APP_VERSION = '0.18.0.3'; From 6d9df9d76343a98232a504bc3933771eb0c3bc96 Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 20:08:00 -0400 Subject: [PATCH 04/16] docs(release): describe embedding-off fallback as keyword plus time decay --- public/releaseNotes/RELEASE_NOTES_v0.18.0.md | 2 +- src/constants/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/releaseNotes/RELEASE_NOTES_v0.18.0.md b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md index 01869d2f..77b7654e 100644 --- a/public/releaseNotes/RELEASE_NOTES_v0.18.0.md +++ b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md @@ -63,7 +63,7 @@ ## 🧩 记忆注入与检索 (Memory Injection & RAG) - **字符预算严格执行**:记忆注入的字符预算包含标题、ID 和换行,第一条记忆也不会再超出预算。放不下的长记忆会被跳过,后面较短的记忆仍可注入;出错时的兜底注入也遵守预算。 -- **关闭本地嵌入后立即降级**:关闭「启用本地嵌入」后,即使模型仍在内存中,自动注入和 `search_memories` 也会只使用关键词检索。 +- **关闭本地嵌入后立即降级**:关闭「启用本地嵌入」后,即使模型仍在内存中,自动注入和 `search_memories` 也会改用关键词 + 时间衰减评分,不再计算语义相似度。 - **检索更快**:查询向量缓存由自动注入、AI 工具和预览共用;`query_chapter` 会缓存当前书籍的向量快照,并并行读取章节正文。章节向量按小批次生成,暂停或关闭后能及时停止。 --- diff --git a/src/constants/version.ts b/src/constants/version.ts index 589769b2..564024f0 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.18.0.3'; +export const APP_VERSION = '0.18.0.4'; From 45af95d98ffa49d036e9a8e7c8fd285a7dba8815 Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 20:17:43 -0400 Subject: [PATCH 05/16] fix(i18n): describe mobile memory search as keyword plus time decay --- src/constants/version.ts | 2 +- src/i18n/en-US/embedding-ui.ts | 2 +- src/i18n/en-US/settings-ui.ts | 2 +- src/i18n/zh-CN/embedding-ui.ts | 2 +- src/i18n/zh-CN/settings-ui.ts | 2 +- src/i18n/zh-TW/embedding-ui.ts | 2 +- src/i18n/zh-TW/settings-ui.ts | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/constants/version.ts b/src/constants/version.ts index 564024f0..4c15ee73 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.18.0.4'; +export const APP_VERSION = '0.18.0.5'; diff --git a/src/i18n/en-US/embedding-ui.ts b/src/i18n/en-US/embedding-ui.ts index 4ea49d4f..b906710b 100644 --- a/src/i18n/en-US/embedding-ui.ts +++ b/src/i18n/en-US/embedding-ui.ts @@ -35,7 +35,7 @@ export default { resume: 'Resume', mobileUnsupported: 'Local embeddings are unavailable on mobile', mobileHint: - 'Model size and unstable mobile WebGPU support prevent local embedding on mobile. Mobile search uses keyword matching only; vectors stay on the device that generated them and are not synced to mobile.', + 'Model size and unstable mobile WebGPU support prevent local embedding on mobile. Mobile search scores by keyword matching and time decay; vectors stay on the device that generated them and are not synced to mobile.', notEnabled: 'Local embeddings are disabled', enableHint: 'Enable this feature to download an embedding model (about 190 MiB) for semantic memory and chapter search. Related actions are hidden while disabled.', diff --git a/src/i18n/en-US/settings-ui.ts b/src/i18n/en-US/settings-ui.ts index 939e3d79..d0655a02 100644 --- a/src/i18n/en-US/settings-ui.ts +++ b/src/i18n/en-US/settings-ui.ts @@ -63,7 +63,7 @@ export default { semanticNeedsEmbedding: 'Turn on “Local embeddings” above first', enable: 'Enable local embeddings', mobileHint: - 'Local embeddings are not supported on mobile (the model is too large and WebGPU is unstable). Mobile search uses keyword matching only. Vectors are local to each device and are not synced; enabling them on desktop affects only that desktop’s semantic search.', + 'Local embeddings are not supported on mobile (the model is too large and WebGPU is unstable). Mobile search uses keyword matching and time decay only. Vectors are local to each device and are not synced; enabling them on desktop affects only that desktop’s semantic search.', desktopHint: 'When enabled, an embedding model is downloaded to the browser for semantic memory retrieval and chapter vector search. The first download is about 190 MiB. When off, keyword retrieval remains available and vector generation pauses.', modelTitle: 'Embedding model', diff --git a/src/i18n/zh-CN/embedding-ui.ts b/src/i18n/zh-CN/embedding-ui.ts index 6ee661bd..2ae6a39f 100644 --- a/src/i18n/zh-CN/embedding-ui.ts +++ b/src/i18n/zh-CN/embedding-ui.ts @@ -35,7 +35,7 @@ export default { resume: '恢复', mobileUnsupported: '移动设备不支持本地嵌入', mobileHint: - '模型过大、WebGPU 在移动浏览器上不稳定,本功能在移动端被强制禁用。手机端检索仅使用关键词匹配;向量只保存在生成它的设备上,不会同步到手机。', + '模型过大、WebGPU 在移动浏览器上不稳定,本功能在移动端被强制禁用。手机端检索使用关键词匹配和时间衰减评分;向量只保存在生成它的设备上,不会同步到手机。', notEnabled: '本地嵌入未启用', enableHint: '启用后可在本地下载嵌入模型(约 190 MiB),支持语义记忆检索与章节向量搜索。关闭状态下所有相关操作按钮均已隐藏。', diff --git a/src/i18n/zh-CN/settings-ui.ts b/src/i18n/zh-CN/settings-ui.ts index b46d5a1c..aedaad01 100644 --- a/src/i18n/zh-CN/settings-ui.ts +++ b/src/i18n/zh-CN/settings-ui.ts @@ -60,7 +60,7 @@ export default { semanticNeedsEmbedding: '需要先在上方开启“本地嵌入”总开关', enable: '启用本地嵌入', mobileHint: - '移动设备不支持本地嵌入(模型过大、WebGPU 不稳定)。手机端检索仅用关键词匹配——向量是设备本地状态,不参与同步;在桌面端启用后也只影响桌面端自己的语义检索。', + '移动设备不支持本地嵌入(模型过大、WebGPU 不稳定)。手机端检索仅用关键词匹配和时间衰减——向量是设备本地状态,不参与同步;在桌面端启用后也只影响桌面端自己的语义检索。', desktopHint: '启用后下载嵌入模型到浏览器,支持语义记忆检索与章节向量搜索;首次下载约 190 MiB;关闭时仅用关键词检索并暂停向量生成。', modelTitle: '嵌入模型', diff --git a/src/i18n/zh-TW/embedding-ui.ts b/src/i18n/zh-TW/embedding-ui.ts index 76fdbf3c..7bd42b99 100644 --- a/src/i18n/zh-TW/embedding-ui.ts +++ b/src/i18n/zh-TW/embedding-ui.ts @@ -35,7 +35,7 @@ export default { resume: '恢復', mobileUnsupported: '行動裝置不支援本機嵌入', mobileHint: - '模型過大,且 WebGPU 在行動瀏覽器上不穩定,因此行動裝置停用此功能。手機端檢索僅使用關鍵詞比對;向量只保存在產生它的裝置上,不會同步到手機。', + '模型過大,且 WebGPU 在行動瀏覽器上不穩定,因此行動裝置停用此功能。手機端檢索使用關鍵詞比對和時間衰減評分;向量只保存在產生它的裝置上,不會同步到手機。', notEnabled: '本機嵌入尚未啟用', enableHint: '啟用後可下載嵌入模型(約 190 MiB),支援語意記憶檢索及章節向量搜尋。停用時隱藏相關操作按鈕。', diff --git a/src/i18n/zh-TW/settings-ui.ts b/src/i18n/zh-TW/settings-ui.ts index 42a0f739..e2c1222d 100644 --- a/src/i18n/zh-TW/settings-ui.ts +++ b/src/i18n/zh-TW/settings-ui.ts @@ -60,7 +60,7 @@ export default { semanticNeedsEmbedding: '需要先在上方開啟「本地嵌入」總開關', enable: '啟用本地嵌入', mobileHint: - '行動裝置不支援本地嵌入(模型過大、WebGPU 不穩定)。手機端檢索僅使用關鍵詞比對——向量是裝置本地狀態,不參與同步;在桌面端啟用後也只影響桌面端自己的語意檢索。', + '行動裝置不支援本地嵌入(模型過大、WebGPU 不穩定)。手機端檢索僅使用關鍵詞比對和時間衰減——向量是裝置本地狀態,不參與同步;在桌面端啟用後也只影響桌面端自己的語意檢索。', desktopHint: '啟用後下載嵌入模型到瀏覽器,支援語意記憶檢索與章節向量搜尋;關閉時僅使用關鍵詞比對,節省 ~190 MiB 儲存空間。', modelTitle: '嵌入模型', From a3713365d129c19ab6552c288b2ec0ae51aa7413 Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 20:28:53 -0400 Subject: [PATCH 06/16] fix: keep manual resume from bypassing the embedding sync gate --- public/help/en-US/local-embedding.md | 2 +- public/help/zh-CN/local-embedding.md | 2 +- public/help/zh-TW/local-embedding.md | 2 +- public/releaseNotes/RELEASE_NOTES_v0.18.0.md | 1 + src/__tests__/embedding-queue.test.ts | 22 +++++++++++++++++++ .../novel/BatchEmbeddingsBackendStatus.vue | 3 +++ src/components/novel/BatchEmbeddingsPanel.vue | 1 + .../novel/MemoryQueueProgressBanner.vue | 1 + src/constants/version.ts | 2 +- src/services/embedding-queue.ts | 12 +++++++++- 10 files changed, 43 insertions(+), 5 deletions(-) diff --git a/public/help/en-US/local-embedding.md b/public/help/en-US/local-embedding.md index 38be93a9..2bf60a2f 100644 --- a/public/help/en-US/local-embedding.md +++ b/public/help/en-US/local-embedding.md @@ -149,7 +149,7 @@ Cloud sync and revision restoration temporarily suspend the queue through a **de - Reopening resumes **only** work suspended by the gate, preserving a manual "Pause". - This avoids vector writes racing `overwriteFromSnapshot` or indexing partially restored data. -In the UI: the panel has no dedicated sync message. During sync the queue is paused, so the bottom status block shows "Resume". When sync finishes, the queue continues automatically without a click. +In the UI: the panel has no dedicated sync message. During sync the queue is paused, so the bottom status block shows "Resume", but the button is disabled so vectors are not written mid-sync. When sync finishes, the queue continues automatically without a click; if you clicked "Pause" during sync, the queue stays paused afterwards. --- diff --git a/public/help/zh-CN/local-embedding.md b/public/help/zh-CN/local-embedding.md index 96935171..3126713b 100644 --- a/public/help/zh-CN/local-embedding.md +++ b/public/help/zh-CN/local-embedding.md @@ -149,7 +149,7 @@ Quasar 检测到物理移动设备时会**锁定**「启用本地嵌入」开关 - 闸门解锁后**只**恢复"由闸门自身挂起"的任务 — 用户在 UI 上主动点击的「暂停」状态完整保留,不会被同步流程意外解除。 - 这避免了同步过程中嵌入写入与 `overwriteFromSnapshot` 的覆盖竞态,也防止恢复一半的数据上面再叠上半新的向量。 -UI 表现:面板没有专门的同步提示;同步期间队列处于暂停,底部状态区显示「恢复」按钮。同步结束后队列自动继续,无需手动点击。 +UI 表现:面板没有专门的同步提示;同步期间队列处于暂停,底部状态区显示「恢复」按钮,但按钮不可用,避免在同步过程中写入向量。同步结束后队列自动继续,无需手动点击;如果在同步期间点击了「暂停」,同步结束后队列保持暂停。 --- diff --git a/public/help/zh-TW/local-embedding.md b/public/help/zh-TW/local-embedding.md index 527bd32c..54480831 100644 --- a/public/help/zh-TW/local-embedding.md +++ b/public/help/zh-TW/local-embedding.md @@ -149,7 +149,7 @@ Quasar 偵測到實體行動裝置時會**鎖定**「啟用本地嵌入」開關 - 閘門解鎖後**只**恢復「由閘門自身掛起」的任務 — 使用者在 UI 上主動點選的「暫停」狀態完整保留,不會被同步流程意外解除。 - 這避免了同步過程中嵌入寫入與 `overwriteFromSnapshot` 的覆蓋競態,也防止還原一半的資料上再疊上半新的向量。 -UI 表現:面板沒有專門的同步提示;同步期間佇列處於暫停,底部狀態區顯示「恢復」按鈕。同步結束後佇列自動繼續,無需手動點選。 +UI 表現:面板沒有專門的同步提示;同步期間佇列處於暫停,底部狀態區顯示「恢復」按鈕,但按鈕無法使用,避免在同步過程中寫入向量。同步結束後佇列自動繼續,無需手動點選;如果在同步期間點選了「暫停」,同步結束後佇列保持暫停。 --- diff --git a/public/releaseNotes/RELEASE_NOTES_v0.18.0.md b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md index 77b7654e..3a0b3345 100644 --- a/public/releaseNotes/RELEASE_NOTES_v0.18.0.md +++ b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md @@ -73,6 +73,7 @@ - 修复:网页版的封面图片改为直接从原网站加载,不再经过 CORS 代理,不受代理故障或限流影响。 - 修复:网页版访问本机或局域网地址(例如 Ollama 等本地模型服务)时自动直连,即使开启了「使用 CORS 代理」也一样;上传封面也不再经过代理。 - 修复:从修订历史或备份恢复大书库时,内存占用明显降低。 +- 修复:同步或修订恢复期间,在「向量索引」面板或记忆面板点「恢复」会绕过同步闸门,继续写入向量;现在该按钮在同步期间不可用。同步期间点「暂停」也会在同步结束后保留,不再被自动解除。 - 修复:同步时偶尔把自己上一批上传误判为「其他设备写入」,并且错误提示显示成翻译 key 的问题。 --- diff --git a/src/__tests__/embedding-queue.test.ts b/src/__tests__/embedding-queue.test.ts index 6f1f0c40..ea396b5d 100644 --- a/src/__tests__/embedding-queue.test.ts +++ b/src/__tests__/embedding-queue.test.ts @@ -859,6 +859,28 @@ describe('EmbeddingQueue - applySyncGate', () => { expect(EmbeddingQueue.isPaused()).toBe(true); }); + test('gate 挂起期间手动 resume 不能绕过 gate', () => { + EmbeddingQueue.applySyncGate(true); + expect(EmbeddingQueue.getProgress().syncGated).toBe(true); + + EmbeddingQueue.resume(); + expect(EmbeddingQueue.isPaused()).toBe(true); + expect(EmbeddingQueue.getProgress().syncGated).toBe(true); + + EmbeddingQueue.applySyncGate(false); + expect(EmbeddingQueue.isPaused()).toBe(false); + expect(EmbeddingQueue.getProgress().syncGated).toBe(false); + }); + + test('gate 挂起期间手动 pause 在 gate 释放后保留', () => { + EmbeddingQueue.applySyncGate(true); + EmbeddingQueue.pause(); + + EmbeddingQueue.applySyncGate(false); + expect(EmbeddingQueue.isPaused()).toBe(true); + expect(EmbeddingQueue.getProgress().syncGated).toBe(false); + }); + test('gate 重复释放幂等', () => { EmbeddingQueue.applySyncGate(true); expect(EmbeddingQueue.isPaused()).toBe(true); diff --git a/src/components/novel/BatchEmbeddingsBackendStatus.vue b/src/components/novel/BatchEmbeddingsBackendStatus.vue index 353d049c..1063838e 100644 --- a/src/components/novel/BatchEmbeddingsBackendStatus.vue +++ b/src/components/novel/BatchEmbeddingsBackendStatus.vue @@ -13,6 +13,8 @@ defineProps<{ statusText: string; running: boolean; paused: boolean; + /** 同步闸门挂起中:恢复按钮禁用,同步结束后自动继续 */ + syncGated?: boolean; }>(); defineEmits<{ @@ -67,6 +69,7 @@ defineEmits<{ size="small" severity="success" icon="pi pi-play" + :disabled="syncGated" @click="$emit('resume')" /> diff --git a/src/components/novel/BatchEmbeddingsPanel.vue b/src/components/novel/BatchEmbeddingsPanel.vue index 42eec358..e730c5e8 100644 --- a/src/components/novel/BatchEmbeddingsPanel.vue +++ b/src/components/novel/BatchEmbeddingsPanel.vue @@ -554,6 +554,7 @@ defineExpose({ toggle }); :status-text="statusLabel.text" :running="progress.running" :paused="progress.paused" + :sync-gated="progress.syncGated" @pause="pauseQueue" @resume="resumeQueue" /> diff --git a/src/components/novel/MemoryQueueProgressBanner.vue b/src/components/novel/MemoryQueueProgressBanner.vue index aa931c19..e80ed744 100644 --- a/src/components/novel/MemoryQueueProgressBanner.vue +++ b/src/components/novel/MemoryQueueProgressBanner.vue @@ -37,6 +37,7 @@ defineEmits<{ togglePause: [] }>(); size="small" severity="secondary" text + :disabled="queueProgress.paused && queueProgress.syncGated" @click="$emit('togglePause')" /> diff --git a/src/constants/version.ts b/src/constants/version.ts index 4c15ee73..9c9db179 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.18.0.5'; +export const APP_VERSION = '0.18.0.6'; diff --git a/src/services/embedding-queue.ts b/src/services/embedding-queue.ts index 90370692..8c79ab3a 100644 --- a/src/services/embedding-queue.ts +++ b/src/services/embedding-queue.ts @@ -87,6 +87,8 @@ export interface EmbeddingQueueProgress { etaMs: number | null; running: boolean; paused: boolean; + /** 同步 / 恢复闸门正在挂起队列,此时手动恢复无效 */ + syncGated: boolean; breakdown: { memory: EmbeddingQueueBreakdown; chapter: EmbeddingQueueBreakdown; @@ -335,13 +337,20 @@ export class EmbeddingQueue { // 暂停 / 恢复 // ========================================================================== static pause(): void { + if (this.syncGatePaused) { + // 闸门期间用户主动暂停:转为用户暂停,闸门释放时不再自动恢复 + this.syncGatePaused = false; + this.emitProgress(); + return; + } if (this.paused) return; this.paused = true; this.emitProgress(); } static resume(): void { - if (!this.paused) return; + // 同步 / 恢复期间不能绕过闸门写 IndexedDB;闸门释放时会自动恢复 + if (!this.paused || this.syncGatePaused) return; this.paused = false; this.emitProgress(); this.scheduleRun(); @@ -420,6 +429,7 @@ export class EmbeddingQueue { etaMs: this.estimateEtaMs(), running: this.processing, paused: this.paused, + syncGated: this.syncGatePaused, breakdown, currentTask: this.currentTask, }; From d6b9b720c83aff6c187c5084e9f8f40582f2bbe2 Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 20:36:54 -0400 Subject: [PATCH 07/16] fix: track the embedding sync gate separately from user pause --- src/__tests__/embedding-queue.test.ts | 25 +++++++++++ src/constants/version.ts | 2 +- src/services/embedding-queue.ts | 64 ++++++++++++--------------- 3 files changed, 54 insertions(+), 37 deletions(-) diff --git a/src/__tests__/embedding-queue.test.ts b/src/__tests__/embedding-queue.test.ts index ea396b5d..39adc290 100644 --- a/src/__tests__/embedding-queue.test.ts +++ b/src/__tests__/embedding-queue.test.ts @@ -881,6 +881,31 @@ describe('EmbeddingQueue - applySyncGate', () => { expect(EmbeddingQueue.getProgress().syncGated).toBe(false); }); + test('gate 期间先 pause 再 resume,仍不能绕过 gate', () => { + EmbeddingQueue.applySyncGate(true); + EmbeddingQueue.pause(); + EmbeddingQueue.resume(); + + expect(EmbeddingQueue.isPaused()).toBe(true); + expect(EmbeddingQueue.getProgress().syncGated).toBe(true); + + EmbeddingQueue.applySyncGate(false); + expect(EmbeddingQueue.isPaused()).toBe(false); + }); + + test('用户已暂停时 gate 生效,同步期间 resume 仍被 gate 拦住', () => { + EmbeddingQueue.pause(); + EmbeddingQueue.applySyncGate(true); + expect(EmbeddingQueue.getProgress().syncGated).toBe(true); + + EmbeddingQueue.resume(); + expect(EmbeddingQueue.isPaused()).toBe(true); + + EmbeddingQueue.applySyncGate(false); + expect(EmbeddingQueue.isPaused()).toBe(false); + expect(EmbeddingQueue.getProgress().syncGated).toBe(false); + }); + test('gate 重复释放幂等', () => { EmbeddingQueue.applySyncGate(true); expect(EmbeddingQueue.isPaused()).toBe(true); diff --git a/src/constants/version.ts b/src/constants/version.ts index 9c9db179..1ed1386f 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.18.0.6'; +export const APP_VERSION = '0.18.0.7'; diff --git a/src/services/embedding-queue.ts b/src/services/embedding-queue.ts index 8c79ab3a..6a6becab 100644 --- a/src/services/embedding-queue.ts +++ b/src/services/embedding-queue.ts @@ -119,11 +119,10 @@ export class EmbeddingQueue { private static runScheduled = false; private static currentTask: EmbeddingQueueCurrentTask | null = null; private static serviceRevision = 0; - /** - * 同步/恢复期间由外部 gate 临时暂停时置位;用于在同步结束后只恢复"由 gate 挂起"的场景, - * 避免把用户主动点击的 pause 一起解除。 - */ - private static syncGatePaused = false; + /** 用户(或设置开关)主动暂停;与同步闸门分开记录,闸门释放时不会被一起清除 */ + private static userPaused = false; + /** 同步/恢复闸门是否生效;只由 applySyncGate 置位和清除,生效期间任何 resume 都不能放行 */ + private static syncGateActive = false; // 分 kind 的会话统计 private static totalEnqueued = { memory: 0, chapter: 0 }; @@ -337,23 +336,24 @@ export class EmbeddingQueue { // 暂停 / 恢复 // ========================================================================== static pause(): void { - if (this.syncGatePaused) { - // 闸门期间用户主动暂停:转为用户暂停,闸门释放时不再自动恢复 - this.syncGatePaused = false; - this.emitProgress(); - return; - } - if (this.paused) return; - this.paused = true; - this.emitProgress(); + if (this.userPaused) return; + this.userPaused = true; + this.updatePaused(); } static resume(): void { - // 同步 / 恢复期间不能绕过闸门写 IndexedDB;闸门释放时会自动恢复 - if (!this.paused || this.syncGatePaused) return; - this.paused = false; + // 只撤销用户暂停;同步 / 恢复闸门生效时队列保持暂停,闸门释放后才继续写 IndexedDB + if (!this.userPaused) return; + this.userPaused = false; + this.updatePaused(); + } + + /** 暂停状态 = 用户暂停 ∨ 闸门生效;由暂停转为运行时调度 run */ + private static updatePaused(): void { + const wasPaused = this.paused; + this.paused = this.userPaused || this.syncGateActive; this.emitProgress(); - this.scheduleRun(); + if (wasPaused && !this.paused) this.scheduleRun(); } static isPaused(): boolean { @@ -366,25 +366,16 @@ export class EmbeddingQueue { /** * 同步/恢复期间的外部 gate: - * - shouldPause=true 时,若队列当前未暂停则挂起,并记下"挂起是 gate 触发的" - * - shouldPause=false 时,只解除由 gate 造成的挂起;若用户通过 UI 主动 pause 过就保留 + * - shouldPause=true 时无条件记为闸门生效,队列挂起;生效期间 resume() 不能放行 + * - shouldPause=false 时解除闸门;若用户通过 UI / 设置主动 pause 过则保持暂停 * - * 这是为了避免 sync 结束后把用户手动点击的暂停一起抹掉(BatchEmbeddingsPanel / MemoryPanel - * 暴露了手动 pause/resume 按钮)。 + * 闸门与用户暂停分开记录,避免同步期间的 pause/resume 解除闸门,也避免 sync 结束后把 + * 用户手动点击的暂停一起抹掉(BatchEmbeddingsPanel / MemoryPanel 暴露了手动 pause/resume 按钮)。 */ static applySyncGate(shouldPause: boolean): void { - if (shouldPause) { - if (this.paused) return; - this.paused = true; - this.syncGatePaused = true; - this.emitProgress(); - return; - } - if (!this.syncGatePaused) return; - this.syncGatePaused = false; - this.paused = false; - this.emitProgress(); - this.scheduleRun(); + if (this.syncGateActive === shouldPause) return; + this.syncGateActive = shouldPause; + this.updatePaused(); } /** @@ -429,7 +420,7 @@ export class EmbeddingQueue { etaMs: this.estimateEtaMs(), running: this.processing, paused: this.paused, - syncGated: this.syncGatePaused, + syncGated: this.syncGateActive, breakdown, currentTask: this.currentTask, }; @@ -701,7 +692,8 @@ export class EmbeddingQueue { this.pending = []; this.processing = false; this.paused = false; - this.syncGatePaused = false; + this.userPaused = false; + this.syncGateActive = false; this.runScheduled = false; this.currentTask = null; this.serviceRevision = 0; From 10a4fa6f4962075e3d5672f3164642e6a180fbe7 Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 20:42:06 -0400 Subject: [PATCH 08/16] fix: abort legacy sync apply on memory write failures and document stale memory retrieval --- public/help/en-US/local-embedding.md | 6 ++-- public/help/zh-CN/local-embedding.md | 6 ++-- public/help/zh-TW/local-embedding.md | 6 ++-- public/releaseNotes/RELEASE_NOTES_v0.18.0.md | 2 +- src/__tests__/sync-data-service.test.ts | 32 ++++++++++++++++++++ src/constants/version.ts | 2 +- src/i18n/en-US/embedding-ui.ts | 2 +- src/i18n/zh-CN/embedding-ui.ts | 3 +- src/i18n/zh-TW/embedding-ui.ts | 2 +- src/services/sync-data-service.ts | 3 ++ 10 files changed, 50 insertions(+), 14 deletions(-) diff --git a/public/help/en-US/local-embedding.md b/public/help/en-US/local-embedding.md index 2bf60a2f..9adcb93f 100644 --- a/public/help/en-US/local-embedding.md +++ b/public/help/en-US/local-embedding.md @@ -36,7 +36,7 @@ When disabled: - **Default artifact**: official `onnx/model.onnx`, about **190 MiB**. The static vocabulary table is int8-compressed while Transformer computation remains fp32. `dtype: fp32` selects this compact artifact, not a full fp32 vocabulary table. - **Version**: `bekko-embedding-v1-a25m@384@mean@raw`. Changes to model ID, dimensions, pooling, or input scheme update the version; old memory and chapter vectors become stale and must be recomputed (see section 5). -> **Upgrading from GTE**: users who previously used the GTE model must download the Bekko model, and all existing vectors must be rebuilt. Until rebuilding finishes, memory scoring falls back to keywords and time decay, and chapter semantic search is unavailable. +> **Upgrading from GTE**: users who previously used the GTE model must download the Bekko model, and all existing vectors must be rebuilt. Until the model finishes downloading, memory scoring uses keywords and time decay. Once the model is ready, old memories whose vectors have not been rebuilt are rarely auto-injected or found by `search_memories` (in semantic mode, keywords and time decay alone cannot reach the minimum score), and chapter semantic search is unavailable. Opening a book's details page rebuilds that book's vectors automatically, so rebuild soon after upgrading. ### Backends {#local-embedding-section-5} @@ -133,9 +133,9 @@ Background memory and chapter inference uses groups of at most four inputs, spli `MODEL_VERSION` records the current space and is stored on each embedded memory/chunk. Shared `isMemoryEmbeddingStale` / `isChapterChunkStale` checks classify: - **Current**: eligible for semantic retrieval and counted as embedded. -- **Stale**: semantic scoring is skipped (falling back to keywords and time decay), and backfill scanning queues a rebuild. +- **Stale**: semantic scoring is skipped, and backfill scanning queues a rebuild. While the model is ready, other memories are still scored in semantic mode, so stale records keep only the small keyword and time-decay weights and usually miss the injection threshold. Scoring switches entirely to keywords and time decay only when no record can use semantics. -**A model upgrade does not lose data**: old records remain retrievable with temporarily reduced scoring until rebuilding finishes. +**A model upgrade does not lose data**: old records and memory content are kept, but they largely sit out retrieval and injection until rebuilt; after rebuilding, full capability returns. While local embeddings are enabled, startup removes **old model caches** such as `embeddinggemma` and `qwen3-embedding` from browser Cache Storage to reclaim disk space. The old GTE model cache (about 340–465 MB) is **intentionally kept** for now and not deleted automatically; to reclaim that space, clear this site's data in the browser (the current model then needs to be downloaded again). diff --git a/public/help/zh-CN/local-embedding.md b/public/help/zh-CN/local-embedding.md index 3126713b..47465a1f 100644 --- a/public/help/zh-CN/local-embedding.md +++ b/public/help/zh-CN/local-embedding.md @@ -36,7 +36,7 @@ - **默认文件**:官方 `onnx/model.onnx`,约 **190 MiB**;静态词表已做 int8 压缩,Transformer 计算保持 fp32。`dtype: fp32` 选择这个已压缩文件,不表示下载完整 fp32 词表。 - **模型版本号**:`bekko-embedding-v1-a25m@384@mean@raw`。模型 ID、维度、pooling 或输入方案变化都会更新版本号,旧记忆与章节向量会被判定为 stale,需要重新计算(见第 5 节)。 -> **从 GTE 升级**:此前使用 GTE 模型的用户需要重新下载 Bekko 模型,已有向量全部需要重建。重建完成前,记忆打分降级为关键词 + 时间衰减,章节语义搜索暂不可用。 +> **从 GTE 升级**:此前使用 GTE 模型的用户需要重新下载 Bekko 模型,已有向量全部需要重建。模型下载完成前,记忆打分使用关键词 + 时间衰减;模型就绪后,尚未重建向量的旧记忆几乎不会被自动注入或被 `search_memories` 找到(语义模式下仅靠关键词与时间衰减达不到最低分阈值),章节语义搜索也暂不可用。打开书籍详情页会自动重建该书向量,建议升级后尽快完成重建。 ### 运行后端 {#local-embedding-section-5} @@ -133,9 +133,9 @@ Quasar 检测到物理移动设备时会**锁定**「启用本地嵌入」开关 应用通过 `MODEL_VERSION` 常量记录当前空间身份,每条已嵌入的记忆 / chunk 都会同时存这个版本号。检索与统计逻辑会调用统一的 `isMemoryEmbeddingStale` / `isChapterChunkStale` 判断版本是否匹配: - 匹配(current):参与语义检索 / 计入"已嵌入"。 -- 不匹配(stale):跳过语义评分(自动降级到关键词 + 时间衰减),并在回填扫描时进入重算队列。 +- 不匹配(stale):跳过语义评分,并在回填扫描时进入重算队列。模型就绪时其他记忆仍按语义模式打分,stale 条目只剩关键词与时间衰减的小权重,通常达不到注入阈值;全部条目都无法使用语义时,才整体切换为关键词 + 时间衰减。 -**这意味着升级模型不会丢数据**:旧条目仍能被检索,只是暂时降级使用,等重建完成后回到完整能力。 +**这意味着升级模型不会丢数据**:旧条目和记忆内容都保留,但在重建完成前基本不参与检索与注入;重建后回到完整能力。 启用本地嵌入时,应用启动会清理浏览器 Cache Storage 中的**历史模型残留**(如 `embeddinggemma`、`qwen3-embedding`),回收磁盘空间。旧 GTE 模型的缓存(约 340–465 MB)目前**有意保留**,暂不自动删除;如需回收空间,可在浏览器中清除本站点数据(之后需要重新下载当前模型)。 diff --git a/public/help/zh-TW/local-embedding.md b/public/help/zh-TW/local-embedding.md index 54480831..5cad89c1 100644 --- a/public/help/zh-TW/local-embedding.md +++ b/public/help/zh-TW/local-embedding.md @@ -36,7 +36,7 @@ - **預設檔案**:官方 `onnx/model.onnx`,約 **190 MiB**;靜態詞表已做 int8 壓縮,Transformer 計算保持 fp32。`dtype: fp32` 選擇這個已壓縮檔案,不表示下載完整 fp32 詞表。 - **模型版本號**:`bekko-embedding-v1-a25m@384@mean@raw`。模型 ID、維度、pooling 或輸入方案變化都會更新版本號,舊記憶與章節向量會被判定為 stale,需要重新計算(見第 5 節)。 -> **從 GTE 升級**:先前使用 GTE 模型的使用者需要重新下載 Bekko 模型,既有向量全部需要重建。重建完成前,記憶評分降級為關鍵詞 + 時間衰減,章節語意搜尋暫時無法使用。 +> **從 GTE 升級**:先前使用 GTE 模型的使用者需要重新下載 Bekko 模型,既有向量全部需要重建。模型下載完成前,記憶評分使用關鍵詞 + 時間衰減;模型就緒後,尚未重建向量的舊記憶幾乎不會被自動注入或被 `search_memories` 找到(語意模式下僅靠關鍵詞與時間衰減達不到最低分門檻),章節語意搜尋也暫時無法使用。開啟書籍詳情頁會自動重建該書向量,建議升級後盡快完成重建。 ### 執行後端 {#local-embedding-section-5} @@ -133,9 +133,9 @@ Quasar 偵測到實體行動裝置時會**鎖定**「啟用本地嵌入」開關 應用程式透過 `MODEL_VERSION` 常數記錄目前空間身份,每條已嵌入的記憶 / chunk 都會同時儲存這個版本號。檢索與統計邏輯會呼叫統一的 `isMemoryEmbeddingStale` / `isChapterChunkStale` 判斷版本是否相符: - 相符(current):參與語意檢索 / 計入「已嵌入」。 -- 不相符(stale):跳過語意評分(自動降級到關鍵詞 + 時間衰減),並在補齊掃描時進入重算佇列。 +- 不相符(stale):跳過語意評分,並在補齊掃描時進入重算佇列。模型就緒時其他記憶仍按語意模式評分,stale 項目只剩關鍵詞與時間衰減的小權重,通常達不到注入門檻;所有項目都無法使用語意時,才整體切換為關鍵詞 + 時間衰減。 -**這表示升級模型不會遺失資料**:舊項目仍能被檢索,只是暫時降級使用,等重建完成後回到完整能力。 +**這表示升級模型不會遺失資料**:舊項目與記憶內容都會保留,但在重建完成前基本不參與檢索與注入;重建後回到完整能力。 啟用本地嵌入時,應用程式啟動會清理瀏覽器 Cache Storage 中的**歷史模型殘留**(如 `embeddinggemma`、`qwen3-embedding`),回收磁碟空間。舊 GTE 模型的快取(約 340–465 MB)目前**刻意保留**,暫不自動刪除;如需回收空間,可在瀏覽器中清除本網站資料(之後需要重新下載目前的模型)。 diff --git a/public/releaseNotes/RELEASE_NOTES_v0.18.0.md b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md index 3a0b3345..e0157ccf 100644 --- a/public/releaseNotes/RELEASE_NOTES_v0.18.0.md +++ b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md @@ -83,7 +83,7 @@ - **多设备同步请按顺序升级**:本版本把同步协议从 v4 升级到 v6(中间的 v5 没有正式发布)。新版本可以读取旧协议 1–5,第一次同步时会把整个 Gist 迁移到 v6 的新文件布局,上传量可能比较大;数据太多时会分批上传,中断后再次同步会继续完成。v0.17.0 及更早的客户端遇到 v6 数据时会停止同步,并提示升级。请先在每台设备上导出备份,再把所有设备升级到 v0.18.0,最后逐台完成同步。**旧版本的强制推送仍会覆盖共享 Gist**,升级之前请先关闭旧设备的同步,不要用旧版强制推送。 - **迁移后可清理旧文件**:迁移完成后,可以在「清理远端遗留文件」中扫描、清理不再使用的旧格式文件。历史修订仍可恢复。 - **需要重新下载嵌入模型(约 190 MiB)**:启动时不会自动下载新模型。请在 **设置 → 本地嵌入** 点「下载模型」,或者等嵌入任务(例如保存记忆)自动触发下载。旧 GTE 模型的文件(约 340–465 MB)会暂时保留在浏览器缓存中,如需释放空间,可以清除本站点的数据。 -- **已有向量需要重建**:旧的 768 维向量和章节向量都会失效,但不会丢失数据。重建之前,记忆检索只使用关键词和时间衰减,章节搜索暂时不可用。模型就绪后打开书籍详情页,会自动重建这本书的向量;也可以在「向量索引」面板中点「立即重建」、「回填缺失」或「全部重算」。没有打开过的书不会自动重建。 +- **已有向量需要重建**:旧的 768 维向量和章节向量都会失效,但不会丢失数据。模型下载完成前,记忆检索使用关键词和时间衰减;模型就绪后,尚未重建的旧记忆基本不会被自动注入或搜索到,章节搜索也暂时不可用,请尽快完成重建。模型就绪后打开书籍详情页,会自动重建这本书的向量;也可以在「向量索引」面板中点「立即重建」、「回填缺失」或「全部重算」。没有打开过的书不会自动重建。 - **「向量索引」按钮**:没有启用本地嵌入时,「向量索引」按钮会隐藏(以前会显示一个禁用提示)。 - 新增 `@noble/hashes` 依赖(用于恢复签名),并为 `@huggingface/transformers` 4.3.0 打了补丁(`patches/`)。开发者需要重新运行 `bun install`。 diff --git a/src/__tests__/sync-data-service.test.ts b/src/__tests__/sync-data-service.test.ts index 7bcdf691..75132e2c 100644 --- a/src/__tests__/sync-data-service.test.ts +++ b/src/__tests__/sync-data-service.test.ts @@ -1640,6 +1640,38 @@ describe('数据同步服务 (SyncDataService)', () => { expect(mockCoverHistoryStore.replaceHistory).toHaveBeenCalledWith([]); }); + it('旧格式合并写入 Memory 失败时应向上抛出,阻止后续上传', async () => { + mockBooksStore.books = [{ id: 'b1', title: 'Local Book' }] as unknown[]; + mockMemoryService.getAllMemories.mockResolvedValueOnce([]); + mockMemoryService.createMemoryWithId.mockRejectedValueOnce(new Error('QuotaExceededError')); + + await (expect( + SyncDataService.applyDownloadedData({ + memories: [{ id: 'abcd1234', bookId: 'b1', content: 'c', summary: 's', createdAt: 1000 }], + }), + ).rejects.toThrow('QuotaExceededError') as unknown as Promise); + }); + + it('旧格式合并删除过期 Memory 失败时应向上抛出', async () => { + mockBooksStore.books = [{ id: 'b1', title: 'Local Book' }] as unknown[]; + mockSettingsStore.gistSync.lastSyncTime = 5000; + mockMemoryService.getAllMemories.mockResolvedValueOnce([ + { id: 'stale-1', bookId: 'b1', content: 'old', summary: 's', createdAt: 1000 }, + ]); + mockMemoryService.deleteMemory.mockRejectedValueOnce(new Error('delete failed')); + + await (expect( + SyncDataService.applyDownloadedData( + { + memories: [ + { id: 'keep-1', bookId: 'b1', content: 'kept', summary: 's', createdAt: 1000 }, + ], + }, + 5000, + ), + ).rejects.toThrow('delete failed') as unknown as Promise); + }); + it('同步 Memory 时不应因为生成新 ID 而重复创建(应保留远程 memory.id)', async () => { // 本地已有书籍(同步 Memory 合并逻辑依赖 booksStore.books) mockBooksStore.books = [{ id: 'b1', title: 'Local Book' }] as unknown[]; diff --git a/src/constants/version.ts b/src/constants/version.ts index 1ed1386f..4175b3de 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.18.0.7'; +export const APP_VERSION = '0.18.0.8'; diff --git a/src/i18n/en-US/embedding-ui.ts b/src/i18n/en-US/embedding-ui.ts index b906710b..bf8894fa 100644 --- a/src/i18n/en-US/embedding-ui.ts +++ b/src/i18n/en-US/embedding-ui.ts @@ -42,7 +42,7 @@ export default { openSettings: 'Enable in settings', upgraded: 'Embedding space upgraded', staleHint: - 'Outdated embeddings were found for {items}. Search falls back while chapter search is unavailable. Rebuild to restore semantic retrieval.', + 'Outdated embeddings were found for {items}. Until rebuilt, those memories are rarely injected or found, and chapter search is unavailable. Rebuild to restore semantic retrieval.', staleChapters: '{count} chapter | {count} chapters', staleMemories: '{count} memory | {count} memories', rebuildNow: 'Rebuild now', diff --git a/src/i18n/zh-CN/embedding-ui.ts b/src/i18n/zh-CN/embedding-ui.ts index 2ae6a39f..bea112db 100644 --- a/src/i18n/zh-CN/embedding-ui.ts +++ b/src/i18n/zh-CN/embedding-ui.ts @@ -41,7 +41,8 @@ export default { '启用后可在本地下载嵌入模型(约 190 MiB),支持语义记忆检索与章节向量搜索。关闭状态下所有相关操作按钮均已隐藏。', openSettings: '前往设置开启', upgraded: 'Embedding 空间已升级', - staleHint: '检测到旧版向量:{items}。检索会自动降级,章节搜索暂不可用;重建后恢复语义召回。', + staleHint: + '检测到旧版向量:{items}。重建前这些记忆基本不会被注入或检索到,章节搜索暂不可用;重建后恢复语义召回。', staleChapters: '{count} 个章节', staleMemories: '{count} 条记忆', rebuildNow: '立即重建', diff --git a/src/i18n/zh-TW/embedding-ui.ts b/src/i18n/zh-TW/embedding-ui.ts index 7bd42b99..29972ec7 100644 --- a/src/i18n/zh-TW/embedding-ui.ts +++ b/src/i18n/zh-TW/embedding-ui.ts @@ -42,7 +42,7 @@ export default { openSettings: '前往設定啟用', upgraded: 'Embedding 空間已升級', staleHint: - '偵測到舊版向量:{items}。檢索會自動降級,章節搜尋暫時無法使用;重建後恢復語意召回。', + '偵測到舊版向量:{items}。重建前這些記憶基本不會被注入或檢索到,章節搜尋暫時無法使用;重建後恢復語意召回。', staleChapters: '{count} 個章節', staleMemories: '{count} 條記憶', rebuildNow: '立即重建', diff --git a/src/services/sync-data-service.ts b/src/services/sync-data-service.ts index fe99b8eb..7123d99c 100644 --- a/src/services/sync-data-service.ts +++ b/src/services/sync-data-service.ts @@ -1855,6 +1855,7 @@ export class SyncDataService { await MemoryService.deleteMemory(bookId, staleId); } catch (error) { console.warn(`[SyncDataService] 删除旧 Memory ${staleId} 失败:`, error); + throw error; } } } @@ -2154,7 +2155,9 @@ export class SyncDataService { updatedAt: memoryModifiedAt(memory), }); } catch (error) { + // 与 persistMergedMemories 一致:写入失败必须中止本轮应用,避免随后上传丢失该记忆 console.warn(`[SyncDataService] 写入 Memory ${memory.id} 失败:`, error); + throw error; } } From abf8645305a1d5447fa3b7c305c1508555767485 Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 20:51:14 -0400 Subject: [PATCH 09/16] feat(memory): score unembedded memories with fallback weights on strong keyword matches --- public/help/en-US/book-details-memory.md | 2 + public/help/en-US/local-embedding.md | 6 +- public/help/zh-CN/book-details-memory.md | 2 + public/help/zh-CN/local-embedding.md | 6 +- public/help/zh-TW/book-details-memory.md | 2 + public/help/zh-TW/local-embedding.md | 6 +- public/releaseNotes/RELEASE_NOTES_v0.18.0.md | 3 +- src/__tests__/memory-scoring.test.ts | 121 +++++++++++++++++-- src/constants/version.ts | 2 +- src/i18n/en-US/embedding-ui.ts | 2 +- src/i18n/zh-CN/embedding-ui.ts | 2 +- src/i18n/zh-TW/embedding-ui.ts | 2 +- src/services/memory-scoring.ts | 51 +++++--- 13 files changed, 168 insertions(+), 39 deletions(-) diff --git a/public/help/en-US/book-details-memory.md b/public/help/en-US/book-details-memory.md index 352376fd..7935e143 100644 --- a/public/help/en-US/book-details-memory.md +++ b/public/help/en-US/book-details-memory.md @@ -116,6 +116,8 @@ Without embeddings: | Keywords | 0.75 | Same keyword evidence | | Recency | 0.25 | Same recency evidence | +**Memories without a usable vector (stale after a model upgrade, or new and not yet embedded)**: when a query vector is available, such a memory switches to the keyword fallback weights above only if its raw keyword confidence is at least 0.8. Partial matches keep the semantic-led weights (keyword 0.10 + time decay 0.05) and stay below the minimum relevance threshold. + > Raw cosine below 0.30 provides no semantic evidence. With at least four candidates, medium similarities need to exceed the batch median by 0.08 for full contrast confidence, preventing an entirely irrelevant batch from gaining maximum relevance by rank alone. ### Character budget {#book-details-memory-section-17} diff --git a/public/help/en-US/local-embedding.md b/public/help/en-US/local-embedding.md index 9adcb93f..0ad19490 100644 --- a/public/help/en-US/local-embedding.md +++ b/public/help/en-US/local-embedding.md @@ -36,7 +36,7 @@ When disabled: - **Default artifact**: official `onnx/model.onnx`, about **190 MiB**. The static vocabulary table is int8-compressed while Transformer computation remains fp32. `dtype: fp32` selects this compact artifact, not a full fp32 vocabulary table. - **Version**: `bekko-embedding-v1-a25m@384@mean@raw`. Changes to model ID, dimensions, pooling, or input scheme update the version; old memory and chapter vectors become stale and must be recomputed (see section 5). -> **Upgrading from GTE**: users who previously used the GTE model must download the Bekko model, and all existing vectors must be rebuilt. Until the model finishes downloading, memory scoring uses keywords and time decay. Once the model is ready, old memories whose vectors have not been rebuilt are rarely auto-injected or found by `search_memories` (in semantic mode, keywords and time decay alone cannot reach the minimum score), and chapter semantic search is unavailable. Opening a book's details page rebuilds that book's vectors automatically, so rebuild soon after upgrading. +> **Upgrading from GTE**: users who previously used the GTE model must download the Bekko model, and all existing vectors must be rebuilt. Until the model finishes downloading, memory scoring uses keywords and time decay. Once the model is ready, an old memory whose vector has not been rebuilt switches to keyword + time-decay scoring on its own when its raw keyword confidence is at least 0.8, so it can still be auto-injected or found by `search_memories`; partial matches are not recalled. Chapter semantic search is unavailable until rebuilt. Opening a book's details page rebuilds that book's vectors automatically, so rebuild soon after upgrading. ### Backends {#local-embedding-section-5} @@ -133,9 +133,9 @@ Background memory and chapter inference uses groups of at most four inputs, spli `MODEL_VERSION` records the current space and is stored on each embedded memory/chunk. Shared `isMemoryEmbeddingStale` / `isChapterChunkStale` checks classify: - **Current**: eligible for semantic retrieval and counted as embedded. -- **Stale**: semantic scoring is skipped, and backfill scanning queues a rebuild. While the model is ready, other memories are still scored in semantic mode, so stale records keep only the small keyword and time-decay weights and usually miss the injection threshold. Scoring switches entirely to keywords and time decay only when no record can use semantics. +- **Stale**: semantic scoring is skipped, and backfill scanning queues a rebuild. While the model is ready, other memories are still scored in semantic mode. A stale record (or a new memory not yet embedded) switches to keyword 0.75 + time decay 0.25 when its raw keyword confidence is at least 0.8; otherwise it keeps only the small semantic-mode weights and misses the injection threshold. -**A model upgrade does not lose data**: old records and memory content are kept, but they largely sit out retrieval and injection until rebuilt; after rebuilding, full capability returns. +**A model upgrade does not lose data**: old records and memory content are kept, and until rebuilt only memories with strong keyword matches are retrieved or injected; after rebuilding, full capability returns. While local embeddings are enabled, startup removes **old model caches** such as `embeddinggemma` and `qwen3-embedding` from browser Cache Storage to reclaim disk space. The old GTE model cache (about 340–465 MB) is **intentionally kept** for now and not deleted automatically; to reclaim that space, clear this site's data in the browser (the current model then needs to be downloaded again). diff --git a/public/help/zh-CN/book-details-memory.md b/public/help/zh-CN/book-details-memory.md index f35a4395..7d2844da 100644 --- a/public/help/zh-CN/book-details-memory.md +++ b/public/help/zh-CN/book-details-memory.md @@ -163,6 +163,8 @@ | **关键词匹配** | 0.75 | 同上 | | **时间衰减** | 0.25 | 同上 | +**记忆缺可用向量(模型升级后的旧向量、尚未生成向量的新记忆)**:查询向量可用时,这类记忆的关键词原始置信度 ≥ 0.8 才逐条改用上面的关键词 fallback 权重;只部分命中时仍按语义主导权重计分(关键词 0.10 + 时间衰减 0.05),达不到最低相关度阈值。 + > 语义信号使用「绝对置信度 × 批内对比度」校准:原始余弦低于 0.30 视为无语义证据;候选数 ≥ 4 时中等相似度还需比本批中位数高出 0.08 才能拿到完整对比度置信度,避免「整批都无关却仍被 RRF 抬成满分」。 ### 字符预算 {#book-details-memory-section-17} diff --git a/public/help/zh-CN/local-embedding.md b/public/help/zh-CN/local-embedding.md index 47465a1f..f0d1d8c3 100644 --- a/public/help/zh-CN/local-embedding.md +++ b/public/help/zh-CN/local-embedding.md @@ -36,7 +36,7 @@ - **默认文件**:官方 `onnx/model.onnx`,约 **190 MiB**;静态词表已做 int8 压缩,Transformer 计算保持 fp32。`dtype: fp32` 选择这个已压缩文件,不表示下载完整 fp32 词表。 - **模型版本号**:`bekko-embedding-v1-a25m@384@mean@raw`。模型 ID、维度、pooling 或输入方案变化都会更新版本号,旧记忆与章节向量会被判定为 stale,需要重新计算(见第 5 节)。 -> **从 GTE 升级**:此前使用 GTE 模型的用户需要重新下载 Bekko 模型,已有向量全部需要重建。模型下载完成前,记忆打分使用关键词 + 时间衰减;模型就绪后,尚未重建向量的旧记忆几乎不会被自动注入或被 `search_memories` 找到(语义模式下仅靠关键词与时间衰减达不到最低分阈值),章节语义搜索也暂不可用。打开书籍详情页会自动重建该书向量,建议升级后尽快完成重建。 +> **从 GTE 升级**:此前使用 GTE 模型的用户需要重新下载 Bekko 模型,已有向量全部需要重建。模型下载完成前,记忆打分使用关键词 + 时间衰减;模型就绪后,尚未重建向量的旧记忆在关键词强命中(原始置信度 ≥ 0.8)时逐条改用关键词 + 时间衰减评分,仍可被自动注入或被 `search_memories` 找到;只部分命中的不会被召回。章节语义搜索在重建前暂不可用。打开书籍详情页会自动重建该书向量,建议升级后尽快完成重建。 ### 运行后端 {#local-embedding-section-5} @@ -133,9 +133,9 @@ Quasar 检测到物理移动设备时会**锁定**「启用本地嵌入」开关 应用通过 `MODEL_VERSION` 常量记录当前空间身份,每条已嵌入的记忆 / chunk 都会同时存这个版本号。检索与统计逻辑会调用统一的 `isMemoryEmbeddingStale` / `isChapterChunkStale` 判断版本是否匹配: - 匹配(current):参与语义检索 / 计入"已嵌入"。 -- 不匹配(stale):跳过语义评分,并在回填扫描时进入重算队列。模型就绪时其他记忆仍按语义模式打分,stale 条目只剩关键词与时间衰减的小权重,通常达不到注入阈值;全部条目都无法使用语义时,才整体切换为关键词 + 时间衰减。 +- 不匹配(stale):跳过语义评分,并在回填扫描时进入重算队列。模型就绪时其他记忆仍按语义模式打分;stale 条目(以及尚未生成向量的新记忆)关键词原始置信度 ≥ 0.8 时逐条改用关键词 0.75 + 时间衰减 0.25,否则只剩语义模式下的小权重,达不到注入阈值。 -**这意味着升级模型不会丢数据**:旧条目和记忆内容都保留,但在重建完成前基本不参与检索与注入;重建后回到完整能力。 +**这意味着升级模型不会丢数据**:旧条目和记忆内容都保留,重建完成前只有关键词强命中的记忆能被检索与注入;重建后回到完整能力。 启用本地嵌入时,应用启动会清理浏览器 Cache Storage 中的**历史模型残留**(如 `embeddinggemma`、`qwen3-embedding`),回收磁盘空间。旧 GTE 模型的缓存(约 340–465 MB)目前**有意保留**,暂不自动删除;如需回收空间,可在浏览器中清除本站点数据(之后需要重新下载当前模型)。 diff --git a/public/help/zh-TW/book-details-memory.md b/public/help/zh-TW/book-details-memory.md index 1b6c6a96..93b28a37 100644 --- a/public/help/zh-TW/book-details-memory.md +++ b/public/help/zh-TW/book-details-memory.md @@ -163,6 +163,8 @@ | **關鍵詞匹配** | 0.75 | 同上 | | **時間衰減** | 0.25 | 同上 | +**記憶缺少可用向量(模型升級後的舊向量、尚未產生向量的新記憶)**:查詢向量可用時,這類記憶的關鍵詞原始置信度 ≥ 0.8 才逐條改用上面的關鍵詞 fallback 權重;只部分命中時仍按語意主導權重計分(關鍵詞 0.10 + 時間衰減 0.05),達不到最低相關度門檻。 + > 語義信號使用「絕對置信度 × 批內對比度」校准:原始余弦低於 0.30 視為無語義證據;候選數 ≥ 4 時中等相似度還需比本批中位數高出 0.08 才能拿到完整對比度置信度,避免「整批都無關卻仍被 RRF 抬成滿分」。 ### 字符預算 {#book-details-memory-section-17} diff --git a/public/help/zh-TW/local-embedding.md b/public/help/zh-TW/local-embedding.md index 5cad89c1..6ac81a3b 100644 --- a/public/help/zh-TW/local-embedding.md +++ b/public/help/zh-TW/local-embedding.md @@ -36,7 +36,7 @@ - **預設檔案**:官方 `onnx/model.onnx`,約 **190 MiB**;靜態詞表已做 int8 壓縮,Transformer 計算保持 fp32。`dtype: fp32` 選擇這個已壓縮檔案,不表示下載完整 fp32 詞表。 - **模型版本號**:`bekko-embedding-v1-a25m@384@mean@raw`。模型 ID、維度、pooling 或輸入方案變化都會更新版本號,舊記憶與章節向量會被判定為 stale,需要重新計算(見第 5 節)。 -> **從 GTE 升級**:先前使用 GTE 模型的使用者需要重新下載 Bekko 模型,既有向量全部需要重建。模型下載完成前,記憶評分使用關鍵詞 + 時間衰減;模型就緒後,尚未重建向量的舊記憶幾乎不會被自動注入或被 `search_memories` 找到(語意模式下僅靠關鍵詞與時間衰減達不到最低分門檻),章節語意搜尋也暫時無法使用。開啟書籍詳情頁會自動重建該書向量,建議升級後盡快完成重建。 +> **從 GTE 升級**:先前使用 GTE 模型的使用者需要重新下載 Bekko 模型,既有向量全部需要重建。模型下載完成前,記憶評分使用關鍵詞 + 時間衰減;模型就緒後,尚未重建向量的舊記憶在關鍵詞強命中(原始置信度 ≥ 0.8)時逐條改用關鍵詞 + 時間衰減評分,仍可被自動注入或被 `search_memories` 找到;只部分命中的不會被召回。章節語意搜尋在重建前暫時無法使用。開啟書籍詳情頁會自動重建該書向量,建議升級後盡快完成重建。 ### 執行後端 {#local-embedding-section-5} @@ -133,9 +133,9 @@ Quasar 偵測到實體行動裝置時會**鎖定**「啟用本地嵌入」開關 應用程式透過 `MODEL_VERSION` 常數記錄目前空間身份,每條已嵌入的記憶 / chunk 都會同時儲存這個版本號。檢索與統計邏輯會呼叫統一的 `isMemoryEmbeddingStale` / `isChapterChunkStale` 判斷版本是否相符: - 相符(current):參與語意檢索 / 計入「已嵌入」。 -- 不相符(stale):跳過語意評分,並在補齊掃描時進入重算佇列。模型就緒時其他記憶仍按語意模式評分,stale 項目只剩關鍵詞與時間衰減的小權重,通常達不到注入門檻;所有項目都無法使用語意時,才整體切換為關鍵詞 + 時間衰減。 +- 不相符(stale):跳過語意評分,並在補齊掃描時進入重算佇列。模型就緒時其他記憶仍按語意模式評分;stale 項目(以及尚未產生向量的新記憶)關鍵詞原始置信度 ≥ 0.8 時逐條改用關鍵詞 0.75 + 時間衰減 0.25,否則只剩語意模式下的小權重,達不到注入門檻。 -**這表示升級模型不會遺失資料**:舊項目與記憶內容都會保留,但在重建完成前基本不參與檢索與注入;重建後回到完整能力。 +**這表示升級模型不會遺失資料**:舊項目與記憶內容都會保留,重建完成前只有關鍵詞強命中的記憶能被檢索與注入;重建後回到完整能力。 啟用本地嵌入時,應用程式啟動會清理瀏覽器 Cache Storage 中的**歷史模型殘留**(如 `embeddinggemma`、`qwen3-embedding`),回收磁碟空間。舊 GTE 模型的快取(約 340–465 MB)目前**刻意保留**,暫不自動刪除;如需回收空間,可在瀏覽器中清除本網站資料(之後需要重新下載目前的模型)。 diff --git a/public/releaseNotes/RELEASE_NOTES_v0.18.0.md b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md index e0157ccf..a6068cca 100644 --- a/public/releaseNotes/RELEASE_NOTES_v0.18.0.md +++ b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md @@ -63,6 +63,7 @@ ## 🧩 记忆注入与检索 (Memory Injection & RAG) - **字符预算严格执行**:记忆注入的字符预算包含标题、ID 和换行,第一条记忆也不会再超出预算。放不下的长记忆会被跳过,后面较短的记忆仍可注入;出错时的兜底注入也遵守预算。 +- **缺向量的记忆逐条降级**:模型就绪后,向量过期(例如升级后尚未重建)或还没生成向量的记忆,如果关键词强命中(原始置信度 ≥ 0.8),会单独改用关键词 + 时间衰减评分,不再因为缺少向量而无法注入;只部分命中的记忆仍不会被召回,避免弱匹配挤进上下文。 - **关闭本地嵌入后立即降级**:关闭「启用本地嵌入」后,即使模型仍在内存中,自动注入和 `search_memories` 也会改用关键词 + 时间衰减评分,不再计算语义相似度。 - **检索更快**:查询向量缓存由自动注入、AI 工具和预览共用;`query_chapter` 会缓存当前书籍的向量快照,并并行读取章节正文。章节向量按小批次生成,暂停或关闭后能及时停止。 @@ -83,7 +84,7 @@ - **多设备同步请按顺序升级**:本版本把同步协议从 v4 升级到 v6(中间的 v5 没有正式发布)。新版本可以读取旧协议 1–5,第一次同步时会把整个 Gist 迁移到 v6 的新文件布局,上传量可能比较大;数据太多时会分批上传,中断后再次同步会继续完成。v0.17.0 及更早的客户端遇到 v6 数据时会停止同步,并提示升级。请先在每台设备上导出备份,再把所有设备升级到 v0.18.0,最后逐台完成同步。**旧版本的强制推送仍会覆盖共享 Gist**,升级之前请先关闭旧设备的同步,不要用旧版强制推送。 - **迁移后可清理旧文件**:迁移完成后,可以在「清理远端遗留文件」中扫描、清理不再使用的旧格式文件。历史修订仍可恢复。 - **需要重新下载嵌入模型(约 190 MiB)**:启动时不会自动下载新模型。请在 **设置 → 本地嵌入** 点「下载模型」,或者等嵌入任务(例如保存记忆)自动触发下载。旧 GTE 模型的文件(约 340–465 MB)会暂时保留在浏览器缓存中,如需释放空间,可以清除本站点的数据。 -- **已有向量需要重建**:旧的 768 维向量和章节向量都会失效,但不会丢失数据。模型下载完成前,记忆检索使用关键词和时间衰减;模型就绪后,尚未重建的旧记忆基本不会被自动注入或搜索到,章节搜索也暂时不可用,请尽快完成重建。模型就绪后打开书籍详情页,会自动重建这本书的向量;也可以在「向量索引」面板中点「立即重建」、「回填缺失」或「全部重算」。没有打开过的书不会自动重建。 +- **已有向量需要重建**:旧的 768 维向量和章节向量都会失效,但不会丢失数据。模型下载完成前,记忆检索使用关键词和时间衰减;模型就绪后,尚未重建的旧记忆在关键词强命中时仍可被注入和搜索到,只部分命中的不会被召回;章节搜索在重建前暂时不可用,请尽快完成重建。模型就绪后打开书籍详情页,会自动重建这本书的向量;也可以在「向量索引」面板中点「立即重建」、「回填缺失」或「全部重算」。没有打开过的书不会自动重建。 - **「向量索引」按钮**:没有启用本地嵌入时,「向量索引」按钮会隐藏(以前会显示一个禁用提示)。 - 新增 `@noble/hashes` 依赖(用于恢复签名),并为 `@huggingface/transformers` 4.3.0 打了补丁(`patches/`)。开发者需要重新运行 `bun install`。 diff --git a/src/__tests__/memory-scoring.test.ts b/src/__tests__/memory-scoring.test.ts index bd519eb3..7a478632 100644 --- a/src/__tests__/memory-scoring.test.ts +++ b/src/__tests__/memory-scoring.test.ts @@ -14,6 +14,7 @@ import { FALLBACK_WEIGHTS, MAX_TOTAL_SCORE, DEFAULT_MIN_SCORE, + UNEMBEDDED_FALLBACK_MIN_KEYWORD, } from 'src/services/memory-scoring'; import type { ScoredMemory } from 'src/services/memory-scoring'; @@ -205,27 +206,68 @@ describe('memory-scoring - scoreMemory', () => { expect(breakdown.total).toBeCloseTo(MAX_TOTAL_SCORE, 5); }); - test('查询 embedding 可用但记忆缺向量时不提升辅助信号权重', () => { + test('查询 embedding 可用但记忆缺向量且关键词只部分命中时不提升辅助信号权重', () => { const now = 1_000_000_000; const memory = makeMemory({ summary: '小明', lastAccessedAt: now, }); const breakdown = scoreMemory(memory, { - chunkEntities: [{ name: '小明' }], + chunkEntities: [{ name: '小明' }, { name: '小红' }], chunkEmbedding: [1, 0], now, }); - // 查询已进入 embedding 模式;单条记忆缺向量时不能偷偷切回 fallback 抬高辅助信号。 + // 关键词置信度不足时不能切回 fallback 抬高辅助信号,仍按语义模式小权重计分。 + expect(breakdown.scoringMode).toBe('semantic'); expect(breakdown.semantic).toBe(0); - expect(breakdown.keyword).toBeCloseTo(1, 5); - expect(breakdown.recency).toBeCloseTo(1, 5); - expect(breakdown.keywordWeighted).toBeCloseTo(SCORING_WEIGHTS.keyword, 5); + expect(breakdown.keyword).toBeCloseTo(0.5, 5); + expect(breakdown.keyword).toBeLessThan(UNEMBEDDED_FALLBACK_MIN_KEYWORD); + expect(breakdown.keywordWeighted).toBeCloseTo(0.5 * SCORING_WEIGHTS.keyword, 5); expect(breakdown.recencyWeighted).toBeCloseTo(SCORING_WEIGHTS.recency, 5); expect(breakdown.total).toBeLessThan(DEFAULT_MIN_SCORE); }); + test('查询 embedding 可用但记忆缺向量且关键词强命中时按 fallback 权重计分', () => { + const now = 1_000_000_000; + const memory = makeMemory({ + summary: '小明', + lastAccessedAt: now, + }); + const breakdown = scoreMemory(memory, { + chunkEntities: [{ name: '小明' }], + chunkEmbedding: [1, 0], + now, + }); + + expect(breakdown.scoringMode).toBe('fallback'); + expect(breakdown.semantic).toBe(0); + expect(breakdown.semanticWeighted).toBe(0); + expect(breakdown.keywordWeighted).toBeCloseTo(FALLBACK_WEIGHTS.keyword, 5); + expect(breakdown.recencyWeighted).toBeCloseTo(FALLBACK_WEIGHTS.recency, 5); + expect(breakdown.total).toBeGreaterThan(DEFAULT_MIN_SCORE); + }); + + test('版本不匹配的 stale 记忆关键词强命中时同样按 fallback 权重计分', () => { + const now = 1_000_000_000; + const memory = makeMemory({ + summary: '小明', + embeddings: [[1, 0]], + embeddingModel: 'v-old', + lastAccessedAt: now, + }); + const breakdown = scoreMemory(memory, { + chunkEntities: [{ name: '小明' }], + chunkEmbedding: [1, 0], + now, + expectedModelVersion: 'v-new', + }); + + expect(breakdown.scoringMode).toBe('fallback'); + expect(breakdown.semantic).toBe(0); + expect(breakdown.total).toBeGreaterThan(DEFAULT_MIN_SCORE); + }); + test('旧单向量 embedding 字段不再参与语义评分', () => { const legacyMemory = { ...makeMemory(), embedding: [1, 0] } as Memory & { embedding: number[]; @@ -813,11 +855,72 @@ describe('memory-scoring - scoreMemoriesBatch', () => { }); const byId = new Map(result.map((s) => [s.memory.id, s])); - // c 被当作无 semantic,但仍可通过 keyword 排名通道参与融合。 + // c 没有可用向量且关键词完整命中 → 逐条降级为 fallback 权重,重建前仍可被召回。 expect(byId.get('c')!.breakdown.semantic).toBe(0); - expect(byId.get('c')!.breakdown.keywordWeighted).toBeCloseTo(SCORING_WEIGHTS.keyword, 5); - // a、b 语义值正常计算。 + expect(byId.get('c')!.breakdown.scoringMode).toBe('fallback'); + expect(byId.get('c')!.breakdown.keywordWeighted).toBeCloseTo(FALLBACK_WEIGHTS.keyword, 5); + // a、b 有可用向量,仍按语义优先权重计分。 expect(byId.get('a')!.breakdown.semantic).toBeGreaterThan(0); + expect(byId.get('a')!.breakdown.scoringMode).toBe('semantic'); + expect(byId.get('b')!.breakdown.scoringMode).toBe('semantic'); + }); + + test('缺向量记忆关键词只部分命中时不降级,达不到注入阈值', () => { + const now = Date.now(); + const memories: Memory[] = [ + makeMemory({ id: 'embedded', summary: '无关', embeddings: [makeEmbedding([0, 1, 0])] }), + makeMemory({ id: 'pending', summary: '小明', lastAccessedAt: now }), + ]; + + const result = scoreMemoriesBatch(memories, { + chunkEntities: [{ name: '小明' }, { name: '小红' }], + chunkEmbedding: makeEmbedding([1, 0, 0]), + now, + }); + const pending = result.find((s) => s.memory.id === 'pending')!.breakdown; + + expect(pending.scoringMode).toBe('semantic'); + expect(pending.keyword).toBeLessThan(UNEMBEDDED_FALLBACK_MIN_KEYWORD); + expect(pending.total).toBeLessThan(DEFAULT_MIN_SCORE); + }); + + test('缺向量记忆没有关键词命中时,仅靠时间衰减不能越过注入阈值', () => { + const now = Date.now(); + const result = scoreMemoriesBatch( + [ + makeMemory({ id: 'embedded', summary: '小明', embeddings: [makeEmbedding([1, 0, 0])] }), + makeMemory({ id: 'recent-pending', summary: '其他内容', lastAccessedAt: now }), + ], + { + chunkEntities: [{ name: '小明' }], + chunkEmbedding: makeEmbedding([1, 0, 0]), + now, + }, + ); + const pending = result.find((s) => s.memory.id === 'recent-pending')!.breakdown; + + expect(pending.scoringMode).toBe('semantic'); + expect(pending.total).toBeLessThan(DEFAULT_MIN_SCORE); + }); + + test('全部记忆都缺向量时与关闭嵌入的 fallback 打分一致', () => { + const now = Date.now(); + const memories: Memory[] = [ + makeMemory({ id: 'x', summary: '小明', lastAccessedAt: now }), + makeMemory({ id: 'y', summary: '小明和小红', lastAccessedAt: now - 10 * MS_PER_DAY }), + ]; + const context = { chunkEntities: [{ name: '小明' }], now }; + + const withQuery = scoreMemoriesBatch(memories, { + ...context, + chunkEmbedding: makeEmbedding([1, 0, 0]), + }); + const withoutQuery = scoreMemoriesBatch(memories, context); + + withQuery.forEach((scored, i) => { + expect(scored.breakdown.scoringMode).toBe('fallback'); + expect(scored.breakdown.total).toBeCloseTo(withoutQuery[i]!.breakdown.total, 6); + }); }); test('空数组直接返回空', () => { diff --git a/src/constants/version.ts b/src/constants/version.ts index 4175b3de..b7c199a7 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.18.0.8'; +export const APP_VERSION = '0.18.0.9'; diff --git a/src/i18n/en-US/embedding-ui.ts b/src/i18n/en-US/embedding-ui.ts index bf8894fa..bf5a083c 100644 --- a/src/i18n/en-US/embedding-ui.ts +++ b/src/i18n/en-US/embedding-ui.ts @@ -42,7 +42,7 @@ export default { openSettings: 'Enable in settings', upgraded: 'Embedding space upgraded', staleHint: - 'Outdated embeddings were found for {items}. Until rebuilt, those memories are rarely injected or found, and chapter search is unavailable. Rebuild to restore semantic retrieval.', + 'Outdated embeddings were found for {items}. Until rebuilt, only memories with strong keyword matches are injected or found, and chapter search is unavailable. Rebuild to restore semantic retrieval.', staleChapters: '{count} chapter | {count} chapters', staleMemories: '{count} memory | {count} memories', rebuildNow: 'Rebuild now', diff --git a/src/i18n/zh-CN/embedding-ui.ts b/src/i18n/zh-CN/embedding-ui.ts index bea112db..b63c9ac3 100644 --- a/src/i18n/zh-CN/embedding-ui.ts +++ b/src/i18n/zh-CN/embedding-ui.ts @@ -42,7 +42,7 @@ export default { openSettings: '前往设置开启', upgraded: 'Embedding 空间已升级', staleHint: - '检测到旧版向量:{items}。重建前这些记忆基本不会被注入或检索到,章节搜索暂不可用;重建后恢复语义召回。', + '检测到旧版向量:{items}。重建前只有关键词强命中的记忆能被注入或检索到,章节搜索暂不可用;重建后恢复语义召回。', staleChapters: '{count} 个章节', staleMemories: '{count} 条记忆', rebuildNow: '立即重建', diff --git a/src/i18n/zh-TW/embedding-ui.ts b/src/i18n/zh-TW/embedding-ui.ts index 29972ec7..fecf38c8 100644 --- a/src/i18n/zh-TW/embedding-ui.ts +++ b/src/i18n/zh-TW/embedding-ui.ts @@ -42,7 +42,7 @@ export default { openSettings: '前往設定啟用', upgraded: 'Embedding 空間已升級', staleHint: - '偵測到舊版向量:{items}。重建前這些記憶基本不會被注入或檢索到,章節搜尋暫時無法使用;重建後恢復語意召回。', + '偵測到舊版向量:{items}。重建前只有關鍵詞強命中的記憶能被注入或檢索到,章節搜尋暫時無法使用;重建後恢復語意召回。', staleChapters: '{count} 個章節', staleMemories: '{count} 條記憶', rebuildNow: '立即重建', diff --git a/src/services/memory-scoring.ts b/src/services/memory-scoring.ts index 4cef0ad8..e701e2bc 100644 --- a/src/services/memory-scoring.ts +++ b/src/services/memory-scoring.ts @@ -448,6 +448,23 @@ export const FALLBACK_WEIGHTS = { recency: 0.25, } as const; +/** + * 语义模式下,没有可用向量的记忆(模型升级后的 stale 向量、尚未嵌入的新记忆) + * 只有关键词原始置信度达到该值时才逐条降级到 fallback 权重。 + * 部分命中仍按语义权重计分,避免缺向量条目靠弱关键词挤进注入。 + */ +export const UNEMBEDDED_FALLBACK_MIN_KEYWORD = 0.8; + +/** 是否对该条记忆使用 fallback 权重:查询无向量时整体降级;记忆缺向量时需关键词强命中。 */ +function shouldUseFallbackWeights( + semanticMode: boolean, + hasUsableVector: boolean, + keyword: number, +): boolean { + if (!semanticMode) return true; + return !hasUsableVector && keyword >= UNEMBEDDED_FALLBACK_MIN_KEYWORD; +} + /** * 根据 context 合并低权重辅助信号:章节标题/自然语言 query 与正文实体各自打分, * 取更强者。embedding 模式下该信号固定只占 0.10,不能单独决定召回。 @@ -462,7 +479,8 @@ function resolveKeyword(memory: Memory, context: ScoringContext): number { /** * 对单条记忆打分,返回完整 breakdown 结构体。 - * embedding 不可用时按原始关键词置信度降级,访问时间仅保留在 breakdown 中展示。 + * embedding 不可用时按原始关键词置信度降级;记忆本身缺可用向量时, + * 关键词强命中才逐条降级(见 UNEMBEDDED_FALLBACK_MIN_KEYWORD)。 */ export function scoreMemory(memory: Memory, context: ScoringContext): ScoreBreakdown { const chunkEmbeddings = resolveChunkEmbeddings(context); @@ -480,23 +498,25 @@ export function scoreMemory(memory: Memory, context: ScoringContext): ScoreBreak : 0; const keyword = resolveKeyword(memory, context); const recency = calculateRecencyFactor(memory, context.now); + const hasUsableVector = versionOk && memoryEmbeddings.length > 0; + const useFallback = shouldUseFallbackWeights(semanticMode, hasUsableVector, keyword); let semanticWeighted: number; let keywordWeighted: number; let recencyWeighted: number; - if (semanticMode) { - semanticWeighted = semantic * SCORING_WEIGHTS.semantic; - keywordWeighted = keyword * SCORING_WEIGHTS.keyword; - recencyWeighted = recency * SCORING_WEIGHTS.recency; - } else { + if (useFallback) { semanticWeighted = 0; keywordWeighted = keyword * FALLBACK_WEIGHTS.keyword; recencyWeighted = recency * FALLBACK_WEIGHTS.recency; + } else { + semanticWeighted = semantic * SCORING_WEIGHTS.semantic; + keywordWeighted = keyword * SCORING_WEIGHTS.keyword; + recencyWeighted = recency * SCORING_WEIGHTS.recency; } const total = semanticWeighted + keywordWeighted + recencyWeighted; return { - scoringMode: semanticMode ? 'semantic' : 'fallback', + scoringMode: useFallback ? 'fallback' : 'semantic', semantic, keyword, recency, @@ -513,7 +533,8 @@ export function scoreMemory(memory: Memory, context: ScoringContext): ScoreBreak * 和 `scoreMemory` 单条打分的关键区别: * 1. dense 先做多分段稳健聚合,再用绝对余弦与批内对比度校准置信度,最后乘 RRF 名次分。 * 2. keyword 同样转成 RRF 名次分,但仍乘原始命中置信度,避免弱部分匹配被抬成高分。 - * 3. query embedding 可用时固定采用语义优先权重;不可用时才整体切换到 fallback 权重。 + * 3. query embedding 可用时采用语义优先权重;不可用时整体切换到 fallback 权重。 + * 记忆本身缺可用向量(stale / 未嵌入)且关键词强命中时,该条逐条降级到 fallback 权重。 * * 返回与输入下标一一对应的 ScoredMemory 数组(未排序,保持输入顺序)。 * @@ -553,18 +574,16 @@ export function scoreMemoriesBatch(memories: Memory[], context: ScoringContext): const semantic = semanticSignals[i] ?? 0; const keyword = rawKeywords[i] ?? 0; const recency = calculateRecencyFactor(memory, context.now); - const semanticWeighted = semantic * (semanticMode ? SCORING_WEIGHTS.semantic : 0); - const keywordWeighted = - keyword * - (keywordRanks[i] ?? 0) * - (semanticMode ? SCORING_WEIGHTS.keyword : FALLBACK_WEIGHTS.keyword); - const recencyWeighted = - recency * (semanticMode ? SCORING_WEIGHTS.recency : FALLBACK_WEIGHTS.recency); + const useFallback = shouldUseFallbackWeights(semanticMode, rawSemantics[i] !== null, keyword); + const weights = useFallback ? FALLBACK_WEIGHTS : SCORING_WEIGHTS; + const semanticWeighted = useFallback ? 0 : semantic * SCORING_WEIGHTS.semantic; + const keywordWeighted = keyword * (keywordRanks[i] ?? 0) * weights.keyword; + const recencyWeighted = recency * weights.recency; return { memory, breakdown: { - scoringMode: semanticMode ? 'semantic' : 'fallback', + scoringMode: useFallback ? 'fallback' : 'semantic', semantic, keyword, recency, From 1dce2dd5a25a725099c4a67b0736883d745a568a Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 20:57:14 -0400 Subject: [PATCH 10/16] fix: accept empty-summary memories and abort on stale model/book delete failures in legacy sync --- public/help/zh-CN/index.json | 2 +- public/help/zh-TW/index.json | 2 +- public/releaseNotes/RELEASE_NOTES_v0.18.0.md | 2 +- src/__tests__/memory-sync-access.test.ts | 26 ++++++++ src/__tests__/sync-data-service.test.ts | 68 ++++++++++++++++++++ src/constants/version.ts | 2 +- src/services/memory-service.ts | 6 +- src/services/sync-data-service.ts | 20 ++++-- 8 files changed, 118 insertions(+), 10 deletions(-) diff --git a/public/help/zh-CN/index.json b/public/help/zh-CN/index.json index 75a4a1d5..ea31f243 100644 --- a/public/help/zh-CN/index.json +++ b/public/help/zh-CN/index.json @@ -258,7 +258,7 @@ "file": "local-embedding.md", "path": "help/zh-CN", "category": "使用指南", - "description": "本地嵌入引擎完整说明:gte-multilingual-base 模型(CLS pooling)、WebGPU/WASM 后端、批量嵌入面板、长文分段嵌入、模型版本与 stale 重算、同步 sync gate、故障排查", + "description": "本地嵌入引擎完整说明:bekko-embedding-v1-a25m 模型(384 维 mean pooling)、Web Worker 推理与 WebGPU/WASM 后端、向量索引面板、长文分段嵌入、模型版本与 stale 重算、同步 sync gate、故障排查", "categoryId": "guides", "sectionAliases": { "本地嵌入local-embedding": "local-embedding-section-1", diff --git a/public/help/zh-TW/index.json b/public/help/zh-TW/index.json index b2e2cbb1..d61e3fb4 100644 --- a/public/help/zh-TW/index.json +++ b/public/help/zh-TW/index.json @@ -255,7 +255,7 @@ { "category": "使用指南", "categoryId": "guides", - "description": "本地嵌入引擎完整說明:gte-multilingual-base 模型(CLS pooling)、WebGPU/WASM 後端、批量嵌入面板、長文分段嵌入、模型版本與 stale 重算、同步 sync gate、故障排查", + "description": "本地嵌入引擎完整說明:bekko-embedding-v1-a25m 模型(384 維 mean pooling)、Web Worker 推論與 WebGPU/WASM 後端、向量索引面板、長文分段嵌入、模型版本與 stale 重算、同步 sync gate、故障排查", "file": "local-embedding.md", "id": "local-embedding", "path": "help/zh-TW", diff --git a/public/releaseNotes/RELEASE_NOTES_v0.18.0.md b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md index a6068cca..a7d5d60f 100644 --- a/public/releaseNotes/RELEASE_NOTES_v0.18.0.md +++ b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md @@ -12,7 +12,7 @@ ## ☁️ 同步协议 v6 (Chapter-level Incremental Sync) -- **按章节小组增量同步**:书籍资料和目录单独保存,不含正文;章节正文按章节 ID 固定分到 16 个小组,插入或重排章节不会让其他章节换组。新增或修改一章时,通常只上传所在小组、目录(有变化时才传)和清单;只改正文时目录也会跳过。下载时同样只拉取有变化的小组。 +- **按章节小组增量同步**:书籍资料和目录单独保存,不含正文;章节正文按章节 ID 固定分到 16 个小组,插入或重排章节不会让其他章节换组。新增或修改一章时,通常只上传所在小组、目录(有变化时才传)和清单;只改正文时目录也会跳过。下载时只拉取有变化的小组,并读取该书的目录用于组装和校验正文。 - **大书库不再受 300 个文件限制**:GitHub 返回的文件列表最多只有 300 个文件。以前列表被截断就会停止同步,现在会按当前修订读取完整的同步清单,补齐没有列出的文件,并继续校验哈希。只有既被截断、又读不到完整清单时才会停止。 - **书库没有变化时同步更快**:本机记录一个只存在本地的同步检查点。书库、设置和删除记录都没有变化时,不再读取全部章节正文和记忆来重算清单;远端的变化仍会照常检查。冷启动时章节正文改为批量读取,远端文件最多 4 个并发下载。 - **记忆访问时间只在本地**:记忆新增内容修改时间。「最后访问时间」只在本机用于排序和时间衰减,不再上传。因此打开或被 AI 读取的记忆不会再触发同步,也不会覆盖其他设备上较新的修改,或让已删除的记忆恢复。 diff --git a/src/__tests__/memory-sync-access.test.ts b/src/__tests__/memory-sync-access.test.ts index bcca54b3..9f150321 100644 --- a/src/__tests__/memory-sync-access.test.ts +++ b/src/__tests__/memory-sync-access.test.ts @@ -10,6 +10,32 @@ async function syncedMemories() { return normalizeMemoriesForSync({ book: await MemoryService.getAllMemories('book') }).book; } +describe('同步导入接受空摘要记忆', () => { + beforeEach(() => { + spyOn(EmbeddingQueue, 'enqueue').mockImplementation(() => undefined); + }); + afterEach(() => mock.restore()); + + it('普通创建仍拒绝空摘要', async () => { + await (expect( + MemoryService.createMemoryWithId('book-empty', 'strict', '内容', ''), + ).rejects.toThrow() as unknown as Promise); + }); + + it('同步导入模式写入空摘要记忆而不抛错', async () => { + await MemoryService.createMemoryWithId( + 'book-empty', + 'legacy', + '旧版内容', + '', + { createdAt: 50, updatedAt: 50 }, + { allowEmptyText: true }, + ); + const stored = await MemoryService.getAllMemories('book-empty'); + expect(stored.map((m) => [m.id, m.summary, m.content])).toEqual([['legacy', '', '旧版内容']]); + }); +}); + describe('Memory 访问与同步内容分离', () => { beforeEach(() => { spyOn(EmbeddingQueue, 'enqueue').mockImplementation(() => undefined); diff --git a/src/__tests__/sync-data-service.test.ts b/src/__tests__/sync-data-service.test.ts index 75132e2c..d77de4d8 100644 --- a/src/__tests__/sync-data-service.test.ts +++ b/src/__tests__/sync-data-service.test.ts @@ -27,6 +27,7 @@ const mockBooksStore = { rollbackBooks: mock((books: unknown[]) => mockBooksStore.bulkAddBooks(books)), getBookById: mock(() => null), updateBook: mock(() => Promise.resolve()), + deleteBook: mock((_id: string) => Promise.resolve()), }; const mockCoverHistoryStore = { @@ -210,6 +211,7 @@ describe('数据同步服务 (SyncDataService)', () => { mockBooksStore.books = []; mockBooksStore.clearBooks.mockClear(); mockBooksStore.bulkAddBooks.mockClear(); + mockBooksStore.deleteBook.mockClear(); mockCoverHistoryStore.covers = []; mockCoverHistoryStore.clearHistory.mockClear(); @@ -1292,6 +1294,45 @@ describe('数据同步服务 (SyncDataService)', () => { expect(mockDeleteModel).toHaveBeenCalledWith('m1'); }); + it('删除远程已删除的本地模型失败时应向上抛出,阻止后续上传', async () => { + const lastSyncTime = new Date('2024-01-02').getTime(); + mockAIModelsStore.models = [ + { id: 'm1', name: 'Old', lastEdited: new Date('2024-01-01').toISOString() }, + ]; + mockDeleteModel.mockRejectedValueOnce(new Error('model delete failed')); + + await (expect( + SyncDataService.applyDownloadedData( + { + aiModels: [ + { id: 'm2', name: 'Remote', lastEdited: new Date('2024-01-03').toISOString() }, + ], + }, + lastSyncTime, + ), + ).rejects.toThrow('model delete failed') as unknown as Promise); + }); + + it('删除远程已删除的本地书籍失败时应向上抛出,阻止后续上传', async () => { + const lastSyncTime = new Date('2024-01-02').getTime(); + mockBooksStore.books = [ + { id: 'n1', title: 'Old', lastEdited: new Date('2024-01-01').toISOString() }, + ] as unknown[]; + mockBooksStore.deleteBook.mockRejectedValueOnce(new Error('book delete failed')); + + await (expect( + SyncDataService.applyDownloadedData( + { + novels: [ + { id: 'n2', title: 'Remote', lastEdited: new Date('2024-01-03').toISOString() }, + ], + }, + lastSyncTime, + ), + ).rejects.toThrow('book delete failed') as unknown as Promise); + expect(mockBooksStore.deleteBook).toHaveBeenCalledWith('n1'); + }); + it('自动同步时不应返回可恢复的项目', async () => { const lastSyncTime = new Date('2024-01-01').getTime(); const deletionTime = new Date('2024-01-02').getTime(); // 删除时间晚于同步时间 @@ -1640,6 +1681,24 @@ describe('数据同步服务 (SyncDataService)', () => { expect(mockCoverHistoryStore.replaceHistory).toHaveBeenCalledWith([]); }); + it('旧格式合并写入空摘要 Memory 时使用同步导入模式', async () => { + mockBooksStore.books = [{ id: 'b1', title: 'Local Book' }] as unknown[]; + mockMemoryService.getAllMemories.mockResolvedValueOnce([]); + + await SyncDataService.applyDownloadedData({ + memories: [{ id: 'empty-sum', bookId: 'b1', content: 'c', summary: '', createdAt: 1000 }], + }); + + expect(mockMemoryService.createMemoryWithId).toHaveBeenCalledWith( + 'b1', + 'empty-sum', + 'c', + '', + expect.anything(), + { allowEmptyText: true }, + ); + }); + it('旧格式合并写入 Memory 失败时应向上抛出,阻止后续上传', async () => { mockBooksStore.books = [{ id: 'b1', title: 'Local Book' }] as unknown[]; mockMemoryService.getAllMemories.mockResolvedValueOnce([]); @@ -1707,6 +1766,7 @@ describe('数据同步服务 (SyncDataService)', () => { 'c', 's', expect.objectContaining({ createdAt: 1000, lastAccessedAt: 1500 }), + { allowEmptyText: true }, ); // 第二次:本地更新,保留同一 ID(upsert) expect(mockMemoryService.createMemoryWithId).toHaveBeenCalledWith( @@ -1715,6 +1775,7 @@ describe('数据同步服务 (SyncDataService)', () => { 'c', 's', expect.objectContaining({ createdAt: 1000, lastAccessedAt: 2000 }), + { allowEmptyText: true }, ); // 旧逻辑会调用 createMemory()(生成新 id),这会导致重复;现在不应再调用 @@ -1757,6 +1818,7 @@ describe('数据同步服务 (SyncDataService)', () => { '角色A总是使用敬语', '角色A的语言风格', expect.objectContaining({ createdAt: 1000, lastAccessedAt: 2000 }), + { allowEmptyText: true }, ); }); @@ -1795,6 +1857,7 @@ describe('数据同步服务 (SyncDataService)', () => { '角色A总是使用敬语', '新摘要', expect.objectContaining({ createdAt: 1200, lastAccessedAt: 3000 }), + { allowEmptyText: true }, ); // 旧的本地 Memory 应被删除(不在最终列表中) expect(mockMemoryService.deleteMemory).toHaveBeenCalledWith('b1', 'local-1'); @@ -1839,6 +1902,7 @@ describe('数据同步服务 (SyncDataService)', () => { '新内容', '新摘要', expect.objectContaining({ createdAt: 1000, lastAccessedAt: remoteTimestamp }), + { allowEmptyText: true }, ); }); @@ -1902,6 +1966,7 @@ describe('数据同步服务 (SyncDataService)', () => { '远程更新的记忆', '摘要', expect.objectContaining({ createdAt: 800, lastAccessedAt: 2000 }), + { allowEmptyText: true }, ); // 应从删除记录中移除 @@ -2000,6 +2065,7 @@ describe('数据同步服务 (SyncDataService)', () => { expect.any(String), expect.any(String), expect.any(Object), + { allowEmptyText: true }, ); // mem-2 不在远程且 lastAccessedAt <= syncTime → 应被删除 @@ -2076,6 +2142,7 @@ describe('数据同步服务 (SyncDataService)', () => { '本地记忆', '摘要', expect.objectContaining({ createdAt: 1000, lastAccessedAt: 2000 }), + { allowEmptyText: true }, ); }); @@ -3479,6 +3546,7 @@ describe('数据同步服务 (SyncDataService)', () => { 'c', 's', expect.objectContaining({ createdAt: 1, lastAccessedAt: 1 }), + { allowEmptyText: true }, ); }); diff --git a/src/constants/version.ts b/src/constants/version.ts index b7c199a7..43227e46 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.18.0.9'; +export const APP_VERSION = '0.18.0.10'; diff --git a/src/services/memory-service.ts b/src/services/memory-service.ts index 9ab914ad..5a7d6505 100644 --- a/src/services/memory-service.ts +++ b/src/services/memory-service.ts @@ -216,6 +216,7 @@ export class MemoryService { memoryId: string, content: string, summary: string, + allowEmptyText = false, ): void { if (!bookId) { throw new LocalizedError('BOOK_ID_REQUIRED', 'aiEntityFeedback.bookRequired'); @@ -223,6 +224,8 @@ export class MemoryService { if (!memoryId) { throw new LocalizedError('MEMORY_ID_REQUIRED', 'aiEntityFeedback.memoryIdRequired'); } + // 同步 / 快照导入需原样接受远端记录(与 upsertMemoryForSync 一致),不校验正文与摘要 + if (allowEmptyText) return; if (!content) { throw new LocalizedError('MEMORY_CONTENT_REQUIRED', 'aiEntityFeedback.memoryContentRequired'); } @@ -558,8 +561,9 @@ export class MemoryService { content: string, summary: string, timestamps?: { createdAt?: number; lastAccessedAt?: number; updatedAt?: number }, + options?: { allowEmptyText?: boolean }, ): Promise { - this.assertMemoryFields(bookId, memoryId, content, summary); + this.assertMemoryFields(bookId, memoryId, content, summary, options?.allowEmptyText); try { const { existing, memory, evictedId } = await withMemoryWrite(async (store) => { diff --git a/src/services/sync-data-service.ts b/src/services/sync-data-service.ts index 7123d99c..fca51bc9 100644 --- a/src/services/sync-data-service.ts +++ b/src/services/sync-data-service.ts @@ -1382,6 +1382,7 @@ export class SyncDataService { await aiModelService.deleteModel(staleId); } catch (e) { console.warn('[SyncDataService] 删除旧模型失败:', staleId, e); + throw e; } } } @@ -1527,6 +1528,7 @@ export class SyncDataService { await booksStore.deleteBook(staleId); } catch (e) { console.warn('[SyncDataService] 删除旧书籍失败:', staleId, e); + throw e; } } } @@ -2149,11 +2151,19 @@ export class SyncDataService { /** 导入/旧格式合并共用,保留内容时间和设备访问时间。 */ private static async writeImportedMemory(memory: Memory, bookId = memory.bookId): Promise { try { - await MemoryService.createMemoryWithId(bookId, memory.id, memory.content, memory.summary, { - createdAt: memory.createdAt, - lastAccessedAt: memory.lastAccessedAt, - updatedAt: memoryModifiedAt(memory), - }); + // 远端记录可能含空摘要(同步 upsert 允许),导入时不能因字段校验让整轮同步失败 + await MemoryService.createMemoryWithId( + bookId, + memory.id, + memory.content, + memory.summary, + { + createdAt: memory.createdAt, + lastAccessedAt: memory.lastAccessedAt, + updatedAt: memoryModifiedAt(memory), + }, + { allowEmptyText: true }, + ); } catch (error) { // 与 persistMergedMemories 一致:写入失败必须中止本轮应用,避免随后上传丢失该记忆 console.warn(`[SyncDataService] 写入 Memory ${memory.id} 失败:`, error); From d58f39a44c63bafcf81d18d11f8ff1f680ca8074 Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 21:09:28 -0400 Subject: [PATCH 11/16] fix: drop in-flight memory embeddings when the sync gate closes and clarify memory query hint --- public/help/en-US/local-embedding.md | 3 ++- public/help/zh-CN/local-embedding.md | 3 ++- public/help/zh-TW/local-embedding.md | 3 ++- src/__tests__/embedding-queue.test.ts | 34 ++++++++++++++++++++++++++ src/constants/version.ts | 2 +- src/i18n/en-US/embedding-ui.ts | 2 +- src/i18n/zh-CN/embedding-ui.ts | 3 ++- src/i18n/zh-TW/embedding-ui.ts | 3 ++- src/services/embedding-queue.ts | 35 +++++++++++++++++++++------ 9 files changed, 73 insertions(+), 15 deletions(-) diff --git a/public/help/en-US/local-embedding.md b/public/help/en-US/local-embedding.md index 0ad19490..c482a43f 100644 --- a/public/help/en-US/local-embedding.md +++ b/public/help/en-US/local-embedding.md @@ -108,7 +108,7 @@ When stale (version-mismatched) vectors exist, an "Embedding space upgraded" ban - **"Test vector search"**: opens the query dialog (see below). - **"Pause" / "Resume"**: appear in the bottom status block only while the queue is running or paused, to suspend or continue it manually. -**Test vector search dialog**: the intro reads "Describe a scene, character relationship, or keyword to search this book’s chapters and memories." Enter a query, then click "Query chapters" or "Query memories" (Enter runs a chapter query; memory queries match only items with the current model version). +**Test vector search dialog**: the intro reads "Describe a scene, character relationship, or keyword to search this book’s chapters and memories." Enter a query, then click "Query chapters" or "Query memories" (Enter runs a chapter query; memory queries match current-model vectors, and memories without a usable vector can also appear on a strong keyword match). - Before a search, the dialog shows a "Find scenes and memories" prompt. - While a search runs, a loading state appears. @@ -147,6 +147,7 @@ Cloud sync and revision restoration temporarily suspend the queue through a **de - Either `isSyncing` or `isRestoringSyncSnapshot` closes the gate. - Reopening resumes **only** work suspended by the gate, preserving a manual "Pause". +- A batch still running inference when the gate closes is not written: memory and chapter work goes back to the queue and is recomputed from the synced content once the gate opens. - This avoids vector writes racing `overwriteFromSnapshot` or indexing partially restored data. In the UI: the panel has no dedicated sync message. During sync the queue is paused, so the bottom status block shows "Resume", but the button is disabled so vectors are not written mid-sync. When sync finishes, the queue continues automatically without a click; if you clicked "Pause" during sync, the queue stays paused afterwards. diff --git a/public/help/zh-CN/local-embedding.md b/public/help/zh-CN/local-embedding.md index f0d1d8c3..7312c4a7 100644 --- a/public/help/zh-CN/local-embedding.md +++ b/public/help/zh-CN/local-embedding.md @@ -108,7 +108,7 @@ Quasar 检测到物理移动设备时会**锁定**「启用本地嵌入」开关 - **「测试向量查询」**:打开查询对话框(见下文)。 - **「暂停」/「恢复」**:只在队列运行或已暂停时出现在底部状态区,用于手动挂起或继续队列。 -**测试向量查询对话框**:顶部说明「用自然语言描述剧情、人物关系或关键词,检索本书章节与记忆。」输入查询语句后点击「查询章节」或「查询记忆」(回车默认执行章节查询;记忆查询仅匹配当前模型版本的条目)。 +**测试向量查询对话框**:顶部说明「用自然语言描述剧情、人物关系或关键词,检索本书章节与记忆。」输入查询语句后点击「查询章节」或「查询记忆」(回车默认执行章节查询;记忆查询按当前模型版本的向量匹配,尚无可用向量的记忆在关键词强命中时也会返回)。 - 未查询时显示「查找书中的情节和记忆」提示。 - 查询中显示「正在检索章节…」或「正在检索记忆…」加载状态。 @@ -147,6 +147,7 @@ Quasar 检测到物理移动设备时会**锁定**「启用本地嵌入」开关 - `isSyncing` 或 `isRestoringSyncSnapshot` 任一为 true,闸门锁住队列。 - 闸门解锁后**只**恢复"由闸门自身挂起"的任务 — 用户在 UI 上主动点击的「暂停」状态完整保留,不会被同步流程意外解除。 +- 闸门生效时正在推理的批次不会写入:记忆与章节向量都会放回队列,闸门解锁后按同步后的内容重新计算。 - 这避免了同步过程中嵌入写入与 `overwriteFromSnapshot` 的覆盖竞态,也防止恢复一半的数据上面再叠上半新的向量。 UI 表现:面板没有专门的同步提示;同步期间队列处于暂停,底部状态区显示「恢复」按钮,但按钮不可用,避免在同步过程中写入向量。同步结束后队列自动继续,无需手动点击;如果在同步期间点击了「暂停」,同步结束后队列保持暂停。 diff --git a/public/help/zh-TW/local-embedding.md b/public/help/zh-TW/local-embedding.md index 6ac81a3b..f2527cf8 100644 --- a/public/help/zh-TW/local-embedding.md +++ b/public/help/zh-TW/local-embedding.md @@ -108,7 +108,7 @@ Quasar 偵測到實體行動裝置時會**鎖定**「啟用本地嵌入」開關 - **「測試向量查詢」**:開啟查詢對話框(見下文)。 - **「暫停」/「恢復」**:只在佇列執行中或已暫停時出現在底部狀態區,用於手動暫停或繼續佇列。 -**測試向量查詢對話框**:頂部說明「以自然語言描述劇情、人物關係或關鍵詞,檢索本書章節與記憶。」輸入查詢語句後點選「查詢章節」或「查詢記憶」(Enter 預設執行章節查詢;記憶查詢僅比對目前模型版本的項目)。 +**測試向量查詢對話框**:頂部說明「以自然語言描述劇情、人物關係或關鍵詞,檢索本書章節與記憶。」輸入查詢語句後點選「查詢章節」或「查詢記憶」(Enter 預設執行章節查詢;記憶查詢依目前模型版本的向量比對,尚無可用向量的記憶在關鍵詞強命中時也會傳回)。 - 尚未查詢時顯示「尋找書中的情節與記憶」提示。 - 查詢中顯示「正在檢索章節…」或「正在檢索記憶…」載入狀態。 @@ -147,6 +147,7 @@ Quasar 偵測到實體行動裝置時會**鎖定**「啟用本地嵌入」開關 - `isSyncing` 或 `isRestoringSyncSnapshot` 任一為 true,閘門鎖住佇列。 - 閘門解鎖後**只**恢復「由閘門自身掛起」的任務 — 使用者在 UI 上主動點選的「暫停」狀態完整保留,不會被同步流程意外解除。 +- 閘門生效時正在推論的批次不會寫入:記憶與章節向量都會放回佇列,閘門解鎖後依同步後的內容重新計算。 - 這避免了同步過程中嵌入寫入與 `overwriteFromSnapshot` 的覆蓋競態,也防止還原一半的資料上再疊上半新的向量。 UI 表現:面板沒有專門的同步提示;同步期間佇列處於暫停,底部狀態區顯示「恢復」按鈕,但按鈕無法使用,避免在同步過程中寫入向量。同步結束後佇列自動繼續,無需手動點選;如果在同步期間點選了「暫停」,同步結束後佇列保持暫停。 diff --git a/src/__tests__/embedding-queue.test.ts b/src/__tests__/embedding-queue.test.ts index 39adc290..44827c6b 100644 --- a/src/__tests__/embedding-queue.test.ts +++ b/src/__tests__/embedding-queue.test.ts @@ -859,6 +859,40 @@ describe('EmbeddingQueue - applySyncGate', () => { expect(EmbeddingQueue.isPaused()).toBe(true); }); + test('推理中途 gate 生效时不写入记忆向量,批次重新入队并在 gate 释放后重算', async () => { + spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockImplementation(async (id: string) => + makeMemory(id), + ); + const updateSpy = spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue( + undefined, + ); + let releaseInference: (() => void) | undefined; + const inferenceStarted = new Promise((started) => { + spyOn(EmbeddingService, 'embedBatch').mockImplementationOnce(async (texts: string[]) => { + started(); + await new Promise((resolve) => (releaseInference = resolve)); + return texts.map(() => new Float32Array([0.1])); + }); + }); + + EmbeddingQueue.enqueue('m-inflight'); + await inferenceStarted; + + EmbeddingQueue.applySyncGate(true); + releaseInference!(); + await new Promise((r) => setTimeout(r, 20)); + + expect(updateSpy).not.toHaveBeenCalled(); + expect(EmbeddingQueue.getProgress().pending).toBe(1); + + spyOn(EmbeddingService, 'embedBatch').mockImplementation(async (texts: string[]) => + texts.map(() => new Float32Array([0.2])), + ); + EmbeddingQueue.applySyncGate(false); + await waitForIdle(); + expect(updateSpy).toHaveBeenCalledTimes(1); + }); + test('gate 挂起期间手动 resume 不能绕过 gate', () => { EmbeddingQueue.applySyncGate(true); expect(EmbeddingQueue.getProgress().syncGated).toBe(true); diff --git a/src/constants/version.ts b/src/constants/version.ts index 43227e46..a78b95ff 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.18.0.10'; +export const APP_VERSION = '0.18.0.11'; diff --git a/src/i18n/en-US/embedding-ui.ts b/src/i18n/en-US/embedding-ui.ts index bf5a083c..c26c3766 100644 --- a/src/i18n/en-US/embedding-ui.ts +++ b/src/i18n/en-US/embedding-ui.ts @@ -58,7 +58,7 @@ export default { queryLoading: 'Searching {target}…', queryScore: 'Relevance', queryHint: - 'Enter runs a chapter query. Memory queries match only items with the current model version.', + 'Enter runs a chapter query. Memory queries match current-model vectors; memories without a usable vector can also appear on a strong keyword match.', queryChapters: 'Query chapters', queryMemories: 'Query memories', queryResults: '{target} query results', diff --git a/src/i18n/zh-CN/embedding-ui.ts b/src/i18n/zh-CN/embedding-ui.ts index b63c9ac3..ba4bff8e 100644 --- a/src/i18n/zh-CN/embedding-ui.ts +++ b/src/i18n/zh-CN/embedding-ui.ts @@ -56,7 +56,8 @@ export default { queryIdleHint: '输入描述,再选择查询章节或记忆。', queryLoading: '正在检索{target}…', queryScore: '相关度', - queryHint: '回车默认执行章节查询;记忆查询仅匹配当前模型版本的条目。', + queryHint: + '回车默认执行章节查询;记忆查询按当前模型版本的向量匹配,尚无可用向量的记忆在关键词强命中时也会返回。', queryChapters: '查询章节', queryMemories: '查询记忆', queryResults: '{target}查询结果', diff --git a/src/i18n/zh-TW/embedding-ui.ts b/src/i18n/zh-TW/embedding-ui.ts index fecf38c8..064e8f29 100644 --- a/src/i18n/zh-TW/embedding-ui.ts +++ b/src/i18n/zh-TW/embedding-ui.ts @@ -56,7 +56,8 @@ export default { queryIdleHint: '輸入描述,再選擇查詢章節或記憶。', queryLoading: '正在檢索{target}…', queryScore: '相關度', - queryHint: 'Enter 預設執行章節查詢;記憶查詢僅比對目前模型版本的項目。', + queryHint: + 'Enter 預設執行章節查詢;記憶查詢依目前模型版本的向量比對,尚無可用向量的記憶在關鍵詞強命中時也會傳回。', queryChapters: '查詢章節', queryMemories: '查詢記憶', queryResults: '{target}查詢結果', diff --git a/src/services/embedding-queue.ts b/src/services/embedding-queue.ts index 6a6becab..063826e8 100644 --- a/src/services/embedding-queue.ts +++ b/src/services/embedding-queue.ts @@ -510,10 +510,17 @@ export class EmbeddingQueue { const serviceRevision = this.serviceRevision; try { if (head.kind === 'memory') { - await this.processMemoryBatch( + const persisted = await this.processMemoryBatch( batchItems.map((item) => item.id), serviceRevision, ); + if (!persisted) { + // 推理期间同步 / 恢复闸门生效:丢弃旧内容算出的向量,闸门释放后按新内容重算 + this.requeueFront(batchItems); + this.currentTask = null; + this.emitProgress(); + return; + } } else { if (!(await this.processChapter(batchItems[0]!))) return; } @@ -521,10 +528,7 @@ export class EmbeddingQueue { console.warn('[EmbeddingQueue] 批处理失败,继续下一批:', error); this.dispatch('error', { error, batchItems }); if (!EmbeddingService.isReady() || this.serviceRevision !== serviceRevision) { - for (const item of [...batchItems].reverse()) { - if (!this.pending.some((pending) => pending.kind === item.kind && pending.id === item.id)) - this.pending.unshift(item); - } + this.requeueFront(batchItems); this.currentTask = null; this.emitProgress(); return; @@ -597,10 +601,21 @@ export class EmbeddingQueue { } } + /** 把未完成的批次按原顺序放回队首(跳过已在队列中的条目) */ + private static requeueFront(items: QueueItem[]): void { + for (const item of [...items].reverse()) { + if (!this.pending.some((pending) => pending.kind === item.kind && pending.id === item.id)) + this.pending.unshift(item); + } + } + /** - * 处理一批 memory id + * 处理一批 memory id。返回 false 表示推理期间同步闸门生效,本批未写入、需重新入队。 */ - private static async processMemoryBatch(ids: string[], serviceRevision: number): Promise { + private static async processMemoryBatch( + ids: string[], + serviceRevision: number, + ): Promise { const memories: Array<{ id: string; segments: string[] } | null> = await Promise.all( ids.map(async (id) => { try { @@ -620,7 +635,7 @@ export class EmbeddingQueue { ); if (valid.length === 0) { this.completed.memory += ids.length; - return; + return true; } const segmentJobs = valid.flatMap((memory) => @@ -644,6 +659,9 @@ export class EmbeddingQueue { }); } + // 同步 / 快照恢复可能已改写这些记忆;此时写入会把旧内容的向量覆盖到新记录上 + if (this.syncGateActive) return false; + await Promise.all( valid.map(async (entry) => { const embeddings = vectorsByMemory.get(entry.id); @@ -665,6 +683,7 @@ export class EmbeddingQueue { ); this.completed.memory += ids.length; + return true; } /** From 20f83d7945f8ff09b101f17b48b0fad39dac83c3 Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 21:21:01 -0400 Subject: [PATCH 12/16] fix: recheck the sync gate before each memory embedding write and correct catalog upload docs --- public/help/en-US/settings-guide.md | 2 +- public/help/zh-CN/settings-guide.md | 2 +- public/help/zh-TW/settings-guide.md | 2 +- public/releaseNotes/RELEASE_NOTES_v0.18.0.md | 2 +- src/__tests__/embedding-queue.test.ts | 33 +++++++++++ src/constants/version.ts | 2 +- src/services/embedding-queue.ts | 62 +++++++++++--------- 7 files changed, 71 insertions(+), 34 deletions(-) diff --git a/public/help/en-US/settings-guide.md b/public/help/en-US/settings-guide.md index a078fb84..8b80592a 100644 --- a/public/help/en-US/settings-guide.md +++ b/public/help/en-US/settings-guide.md @@ -213,7 +213,7 @@ For remote corruption or device migration, replace remote visible data with loca ### Multilingual protocol and entity deletion {#settings-guide-language-sync} -- Book metadata and content are stored separately. Chapter IDs assign content to up to 16 fixed groups per book; empty groups use no files. Adding a chapter normally uploads its group, book metadata, and manifest. Content-only edits also skip unchanged metadata. Large groups can still be chunked, but inserting or reordering chapters does not reshuffle other groups. +- Book metadata and content are stored separately. Chapter IDs assign content to up to 16 fixed groups per book; empty groups use no files. Adding or editing a chapter normally uploads its group, book metadata, and manifest, without re-uploading other groups. Editing content updates the book's modification time, so metadata is usually uploaded too. Large groups can still be chunked, but inserting or reordering chapters does not reshuffle other groups. - The first v6 upgrade migrates all existing books, so this upload can still be large. Interrupted migrations retain the old book content for retry. Historical restore assembles every required group and stops if any group is missing or corrupt. - 300 is the GitHub API file-list response limit, not a library capacity limit. Larger libraries can sync through the manifest and files pinned to the same revision, with hash verification and failure handling preserved. A multi-batch legacy upgrade first saves a temporary file inventory so the original books remain readable after interruption. The inventory is removed when the final manifest is published. If neither index is available, the operation stops to protect the data. diff --git a/public/help/zh-CN/settings-guide.md b/public/help/zh-CN/settings-guide.md index e0f95442..695e9f34 100644 --- a/public/help/zh-CN/settings-guide.md +++ b/public/help/zh-CN/settings-guide.md @@ -218,7 +218,7 @@ ### 多语言协议与实体删除 {#settings-guide-language-sync} -- 书籍目录与正文分开存储,正文按章节 ID 固定分为最多 16 个小组,空组不占文件。新增一章通常上传所在小组、目录和清单;仅修改正文时,未变化的目录也会跳过。小组过大时仍会分块,插入或重排章节不会让其他组重新分配。 +- 书籍目录与正文分开存储,正文按章节 ID 固定分为最多 16 个小组,空组不占文件。新增或修改一章通常上传所在小组、目录和清单,其他小组不再重传;编辑正文会更新书籍的修改时间,因此目录通常也会一起上传。小组过大时仍会分块,插入或重排章节不会让其他组重新分配。 - 首次升级到 v6 会完整迁移旧书籍,此次上传量仍可能较大。迁移中断后保留旧版正文,下一次同步可重试。历史恢复会重组全部正文组,任一所需组缺失或损坏都会中止恢复。 - 300 是 GitHub API 文件列表的返回上限,不是书库容量上限。超过该数量仍可同步;客户端下载同一修订的清单及对应文件,并保留哈希校验和失败中止。旧布局分批升级会先保存临时文件目录,中断后可继续读取原有书籍;正式清单发布后删除临时目录。清单和临时目录都不可用时会停止操作以保护数据。 diff --git a/public/help/zh-TW/settings-guide.md b/public/help/zh-TW/settings-guide.md index 75b3a4d6..f58e6c00 100644 --- a/public/help/zh-TW/settings-guide.md +++ b/public/help/zh-TW/settings-guide.md @@ -218,7 +218,7 @@ ### 多語言協議與實體刪除 {#settings-guide-language-sync} -- 書籍目錄與正文分開儲存,正文按章節 ID 固定分為最多 16 個小組,空組不占檔案。新增一章通常上傳所在小組、目錄與清單;只修改正文時,未變更的目錄也會跳過。小組過大時仍會分塊,插入或重排章節不會讓其他組重新分配。 +- 書籍目錄與正文分開儲存,正文按章節 ID 固定分為最多 16 個小組,空組不占檔案。新增或修改一章通常上傳所在小組、目錄與清單,其他小組不再重傳;編輯正文會更新書籍的修改時間,因此目錄通常也會一併上傳。小組過大時仍會分塊,插入或重排章節不會讓其他組重新分配。 - 首次升級到 v6 會完整遷移舊書籍,此次上傳量仍可能較大。遷移中斷後保留舊版正文,下一次同步可重試。歷史還原會重組全部正文組,任一所需組缺失或損壞都會中止還原。 - 300 是 GitHub API 檔案清單的回傳上限,不是書庫容量上限。超過該數量仍可同步;客戶端下載同一修訂的清單與對應檔案,並保留雜湊校驗及失敗中止。舊布局分批升級會先儲存暫存檔案目錄,中斷後可繼續讀取原有書籍;正式清單發布後刪除暫存目錄。清單與暫存目錄都無法使用時會停止操作以保護資料。 diff --git a/public/releaseNotes/RELEASE_NOTES_v0.18.0.md b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md index a7d5d60f..f3876c95 100644 --- a/public/releaseNotes/RELEASE_NOTES_v0.18.0.md +++ b/public/releaseNotes/RELEASE_NOTES_v0.18.0.md @@ -12,7 +12,7 @@ ## ☁️ 同步协议 v6 (Chapter-level Incremental Sync) -- **按章节小组增量同步**:书籍资料和目录单独保存,不含正文;章节正文按章节 ID 固定分到 16 个小组,插入或重排章节不会让其他章节换组。新增或修改一章时,通常只上传所在小组、目录(有变化时才传)和清单;只改正文时目录也会跳过。下载时只拉取有变化的小组,并读取该书的目录用于组装和校验正文。 +- **按章节小组增量同步**:书籍资料和目录单独保存,不含正文;章节正文按章节 ID 固定分到 16 个小组,插入或重排章节不会让其他章节换组。新增或修改一章时,通常只上传所在小组、目录和清单,其他小组不再重传;编辑正文会更新书籍的修改时间,因此目录通常也会一起上传。下载时只拉取有变化的小组,并读取该书的目录用于组装和校验正文。 - **大书库不再受 300 个文件限制**:GitHub 返回的文件列表最多只有 300 个文件。以前列表被截断就会停止同步,现在会按当前修订读取完整的同步清单,补齐没有列出的文件,并继续校验哈希。只有既被截断、又读不到完整清单时才会停止。 - **书库没有变化时同步更快**:本机记录一个只存在本地的同步检查点。书库、设置和删除记录都没有变化时,不再读取全部章节正文和记忆来重算清单;远端的变化仍会照常检查。冷启动时章节正文改为批量读取,远端文件最多 4 个并发下载。 - **记忆访问时间只在本地**:记忆新增内容修改时间。「最后访问时间」只在本机用于排序和时间衰减,不再上传。因此打开或被 AI 读取的记忆不会再触发同步,也不会覆盖其他设备上较新的修改,或让已删除的记忆恢复。 diff --git a/src/__tests__/embedding-queue.test.ts b/src/__tests__/embedding-queue.test.ts index 44827c6b..773535c6 100644 --- a/src/__tests__/embedding-queue.test.ts +++ b/src/__tests__/embedding-queue.test.ts @@ -893,6 +893,39 @@ describe('EmbeddingQueue - applySyncGate', () => { expect(updateSpy).toHaveBeenCalledTimes(1); }); + test('持久化阶段 gate 生效时停止写入剩余记忆,并把未写入的条目重新入队', async () => { + spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockImplementation(async (id: string) => + makeMemory(id), + ); + spyOn(EmbeddingService, 'embedBatch').mockImplementation(async (texts: string[]) => + texts.map(() => new Float32Array([0.1])), + ); + const written: string[] = []; + const updateSpy = spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockImplementation( + async (id: string) => { + written.push(id); + // 第一条写入期间同步开始 + if (written.length === 1) EmbeddingQueue.applySyncGate(true); + }, + ); + + EmbeddingQueue.applySyncGate(true); + EmbeddingQueue.enqueue('w1', 'book-1'); + EmbeddingQueue.enqueue('w2', 'book-1'); + EmbeddingQueue.applySyncGate(false); + await new Promise((r) => setTimeout(r, 30)); + + expect(written).toEqual(['w1']); + expect(EmbeddingQueue.getProgress().pending).toBe(1); + + updateSpy.mockImplementation(async (id: string) => { + written.push(id); + }); + EmbeddingQueue.applySyncGate(false); + await waitForIdle(); + expect(written).toEqual(['w1', 'w2']); + }); + test('gate 挂起期间手动 resume 不能绕过 gate', () => { EmbeddingQueue.applySyncGate(true); expect(EmbeddingQueue.getProgress().syncGated).toBe(true); diff --git a/src/constants/version.ts b/src/constants/version.ts index a78b95ff..50e590c3 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.18.0.11'; +export const APP_VERSION = '0.18.0.12'; diff --git a/src/services/embedding-queue.ts b/src/services/embedding-queue.ts index 063826e8..ff221b17 100644 --- a/src/services/embedding-queue.ts +++ b/src/services/embedding-queue.ts @@ -510,13 +510,14 @@ export class EmbeddingQueue { const serviceRevision = this.serviceRevision; try { if (head.kind === 'memory') { - const persisted = await this.processMemoryBatch( + const unpersistedIds = await this.processMemoryBatch( batchItems.map((item) => item.id), serviceRevision, ); - if (!persisted) { - // 推理期间同步 / 恢复闸门生效:丢弃旧内容算出的向量,闸门释放后按新内容重算 - this.requeueFront(batchItems); + if (unpersistedIds.length > 0) { + // 推理或写入期间同步 / 恢复闸门生效:丢弃未写入条目的旧向量,闸门释放后按新内容重算 + const unpersisted = new Set(unpersistedIds); + this.requeueFront(batchItems.filter((item) => unpersisted.has(item.id))); this.currentTask = null; this.emitProgress(); return; @@ -610,12 +611,12 @@ export class EmbeddingQueue { } /** - * 处理一批 memory id。返回 false 表示推理期间同步闸门生效,本批未写入、需重新入队。 + * 处理一批 memory id。返回因同步闸门生效而未写入、需重新入队的 id(空数组表示整批完成)。 */ private static async processMemoryBatch( ids: string[], serviceRevision: number, - ): Promise { + ): Promise { const memories: Array<{ id: string; segments: string[] } | null> = await Promise.all( ids.map(async (id) => { try { @@ -635,7 +636,7 @@ export class EmbeddingQueue { ); if (valid.length === 0) { this.completed.memory += ids.length; - return true; + return []; } const segmentJobs = valid.flatMap((memory) => @@ -659,31 +660,34 @@ export class EmbeddingQueue { }); } - // 同步 / 快照恢复可能已改写这些记忆;此时写入会把旧内容的向量覆盖到新记录上 - if (this.syncGateActive) return false; - - await Promise.all( - valid.map(async (entry) => { - const embeddings = vectorsByMemory.get(entry.id); - if (!embeddings?.length) return; - try { - // 写 IDB(leaf)+ 同步进程内缓存 + 派发 'embedding-updated' 事件。 - // 全部走叶子模块(memory-embedding-lookup / memory-cache),避免 EmbeddingQueue - // 反向 import MemoryService 形成循环依赖。 - await updateMemoryEmbeddingInDB(entry.id, embeddings, MEMORY_EMBEDDING_VERSION); - const bookId = await lookupMemoryBookId(entry.id); - if (bookId) { - syncMemoryEmbeddingCaches(bookId, entry.id, embeddings, MEMORY_EMBEDDING_VERSION); - dispatchMemoryChanged({ bookId, memoryId: entry.id, action: 'embedding-updated' }); - } - } catch (error) { - console.warn(`[EmbeddingQueue] 持久化 memory embedding 失败 (${entry.id}):`, error); + // 逐条写入,每次写入前复核闸门:同步 / 快照恢复可能已改写这些记忆, + // 此时写入会把旧内容的向量覆盖到新记录上。已写入的条目在闸门生效前完成,保留即可。 + for (let index = 0; index < valid.length; index++) { + if (this.syncGateActive) { + const remaining = valid.slice(index).map((entry) => entry.id); + this.completed.memory += ids.length - remaining.length; + return remaining; + } + const entry = valid[index]!; + const embeddings = vectorsByMemory.get(entry.id); + if (!embeddings?.length) continue; + try { + // 写 IDB(leaf)+ 同步进程内缓存 + 派发 'embedding-updated' 事件。 + // 全部走叶子模块(memory-embedding-lookup / memory-cache),避免 EmbeddingQueue + // 反向 import MemoryService 形成循环依赖。 + await updateMemoryEmbeddingInDB(entry.id, embeddings, MEMORY_EMBEDDING_VERSION); + const bookId = await lookupMemoryBookId(entry.id); + if (bookId) { + syncMemoryEmbeddingCaches(bookId, entry.id, embeddings, MEMORY_EMBEDDING_VERSION); + dispatchMemoryChanged({ bookId, memoryId: entry.id, action: 'embedding-updated' }); } - }), - ); + } catch (error) { + console.warn(`[EmbeddingQueue] 持久化 memory embedding 失败 (${entry.id}):`, error); + } + } this.completed.memory += ids.length; - return true; + return []; } /** From c81f21cc3903a416c2532d50863c946a39e8191c Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 21:53:01 -0400 Subject: [PATCH 13/16] fix: verify memory text inside the embedding write transaction and keep sync import type checks --- README.en-US.md | 2 +- README.md | 2 +- README.zh-TW.md | 2 +- src/__tests__/embedding-queue.test.ts | 63 +++++++++++++++-------- src/__tests__/memory-sync-access.test.ts | 65 ++++++++++++++++++++++++ src/constants/version.ts | 2 +- src/services/embedding-queue.ts | 40 ++++++++++----- src/services/memory-service.ts | 9 +++- src/utils/memory-embedding-lookup.ts | 15 ++++-- 9 files changed, 159 insertions(+), 41 deletions(-) diff --git a/README.en-US.md b/README.en-US.md index 27954b45..ee7ba48e 100644 --- a/README.en-US.md +++ b/README.en-US.md @@ -105,7 +105,7 @@ Tsukuyomi answers questions with the current book as context and can act through - **Gist cloud sync**: optionally sync your data to your own GitHub Gist, with revision history grouped by book, restore from usable snapshots, and cleanup of leftover remote files the manifest no longer references. - **Incremental manifest sync**: `manifest.json` with SHA-256 hashes picks out changed entries, and conditional requests cut down downloads. ETags are re-checked before upload; concurrent changes trigger a re-merge and retry. -- **Chapter-group sync**: book metadata and chapter content are stored separately, and chapters are split into 16 fixed groups by ID, so editing one chapter uploads only its group. When the file list exceeds 300 files, missing files are filled in from the sync manifest, so large libraries sync completely. +- **Chapter-group sync**: book metadata and chapter content are stored separately, and chapters are split into 16 fixed groups by ID, so editing one chapter uploads its group, the book metadata, and the manifest without re-uploading other groups. When the file list exceeds 300 files, missing files are filled in from the sync manifest, so large libraries sync completely. - **Consistent deletes**: the manifest carries deletions as tombstones, so an entry deleted on device A is not pushed back by device B. - **In-book deletion records**: terms, characters, and aliases have stable identities, and their deletion records stay with the book; deleted translation versions are recorded too, so devices returning from offline cannot resurrect them. - **Paragraph merging**: with a synced structural baseline, source edits and deletions made on one side are kept; translations are merged only for paragraphs whose source matches, and you are asked to review conflicts when both sides changed the structure. diff --git a/README.md b/README.md index 1fdff9e6..da9102b2 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Tsukuyomi 采用 Bring Your Own Key 模式,内置两种提供商: - **Gist 云同步**: 可选择将数据同步到自己的 GitHub Gist,支持查看按书籍归组的修订历史、恢复可用快照,并清理远端不再引用的遗留文件。 - **Manifest 增量同步**: 基于 `manifest.json` 与 SHA-256 哈希选择变化条目,使用条件请求减少下载;上传前复核 ETag,检测并发变化后重新合并重试。 -- **按章节小组同步**: 书籍目录与章节正文分开存放,正文按章节 ID 固定分成 16 组,修改一章只需上传所在小组;文件列表超过 300 个时按同步清单补齐,大书库也能完整同步。 +- **按章节小组同步**: 书籍目录与章节正文分开存放,正文按章节 ID 固定分成 16 组,修改一章只上传所在小组、目录和清单,其他小组不再重传;文件列表超过 300 个时按同步清单补齐,大书库也能完整同步。 - **跨端删除一致**: Manifest 使用墓碑(tombstones)传递删除语义,A 设备删除的条目不会被 B 设备重新推回。 - **书内实体删除记录**: 术语、角色、别名拥有稳定身份,删除记录随书长期保留;删除的译文版本同样留有记录,离线设备回流时不会复活。 - **段落合并**: 有同步结构基准时,保留单端的原文修订与删除;只有原文一致的段落才合并译文,两端都改过结构时提示检查冲突。 diff --git a/README.zh-TW.md b/README.zh-TW.md index d3662936..bc38a599 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -105,7 +105,7 @@ Tsukuyomi 採用 Bring Your Own Key 模式,內建兩種提供商: - **Gist 雲端同步**: 可選擇將資料同步到自己的 GitHub Gist,支援查看按書籍歸組的修訂歷史、還原可用快照,並清理遠端不再引用的遺留檔案。 - **Manifest 增量同步**: 基於 `manifest.json` 與 SHA-256 雜湊選擇變化條目,使用條件請求減少下載;上傳前複核 ETag,偵測到並行變化後重新合併重試。 -- **按章節小組同步**: 書籍目錄與章節正文分開存放,正文按章節 ID 固定分成 16 組,修改一章只需上傳所在小組;檔案清單超過 300 個時依同步清單補齊,大型書庫也能完整同步。 +- **按章節小組同步**: 書籍目錄與章節正文分開存放,正文按章節 ID 固定分成 16 組,修改一章只上傳所在小組、目錄與清單,其他小組不再重傳;檔案清單超過 300 個時依同步清單補齊,大型書庫也能完整同步。 - **跨端刪除一致**: Manifest 使用墓碑(tombstones)傳遞刪除語意,A 裝置刪除的條目不會被 B 裝置重新推回。 - **書內實體刪除記錄**: 術語、角色、別名擁有穩定身分,刪除記錄隨書長期保留;刪除的譯文版本同樣留有記錄,離線裝置回流時不會復活。 - **段落合併**: 有同步結構基準時,保留單端的原文修訂與刪除;只有原文一致的段落才合併譯文,兩端都改過結構時提示檢查衝突。 diff --git a/src/__tests__/embedding-queue.test.ts b/src/__tests__/embedding-queue.test.ts index 773535c6..ce9ada02 100644 --- a/src/__tests__/embedding-queue.test.ts +++ b/src/__tests__/embedding-queue.test.ts @@ -90,7 +90,7 @@ describe('EmbeddingQueue - 入队与批处理', () => { makeMemory(id), ); const update = spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue( - undefined, + true, ); const chapter = spyOn(ChapterEmbeddingService, 'embedChapter').mockImplementation( async () => { @@ -131,7 +131,7 @@ describe('EmbeddingQueue - 入队与批处理', () => { makeMemory(id), ); const update = spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue( - undefined, + true, ); // reload 会派发生命周期事件;isReady 模拟已恢复,不能仅凭 catch 时的状态判断。 const chapter = spyOn(ChapterEmbeddingService, 'embedChapter') @@ -160,7 +160,7 @@ describe('EmbeddingQueue - 入队与批处理', () => { const memoryA = makeMemory('a'); spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockResolvedValue(memoryA); const updateSpy = spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue( - undefined, + true, ); spyOn(EmbeddingService, 'embedBatch').mockResolvedValue([new Float32Array([0.1, 0.2])]); @@ -183,7 +183,7 @@ describe('EmbeddingQueue - 入队与批处理', () => { content: '这里是正文内容', }); spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockResolvedValue(memory); - spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(undefined); + spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(true); const embedSpy = spyOn(EmbeddingService, 'embedBatch').mockResolvedValue([ new Float32Array([1, 0]), new Float32Array([0, 1]), @@ -205,7 +205,7 @@ describe('EmbeddingQueue - 入队与批处理', () => { makeMemory('long-memory', { summary: '长记忆摘要', content }), ); const updateSpy = spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue( - undefined, + true, ); const embedBatchSpy = spyOn(EmbeddingService, 'embedBatch').mockImplementation( async (texts: string[]) => texts.map((_, index) => new Float32Array([index + 1, index + 2])), @@ -231,7 +231,7 @@ describe('EmbeddingQueue - 入队与批处理', () => { test('重复 enqueue 同一 id 不会重复处理', async () => { spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockResolvedValue(makeMemory('a')); const updateSpy = spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue( - undefined, + true, ); spyOn(EmbeddingService, 'embedBatch').mockResolvedValue([new Float32Array([0.5])]); @@ -249,7 +249,7 @@ describe('EmbeddingQueue - 入队与批处理', () => { spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockImplementation(async (id: string) => makeMemory(id), ); - spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(undefined); + spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(true); const embedBatchSpy = spyOn(EmbeddingService, 'embedBatch').mockImplementation( async (texts: string[]) => texts.map(() => new Float32Array([0.1])), @@ -269,7 +269,7 @@ describe('EmbeddingQueue - 入队与批处理', () => { spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockImplementation(async (id: string) => makeMemory(id), ); - spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(undefined); + spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(true); // 第一批 memory 的 embedBatch 在返回前把总开关关闭。两个 chapter 在它之后入队, // 应该被循环里新增的"每轮重读开关"守卫挡下,不会进入处理。 @@ -306,7 +306,7 @@ describe('EmbeddingQueue - 入队与批处理', () => { spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockImplementation(async (id: string) => makeMemory(id), ); - spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(undefined); + spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(true); const embedBatchSpy = spyOn(EmbeddingService, 'embedBatch').mockImplementation( async (texts: string[]) => texts.map(() => new Float32Array([0.1])), ); @@ -360,7 +360,7 @@ describe('EmbeddingQueue - 入队与批处理', () => { spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockImplementation(async (id: string) => makeMemory(id), ); - spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(undefined); + spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(true); spyOn(EmbeddingService, 'embedBatch').mockImplementation(async (texts: string[]) => { markEmbedStarted(); await block; @@ -387,7 +387,7 @@ describe('EmbeddingQueue - 入队与批处理', () => { spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockImplementation(async (id: string) => makeMemory(id), ); - spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(undefined); + spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(true); const embedBatchSpy = spyOn(EmbeddingService, 'embedBatch').mockImplementation( async (texts: string[]) => texts.map(() => new Float32Array([0.1])), ); @@ -411,7 +411,7 @@ describe('EmbeddingQueue - 入队与批处理', () => { spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockImplementation(async (id: string) => makeMemory(id), ); - spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(undefined); + spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(true); let call = 0; const embedBatchSpy = spyOn(EmbeddingService, 'embedBatch').mockImplementation( @@ -442,7 +442,7 @@ describe('EmbeddingQueue - 入队与批处理', () => { spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockImplementation(async (id: string) => makeMemory(id), ); - spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(undefined); + spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(true); spyOn(EmbeddingService, 'embedBatch').mockResolvedValue([new Float32Array([0.1])]); const snapshots: Array> = []; @@ -479,7 +479,7 @@ describe('EmbeddingQueue - 入队与批处理', () => { spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockImplementation( async (id: string) => memories.find((m) => m.id === id) ?? null, ); - spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(undefined); + spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(true); const embedBatchSpy = spyOn(EmbeddingService, 'embedBatch').mockImplementation( async (texts: string[]) => texts.map(() => new Float32Array([0.5])), ); @@ -627,7 +627,7 @@ describe('EmbeddingQueue - chapter kind', () => { spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockImplementation(async (id: string) => makeMemory(id), ); - spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(undefined); + spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(true); const embedBatchSpy = spyOn(EmbeddingService, 'embedBatch').mockResolvedValue([ new Float32Array([0.1]), ]); @@ -659,7 +659,7 @@ describe('EmbeddingQueue - chapter kind', () => { spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockImplementation(async (id: string) => makeMemory(id), ); - spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(undefined); + spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(true); spyOn(EmbeddingService, 'embedBatch').mockImplementation(async (texts: string[]) => { await block; return texts.map(() => new Float32Array([0.1])); @@ -726,7 +726,7 @@ describe('EmbeddingQueue - chapter kind', () => { const bookId = id === 'm1' || id === 'm2' ? 'book-1' : 'book-2'; return makeMemory(id, { bookId }); }); - spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(undefined); + spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(true); const embedBatchSpy = spyOn(EmbeddingService, 'embedBatch').mockImplementation( async (texts: string[]) => texts.map(() => new Float32Array([0.1])), ); @@ -751,7 +751,7 @@ describe('EmbeddingQueue - chapter kind', () => { spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockResolvedValue( makeMemory('m1', { bookId: 'book-X' }), ); - spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(undefined); + spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(true); let releaseBatch!: () => void; const block = new Promise((r) => { @@ -828,7 +828,7 @@ describe('EmbeddingQueue - applySyncGate', () => { spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockImplementation(async (id: string) => makeMemory(id), ); - spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(undefined); + spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue(true); const embedBatchSpy = spyOn(EmbeddingService, 'embedBatch').mockImplementation( async (texts: string[]) => texts.map(() => new Float32Array([0.1])), ); @@ -864,7 +864,7 @@ describe('EmbeddingQueue - applySyncGate', () => { makeMemory(id), ); const updateSpy = spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB').mockResolvedValue( - undefined, + true, ); let releaseInference: (() => void) | undefined; const inferenceStarted = new Promise((started) => { @@ -906,6 +906,7 @@ describe('EmbeddingQueue - applySyncGate', () => { written.push(id); // 第一条写入期间同步开始 if (written.length === 1) EmbeddingQueue.applySyncGate(true); + return true; }, ); @@ -920,12 +921,34 @@ describe('EmbeddingQueue - applySyncGate', () => { updateSpy.mockImplementation(async (id: string) => { written.push(id); + return true; }); EmbeddingQueue.applySyncGate(false); await waitForIdle(); expect(written).toEqual(['w1', 'w2']); }); + test('写入时传入计算向量所用的正文与摘要,内容已变化未写入则重新入队', async () => { + spyOn(memoryEmbeddingLookup, 'getMemoryByIdFromDB').mockImplementation(async (id: string) => + makeMemory(id), + ); + spyOn(EmbeddingService, 'embedBatch').mockImplementation(async (texts: string[]) => + texts.map(() => new Float32Array([0.1])), + ); + const updateSpy = spyOn(memoryEmbeddingLookup, 'updateMemoryEmbeddingInDB') + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + + EmbeddingQueue.enqueue('m-race', 'book-1'); + await waitForIdle(); + + expect(updateSpy).toHaveBeenCalledTimes(2); + expect(updateSpy).toHaveBeenCalledWith('m-race', expect.anything(), expect.any(String), { + content: 'content-m-race', + summary: 'summary-m-race', + }); + }); + test('gate 挂起期间手动 resume 不能绕过 gate', () => { EmbeddingQueue.applySyncGate(true); expect(EmbeddingQueue.getProgress().syncGated).toBe(true); diff --git a/src/__tests__/memory-sync-access.test.ts b/src/__tests__/memory-sync-access.test.ts index 9f150321..d4a9be20 100644 --- a/src/__tests__/memory-sync-access.test.ts +++ b/src/__tests__/memory-sync-access.test.ts @@ -5,6 +5,7 @@ import { EmbeddingQueue } from '../services/embedding-queue'; import { normalizeMemoriesForSync } from '../utils/sync-strip'; import { hashJson } from '../utils/content-hash'; import { SyncDataService } from '../services/sync-data-service'; +import { updateMemoryEmbeddingInDB } from '../utils/memory-embedding-lookup'; async function syncedMemories() { return normalizeMemoriesForSync({ book: await MemoryService.getAllMemories('book') }).book; @@ -36,6 +37,70 @@ describe('同步导入接受空摘要记忆', () => { }); }); +describe('同步导入仍校验正文与摘要类型', () => { + beforeEach(() => { + spyOn(EmbeddingQueue, 'enqueue').mockImplementation(() => undefined); + }); + afterEach(() => mock.restore()); + + it('同步导入模式拒绝非字符串正文或摘要', async () => { + await (expect( + MemoryService.createMemoryWithId( + 'book-corrupt', + 'no-content', + undefined as unknown as string, + '摘要', + undefined, + { allowEmptyText: true }, + ), + ).rejects.toThrow() as unknown as Promise); + await (expect( + MemoryService.createMemoryWithId( + 'book-corrupt', + 'object-summary', + '内容', + { text: 'x' } as unknown as string, + undefined, + { allowEmptyText: true }, + ), + ).rejects.toThrow() as unknown as Promise); + expect(await MemoryService.getAllMemories('book-corrupt')).toEqual([]); + }); +}); + +describe('向量写入校验记忆内容未变', () => { + beforeEach(() => { + spyOn(EmbeddingQueue, 'enqueue').mockImplementation(() => undefined); + }); + afterEach(() => mock.restore()); + + it('写入事务内发现正文已变化时不写入旧向量', async () => { + await MemoryService.createMemoryWithId('book-vec', 'changed', '同步后的正文', '摘要'); + + const written = await updateMemoryEmbeddingInDB('changed', [[1, 0]], 'model-v', { + content: '同步前的正文', + summary: '摘要', + }); + + expect(written).toBe(false); + const stored = (await MemoryService.getAllMemories('book-vec'))[0]; + expect(stored?.embeddings).toBeUndefined(); + }); + + it('内容一致时写入向量', async () => { + await MemoryService.createMemoryWithId('book-vec2', 'same', '正文', '摘要'); + + const written = await updateMemoryEmbeddingInDB('same', [[1, 0]], 'model-v', { + content: '正文', + summary: '摘要', + }); + + expect(written).toBe(true); + const stored = (await MemoryService.getAllMemories('book-vec2'))[0]; + expect(stored?.embeddingModel).toBe('model-v'); + }); +}); + describe('Memory 访问与同步内容分离', () => { beforeEach(() => { spyOn(EmbeddingQueue, 'enqueue').mockImplementation(() => undefined); diff --git a/src/constants/version.ts b/src/constants/version.ts index 50e590c3..ebbd010f 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.18.0.12'; +export const APP_VERSION = '0.18.0.13'; diff --git a/src/services/embedding-queue.ts b/src/services/embedding-queue.ts index ff221b17..e083aaa0 100644 --- a/src/services/embedding-queue.ts +++ b/src/services/embedding-queue.ts @@ -112,6 +112,13 @@ function buildMemorySegments(memory: Memory): string[] { return [summary, ...contentSegments.filter((segment) => segment !== summary)]; } +/** 一条待嵌入记忆:分段文本 + 计算向量时的正文 / 摘要(写入时用于比对内容是否已变) */ +interface MemoryBatchEntry { + id: string; + segments: string[]; + text: { content: string; summary: string }; +} + export class EmbeddingQueue { private static pending: QueueItem[] = []; private static processing = false; @@ -617,23 +624,21 @@ export class EmbeddingQueue { ids: string[], serviceRevision: number, ): Promise { - const memories: Array<{ id: string; segments: string[] } | null> = await Promise.all( + const memories: Array = await Promise.all( ids.map(async (id) => { try { const mem = await getMemoryByIdFromDB(id); if (!mem) return null; const segments = buildMemorySegments(mem); if (segments.length === 0) return null; - return { id, segments }; + return { id, segments, text: { content: mem.content, summary: mem.summary } }; } catch { return null; } }), ); - const valid = memories.filter( - (memory): memory is { id: string; segments: string[] } => memory !== null, - ); + const valid = memories.filter((memory): memory is MemoryBatchEntry => memory !== null); if (valid.length === 0) { this.completed.memory += ids.length; return []; @@ -660,13 +665,13 @@ export class EmbeddingQueue { }); } - // 逐条写入,每次写入前复核闸门:同步 / 快照恢复可能已改写这些记忆, - // 此时写入会把旧内容的向量覆盖到新记录上。已写入的条目在闸门生效前完成,保留即可。 + // 逐条写入:闸门生效后不再写入;每条写入还在 IDB 事务内比对正文 / 摘要, + // 推理期间被同步、快照恢复或用户改写的记忆不会落下旧内容的向量,改为重新入队。 + const unpersisted: string[] = []; for (let index = 0; index < valid.length; index++) { if (this.syncGateActive) { - const remaining = valid.slice(index).map((entry) => entry.id); - this.completed.memory += ids.length - remaining.length; - return remaining; + unpersisted.push(...valid.slice(index).map((entry) => entry.id)); + break; } const entry = valid[index]!; const embeddings = vectorsByMemory.get(entry.id); @@ -675,7 +680,16 @@ export class EmbeddingQueue { // 写 IDB(leaf)+ 同步进程内缓存 + 派发 'embedding-updated' 事件。 // 全部走叶子模块(memory-embedding-lookup / memory-cache),避免 EmbeddingQueue // 反向 import MemoryService 形成循环依赖。 - await updateMemoryEmbeddingInDB(entry.id, embeddings, MEMORY_EMBEDDING_VERSION); + const written = await updateMemoryEmbeddingInDB( + entry.id, + embeddings, + MEMORY_EMBEDDING_VERSION, + entry.text, + ); + if (!written) { + unpersisted.push(entry.id); + continue; + } const bookId = await lookupMemoryBookId(entry.id); if (bookId) { syncMemoryEmbeddingCaches(bookId, entry.id, embeddings, MEMORY_EMBEDDING_VERSION); @@ -686,8 +700,8 @@ export class EmbeddingQueue { } } - this.completed.memory += ids.length; - return []; + this.completed.memory += ids.length - unpersisted.length; + return unpersisted; } /** diff --git a/src/services/memory-service.ts b/src/services/memory-service.ts index 5a7d6505..ecd097eb 100644 --- a/src/services/memory-service.ts +++ b/src/services/memory-service.ts @@ -224,7 +224,14 @@ export class MemoryService { if (!memoryId) { throw new LocalizedError('MEMORY_ID_REQUIRED', 'aiEntityFeedback.memoryIdRequired'); } - // 同步 / 快照导入需原样接受远端记录(与 upsertMemoryForSync 一致),不校验正文与摘要 + // 同步 / 快照导入允许空正文 / 摘要(与 upsertMemoryForSync 一致),但仍须是字符串, + // 避免损坏的远端数据把 undefined / 对象写进字符串字段 + if (typeof content !== 'string') { + throw new LocalizedError('MEMORY_CONTENT_REQUIRED', 'aiEntityFeedback.memoryContentRequired'); + } + if (typeof summary !== 'string') { + throw new LocalizedError('MEMORY_SUMMARY_REQUIRED', 'aiEntityFeedback.memorySummaryRequired'); + } if (allowEmptyText) return; if (!content) { throw new LocalizedError('MEMORY_CONTENT_REQUIRED', 'aiEntityFeedback.memoryContentRequired'); diff --git a/src/utils/memory-embedding-lookup.ts b/src/utils/memory-embedding-lookup.ts index 59ced089..1f431b79 100644 --- a/src/utils/memory-embedding-lookup.ts +++ b/src/utils/memory-embedding-lookup.ts @@ -139,20 +139,29 @@ export async function updateMemoryEmbeddingInDB( memoryId: string, embeddings: number[][], embeddingModel: string, -): Promise { + expectedText?: { content: string; summary: string }, +): Promise { if (!memoryId) throw new Error('Memory ID 不能为空'); if (!embeddings.length || embeddings.some((item) => item.length === 0)) { throw new Error('embeddings 不能为空'); } if (!embeddingModel) throw new Error('embeddingModel 不能为空'); - await withMemoryWrite(async (store) => { + return withMemoryWrite(async (store) => { const existing = await store.get(memoryId); - if (!existing) return; + if (!existing) return false; + // 与读取→写入在同一事务内比对:推理期间正文 / 摘要被同步或用户改写时,旧向量不能落盘 + if ( + expectedText && + (existing.content !== expectedText.content || existing.summary !== expectedText.summary) + ) { + return false; + } const { embedding: _legacyEmbedding, ...cleanExisting } = existing as MemoryStorage & { embedding?: number[]; }; await store.put({ ...cleanExisting, embeddings, embeddingModel }); + return true; }); } From d0fa44ae8cbc4cd13f882ecdab4846e113689660 Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 22:04:38 -0400 Subject: [PATCH 14/16] fix: keep stale models in the store until deleted and validate v6 memory text types --- src/__tests__/memory-sync-access.test.ts | 33 ++++++++++++++++++++++++ src/__tests__/sync-data-service.test.ts | 19 ++++++++++++++ src/constants/version.ts | 2 +- src/services/memory-service.ts | 7 +++++ src/services/sync-data-service.ts | 11 ++++---- 5 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/__tests__/memory-sync-access.test.ts b/src/__tests__/memory-sync-access.test.ts index d4a9be20..f18d2b18 100644 --- a/src/__tests__/memory-sync-access.test.ts +++ b/src/__tests__/memory-sync-access.test.ts @@ -68,6 +68,39 @@ describe('同步导入仍校验正文与摘要类型', () => { }); }); +describe('v6 记忆同步校验文本类型', () => { + beforeEach(() => { + spyOn(EmbeddingQueue, 'enqueue').mockImplementation(() => undefined); + }); + afterEach(() => mock.restore()); + + it('同步 upsert 拒绝非字符串正文,不写入损坏记录', async () => { + await (expect( + MemoryService.upsertMemoryForSync({ + id: 'bad-v6', + bookId: 'book-v6', + content: undefined as unknown as string, + summary: '摘要', + createdAt: 1, + lastAccessedAt: 1, + }), + ).rejects.toThrow() as unknown as Promise); + expect(await MemoryService.getAllMemories('book-v6')).toEqual([]); + }); + + it('同步 upsert 接受空字符串摘要', async () => { + await MemoryService.upsertMemoryForSync({ + id: 'empty-v6', + bookId: 'book-v6b', + content: '正文', + summary: '', + createdAt: 1, + lastAccessedAt: 1, + }); + expect((await MemoryService.getAllMemories('book-v6b'))[0]?.summary).toBe(''); + }); +}); + describe('向量写入校验记忆内容未变', () => { beforeEach(() => { spyOn(EmbeddingQueue, 'enqueue').mockImplementation(() => undefined); diff --git a/src/__tests__/sync-data-service.test.ts b/src/__tests__/sync-data-service.test.ts index d77de4d8..82da512b 100644 --- a/src/__tests__/sync-data-service.test.ts +++ b/src/__tests__/sync-data-service.test.ts @@ -1313,6 +1313,25 @@ describe('数据同步服务 (SyncDataService)', () => { ).rejects.toThrow('model delete failed') as unknown as Promise); }); + it('删除过期模型失败后重试同步时仍会再次删除该模型', async () => { + const lastSyncTime = new Date('2024-01-02').getTime(); + mockAIModelsStore.models = [ + { id: 'm1', name: 'Old', lastEdited: new Date('2024-01-01').toISOString() }, + ]; + const remote = { + aiModels: [{ id: 'm2', name: 'Remote', lastEdited: new Date('2024-01-03').toISOString() }], + }; + mockDeleteModel.mockRejectedValueOnce(new Error('transient')); + + await (expect(SyncDataService.applyDownloadedData(remote, lastSyncTime)).rejects.toThrow( + 'transient', + ) as unknown as Promise); + + await SyncDataService.applyDownloadedData(remote, lastSyncTime); + expect(mockDeleteModel).toHaveBeenCalledTimes(2); + expect(mockDeleteModel).toHaveBeenLastCalledWith('m1'); + }); + it('删除远程已删除的本地书籍失败时应向上抛出,阻止后续上传', async () => { const lastSyncTime = new Date('2024-01-02').getTime(); mockBooksStore.books = [ diff --git a/src/constants/version.ts b/src/constants/version.ts index ebbd010f..51d76d8f 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.18.0.13'; +export const APP_VERSION = '0.18.0.14'; diff --git a/src/services/memory-service.ts b/src/services/memory-service.ts index ecd097eb..69871390 100644 --- a/src/services/memory-service.ts +++ b/src/services/memory-service.ts @@ -503,6 +503,13 @@ export class MemoryService { if (!memory.bookId) { throw new LocalizedError('BOOK_ID_REQUIRED', 'aiEntityFeedback.bookRequired'); } + // 远端可能是空字符串,但必须是字符串:损坏数据不能写进字符串字段 + if (typeof memory.content !== 'string') { + throw new LocalizedError('MEMORY_CONTENT_REQUIRED', 'aiEntityFeedback.memoryContentRequired'); + } + if (typeof memory.summary !== 'string') { + throw new LocalizedError('MEMORY_SUMMARY_REQUIRED', 'aiEntityFeedback.memorySummaryRequired'); + } const { storage, embeddingDecision } = await withMemoryWrite(async (store) => { const existing = (await store.get(memory.id)) as MemoryStorage | undefined; diff --git a/src/services/sync-data-service.ts b/src/services/sync-data-service.ts index fca51bc9..c6833d20 100644 --- a/src/services/sync-data-service.ts +++ b/src/services/sync-data-service.ts @@ -1372,11 +1372,7 @@ export class SyncDataService { await aiModelService.saveModel(model); } - aiModelsStore.models = finalModels.map((m) => ({ - ...m, - lastEdited: m.lastEdited ? new Date(m.lastEdited) : new Date(0), - })); - + // 先删除过期模型再提交 store:删除失败时 store 仍保留该模型,重试同步会再次删除它 for (const staleId of staleModelIds) { try { await aiModelService.deleteModel(staleId); @@ -1385,6 +1381,11 @@ export class SyncDataService { throw e; } } + + aiModelsStore.models = finalModels.map((m) => ({ + ...m, + lastEdited: m.lastEdited ? new Date(m.lastEdited) : new Date(0), + })); } /** From f2f0993c4f84838aa41f593bbba2d5fe6bbfbeff Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 22:17:12 -0400 Subject: [PATCH 15/16] fix: only attach fresh memory embeddings to caches whose text still matches --- .../memory-cache-embedding-sync.test.ts | 45 +++++++++++++++++++ src/constants/version.ts | 2 +- src/services/embedding-queue.ts | 8 +++- src/services/memory-cache.ts | 9 +++- 4 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/memory-cache-embedding-sync.test.ts diff --git a/src/__tests__/memory-cache-embedding-sync.test.ts b/src/__tests__/memory-cache-embedding-sync.test.ts new file mode 100644 index 00000000..9e10553a --- /dev/null +++ b/src/__tests__/memory-cache-embedding-sync.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { Memory } from 'src/models/memory'; +import { + bookMemoryCache, + buildMemoryCacheKey, + memoryCache, + syncMemoryEmbeddingCaches, +} from 'src/services/memory-cache'; + +function makeMemory(content: string): Memory { + return { id: 'm1', bookId: 'b1', content, summary: '摘要', createdAt: 1, lastAccessedAt: 1 }; +} + +describe('syncMemoryEmbeddingCaches 内容校验', () => { + afterEach(() => { + memoryCache.clear(); + bookMemoryCache.clear(); + }); + + it('缓存中的记忆内容已变化时不写入按旧内容计算的向量', () => { + memoryCache.set(buildMemoryCacheKey('b1', 'm1'), makeMemory('新正文')); + bookMemoryCache.set('b1', { data: [makeMemory('新正文')], expiresAt: Date.now() + 60_000 }); + + syncMemoryEmbeddingCaches('b1', 'm1', [[1, 0]], 'model-v', { + content: '旧正文', + summary: '摘要', + }); + + expect(memoryCache.get(buildMemoryCacheKey('b1', 'm1'))?.embeddings).toBeUndefined(); + expect(bookMemoryCache.get('b1')?.data[0]?.embeddings).toBeUndefined(); + }); + + it('缓存内容一致时写入向量', () => { + memoryCache.set(buildMemoryCacheKey('b1', 'm1'), makeMemory('正文')); + bookMemoryCache.set('b1', { data: [makeMemory('正文')], expiresAt: Date.now() + 60_000 }); + + syncMemoryEmbeddingCaches('b1', 'm1', [[1, 0]], 'model-v', { + content: '正文', + summary: '摘要', + }); + + expect(memoryCache.get(buildMemoryCacheKey('b1', 'm1'))?.embeddingModel).toBe('model-v'); + expect(bookMemoryCache.get('b1')?.data[0]?.embeddingModel).toBe('model-v'); + }); +}); diff --git a/src/constants/version.ts b/src/constants/version.ts index 51d76d8f..121b5677 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.18.0.14'; +export const APP_VERSION = '0.18.0.15'; diff --git a/src/services/embedding-queue.ts b/src/services/embedding-queue.ts index e083aaa0..6d47d301 100644 --- a/src/services/embedding-queue.ts +++ b/src/services/embedding-queue.ts @@ -692,7 +692,13 @@ export class EmbeddingQueue { } const bookId = await lookupMemoryBookId(entry.id); if (bookId) { - syncMemoryEmbeddingCaches(bookId, entry.id, embeddings, MEMORY_EMBEDDING_VERSION); + syncMemoryEmbeddingCaches( + bookId, + entry.id, + embeddings, + MEMORY_EMBEDDING_VERSION, + entry.text, + ); dispatchMemoryChanged({ bookId, memoryId: entry.id, action: 'embedding-updated' }); } } catch (error) { diff --git a/src/services/memory-cache.ts b/src/services/memory-cache.ts index 5f600fc2..cfdcac06 100644 --- a/src/services/memory-cache.ts +++ b/src/services/memory-cache.ts @@ -76,17 +76,22 @@ export function syncMemoryEmbeddingCaches( memoryId: string, embeddings: number[][], embeddingModel: string, + expectedText?: { content: string; summary: string }, ): void { + // 写库后、刷新缓存前记忆可能已被同步 / 编辑改写:只给内容仍一致的缓存条目挂上向量 + const matches = (memory: Memory) => + !expectedText || + (memory.content === expectedText.content && memory.summary === expectedText.summary); const cacheKey = buildMemoryCacheKey(bookId, memoryId); const cachedSingle = memoryCache.get(cacheKey); - if (cachedSingle) { + if (cachedSingle && matches(cachedSingle)) { memoryCache.set(cacheKey, withMemoryEmbeddings(cachedSingle, embeddings, embeddingModel)); } const cachedBook = bookMemoryCache.get(bookId); if (cachedBook) { const next = cachedBook.data.map((m) => - m.id === memoryId ? withMemoryEmbeddings(m, embeddings, embeddingModel) : m, + m.id === memoryId && matches(m) ? withMemoryEmbeddings(m, embeddings, embeddingModel) : m, ); bookMemoryCache.set(bookId, { data: next, From 5b482401d190effbbbd39c2f0e2cece7bc0fcafb Mon Sep 17 00:00:00 2001 From: rozx Date: Fri, 9 Oct 2026 22:32:53 -0400 Subject: [PATCH 16/16] fix: expose newly saved models to rollback while stale model deletes run --- .../memory-cache-embedding-sync.test.ts | 1 + src/__tests__/sync-data-service.test.ts | 29 ++++++++++++++++++- src/constants/version.ts | 2 +- src/services/sync-data-service.ts | 17 +++++++---- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/__tests__/memory-cache-embedding-sync.test.ts b/src/__tests__/memory-cache-embedding-sync.test.ts index 9e10553a..2da5ea38 100644 --- a/src/__tests__/memory-cache-embedding-sync.test.ts +++ b/src/__tests__/memory-cache-embedding-sync.test.ts @@ -1,3 +1,4 @@ +import './setup'; import { afterEach, describe, expect, it } from 'vitest'; import type { Memory } from 'src/models/memory'; import { diff --git a/src/__tests__/sync-data-service.test.ts b/src/__tests__/sync-data-service.test.ts index 82da512b..3cd74198 100644 --- a/src/__tests__/sync-data-service.test.ts +++ b/src/__tests__/sync-data-service.test.ts @@ -1328,10 +1328,37 @@ describe('数据同步服务 (SyncDataService)', () => { ) as unknown as Promise); await SyncDataService.applyDownloadedData(remote, lastSyncTime); - expect(mockDeleteModel).toHaveBeenCalledTimes(2); + // 第一次删除 m1 失败 → 回滚删除已写入的 m2 → 重试再次删除 m1 + const deletedIds = mockDeleteModel.mock.calls.map((call) => call[0]); + expect(deletedIds.filter((id) => id === 'm1')).toHaveLength(2); expect(mockDeleteModel).toHaveBeenLastCalledWith('m1'); }); + it('删除过期模型失败时回滚能清理删除前已写入的新模型', async () => { + const lastSyncTime = new Date('2024-01-02').getTime(); + mockAIModelsStore.models = [ + { id: 'm1', name: 'Old', lastEdited: new Date('2024-01-01').toISOString() }, + ]; + mockDeleteModel.mockRejectedValueOnce(new Error('transient')); + + await (expect( + SyncDataService.applyDownloadedData( + { + aiModels: [ + { id: 'm2', name: 'Remote', lastEdited: new Date('2024-01-03').toISOString() }, + ], + }, + lastSyncTime, + ), + ).rejects.toThrow('transient') as unknown as Promise); + + expect(mockSaveModel).toHaveBeenCalledWith(expect.objectContaining({ id: 'm2' })); + // 外层回滚从 store 枚举模型:新写入的 m2 必须可见,才能被删除 + expect(mockDeleteModel).toHaveBeenCalledWith('m2'); + const ids = (mockAIModelsStore.models as Array<{ id: string }>).map((m) => m.id); + expect(ids).toEqual(['m1']); + }); + it('删除远程已删除的本地书籍失败时应向上抛出,阻止后续上传', async () => { const lastSyncTime = new Date('2024-01-02').getTime(); mockBooksStore.books = [ diff --git a/src/constants/version.ts b/src/constants/version.ts index 121b5677..96f83ff1 100644 --- a/src/constants/version.ts +++ b/src/constants/version.ts @@ -1,4 +1,4 @@ // 应用版本号 // 此文件由 scripts/bump-version.ts 自动生成,请勿手动修改 // 如需更新版本,请运行: bun scripts/bump-version.ts -export const APP_VERSION = '0.18.0.15'; +export const APP_VERSION = '0.18.0.16'; diff --git a/src/services/sync-data-service.ts b/src/services/sync-data-service.ts index c6833d20..a54d9b0b 100644 --- a/src/services/sync-data-service.ts +++ b/src/services/sync-data-service.ts @@ -1372,7 +1372,17 @@ export class SyncDataService { await aiModelService.saveModel(model); } - // 先删除过期模型再提交 store:删除失败时 store 仍保留该模型,重试同步会再次删除它 + // 删除期间 store 同时登记已写入的新模型与待删除的旧模型:删除失败时外层回滚能看到新模型并清理, + // 重试同步也仍能找到旧模型再次删除;全部删除成功后再收窄为最终列表 + const toStoreModel = (m: (typeof finalModels)[number]) => ({ + ...m, + lastEdited: m.lastEdited ? new Date(m.lastEdited) : new Date(0), + }); + aiModelsStore.models = [ + ...finalModels.map(toStoreModel), + ...aiModelsStore.models.filter((m) => !finalModelIds.has(m.id)), + ]; + for (const staleId of staleModelIds) { try { await aiModelService.deleteModel(staleId); @@ -1382,10 +1392,7 @@ export class SyncDataService { } } - aiModelsStore.models = finalModels.map((m) => ({ - ...m, - lastEdited: m.lastEdited ? new Date(m.lastEdited) : new Date(0), - })); + aiModelsStore.models = finalModels.map(toStoreModel); } /**