Skip to content

feat: round 1 — FTS5 search, attachments, import target choice, embedding reuse, UI scale, localized tool descriptions, Telegram/Discord Rust kernels, local voice transcription - #459

Closed
Zycheng114514 wants to merge 62 commits into
ChatLab:mainfrom
Zycheng114514:round-1

Conversation

@Zycheng114514

@Zycheng114514 Zycheng114514 commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

这一轮的九项改动各自在独立分支上开发并验收,按依赖顺序合并进本分支(git log --first-parent 可看到每项的合并点,每个功能分支内部的 commit 也保留)。下面按功能分节,每节给出动机、做法、数字与测试。

Closes #445 · Closes #378 · Closes #376 · Closes #358 · Closes #170 · Refs #232

1. 消息全文索引(FTS5 trigram)与 AI 搜索相关度排序

问题:关键词搜索是 content LIKE '%kw%' 全表扫描,先 COUNT(*) 再取页;AI 工具拿到的是最新 N 条命中而不是最相关的;deep_search_messages 与 search_messages 底层相同却宣称不同策略。上游 v9 曾删掉一版依赖 jieba 写入期分词的 FTS,本方案分词在 SQLite 内部(tokenize='trigram'),Node 与浏览器共用一份 DDL,不碰语义检索。

做法:message_fts 外部内容表 + 三个触发器,DDL 放在 CHAT_DB_INDEXES 末尾(批量导入先写消息、后建索引,首次填充 rebuild,之后增量走触发器);ensureMessageSearchIndex(db) 负责建表与回填(用 message_fts_docsize 判断是否需要 rebuild),调用点:Node 迁移 v11、流式 / 增量 / push 导入、浏览器打开库与导入;路由规则:所有关键词 ≥ 3 码点且索引存在时走 MATCH,否则 LIKE(trigram 对 2 字中文无效);sort: 'relevance' 走 JOIN + bm25(),只在索引路径生效;search_messages 默认相关度,deep_search_messages 成为真正的强制 LIKE 穷举路径;UI 与 CLI 保持时间序。

数字(M5,合成语料,scripts/bench-message-search.mts,12 条查询改前改后命中集合完全一致):1M 消息英文关键词 63–69 ms → 17 ms;中文 ≥3 字 67 → 61 ms;2 字查询不变。导入:messageWriteMs 1 403 → 1 376 ms,新增一次 rebuild 1 853 ms;DB 体积 +53–78%。旧运行时打开 v11 库不受影响(触发器由 SQLite 执行),不提升数据目录门禁。

2. 附件落库与展示(#170 / #232)

问题:媒体消息只剩 [photo] 类文本标记,路径被丢弃;格式规范里 attachments 只是「协议预留」,连结构都没定义。

做法:规范(cn / en / tw)定义 messages[].attachments[](kind / path / name / mimeType / size / durationMs / width / height),版本仍 0.0.2;新表 message_attachment + meta.source_dir(schema v12,ensureAttachmentSchema 同时服务 Node 迁移与浏览器打开);ChatLab JSON / JSONL(含 #170 场景的路径启发式)、Telegram、Discord、Instagram 四个解析器与 Rust chatlab 内核(napi + wasm,已重新生成 wasm 绑定)填充附件;导入写入只在批次带附件时用 INSERT … RETURNING id(无附件时吞吐无回归);桌面 chatlab-media:// 协议与 CLI Web /_web/sessions/:id/attachments/:attachmentId 共用同一路径解析器(段级解析 + 前缀比对,拒绝越界),图片内联、可播放音频 <audio>、其余为芯片;桌面芯片「在文件夹中显示」走新 IPC;远程 URL 只渲染为链接。

3. 导入目标选择与跨来源去重(#376 / #358)

做法:单文件导入弹出目标选择(自动 / 追加到指定会话 / 新建),默认项来自 analyzeAutoImport 预览;三端传参(桌面 IPC、CLI Web targetMode / targetSessionId、CLI --new-session);forceCreate 产出 { action: 'create', reason: 'user-choice' }。#358:显式追加且文件 owner 与会话 owner 不同时,为传入的 platformMessageId 派生命名空间并按内容指纹去重;fixture 复现 12 行 → 修复后 6 行;不带命名空间时行为逐字不变。

4. 合并会话时复用向量(#378)

做法:runner 在 embedding 前按(模型、策略、chunker 身份、维度、embedding_input_hash)查任意聊天库里现成的向量,文本逐字相同才复制(vec0 读回 Float32 字节再插入);carryOver 在合并导入成功后(后台,不阻塞合并响应)启用目标会话并排队构建;worker client 记录活动构建,避免空闲计时器在构建进行中关闭 worker。复用率:同标题尾部追加 83%;改名后 0%(有意的取舍,标题头是否移出 embedding 文本留给维护者)。

5. 桌面端界面缩放(#445)

设置 › 外观新增「界面缩放」五档,持久化到 desktop.ui_scale;建窗时 webPreferences.zoomFactor,did-finish-load 后重读配置再应用一次(覆盖 Chromium 持久化的站点缩放,也覆盖崩溃重载)。

6. AI 工具描述按 locale 双语

packages/tools 已有的英文元数据表补全到所有含中文定义的工具(只翻译中文原文,避免两份英文漂移),跨会话 agent、CLI Web、桌面、MCP(含 chatlab mcp 子命令)四条路径统一走 getLocalizedToolMetadata;删除桌面端从未命中过 i18n 键的 translateTool;中文系统行为不变。

8. Telegram 与 Discord 的 Rust 解析内核(napi + wasm)

问题:Telegram 全量导出的 result.json 动辄几百 MB,Discord 导出同理;两者只有 TS stream-json 实现(约 25 MB/s),而 Rust 内核只覆盖 chatlab / weflow / shuakami_qq。浏览器端更糟:选聊天前的扫描步骤对整个文件 JSON.parse。

做法(Telegram):telegram.rs 一个内核服务两种导出(全量导出按 chatIndex 选聊天,非目标聊天只被字节级 scanner 跳过、不物化;单聊天导出由适配器注入 single: true),逐条复刻 telegram-utils.ts 的类型判定、内容拼接、from_id 前缀规则、成员推导与附件映射;TS 会走非字符串分支的边角值(数字 from_id、null 尺寸等)内核直接报错让包装器回退 TS,保证输出一致。napi 侧 withNativeTelegram / withNativeTelegramSingle;浏览器侧两种 Telegram 格式先走 wasm(optionsJson 传 chatIndex / single),失败回退现有 TS。parity 测试对全量导出(含 chatIndex ≠ 0 与空聊天)与单聊天导出逐字段 deepEqual。

数字(M5,合成导出,scripts/bench-telegram-parser.mts,两次取最优):100 MB / 35.7 万条:TS 3 982 ms(25.7 MB/s)→ native 1 172 ms(87.4 MB/s),3.4×;500 MB / 178 万条:21 574 ms → 6 345 ms,3.4×。峰值 RSS native 比 TS 高约 100–250 MB(整文件缓冲)。.wasm 161 KB → 207 KB。

行为变化:浏览器 Telegram 导入现在带附件(浏览器 TS 解析器本来不产出附件);浏览器全量导出里无 actor 的 service 消息发送者从 System 变为 系统,与桌面端一致。

做法(Telegram 扫描):选聊天前的列表扫描也走内核(scan: true:只数 chats.list 每项的消息数、不物化),Node scanChats 与浏览器 scanBrowserMultiChatSource 都先试内核、失败回退 TS;100 MB 导出 4 784 → 432 ms(11×),浏览器端从整文件 JSON.parse 变为字节级扫描。

做法(Discord):discord.rs 复刻 tyrrrz-discord-exporter.ts 的类型表、附件扩展名分类与 [Image: …] 等标记、embed / sticker 拼接顺序与纯附件 / 纯 embed / 纯贴纸的类型改写、成员 roles「更长者替换」规则;ISO 8601 时间戳自实现(小数秒取前三位、Math.floor 语义),JS 行为依赖宿主时区的形式回退 TS。guild / channel 按真实 JSON 读(TS 用 10 KB 头部正则,遇嵌套对象会丢失;属已知偏差,parity 用结构正常的导出)。只进 napi,不进 wasm(浏览器没有 Discord 检测器)。100 MB / 13.9 万条:5 670 → 854 ms(6.6×);500 MB:28 466 → 4 382 ms(6.5×)。

测试:Rust 单测 +21;parity 测试 Telegram 6 条 / Discord 2 条(native 未构建时自动跳过);wasm 用例 +4。基准脚本 scripts/bench-telegram-parser.mts(含 --scan)与 scripts/bench-discord-parser.mts 随本 PR 提交。

9. 本地语音转文字(Whisper via transformers.js)

问题:语音消息只有 [语音] 占位或导出自带的转写;上游 #447 定下了 [语音 N秒] 转写文本 的分析层约定,但 ChatLab 自己不会转写。

做法(服务层,阶段 A):packages/node-runtime/src/transcription/——自写 RIFF/WAVE 解码(8/16/24/32-bit PCM、float、多声道、EXTENSIBLE)、线性插值重采样到 16 kHz、AudioDecoder 注册表(后续 SILK / AMR 解码器从这里插入)、createTranscriber(onnx-community/whisper-{tiny,base,small} q8,默认 base,复用语义索引的 transformers env 配置 / 下载源 / 代理 / 受管运行时,零新依赖)、transcribeSessionAttachments(逐条落库、可中断续跑、单条失败不影响其余)。落库规则在 core:message_attachment 补 transcript / transcript_model / transcribed_at 三列(ensureAttachmentSchema 幂等补列,有迁移测试),content 为空或纯占位时改写为 [语音 N秒] 文本(stripVoiceTranscriptionPrefix 能剥掉),已有真实文本则只写附件表。CLI:chatlab transcribe --session <id> [--model] [--language] [--attachment …] [--dry-run]。

数字(M5,base q8,say 合成语音):79 s 中文 3.7 s(RTF 0.05),67 s 英文 2.3 s,峰值 RSS 1.4 GB,模型下载 77 MB;small 260 MB / RTF 0.13;tiny 中文退化。冒烟:「今天天氣不錯,我們出去走走吧」/「Hello ChatLab, this is a transcription test.」

已知限制与处理:transformers.js 4.2.0 无语言自动检测,auto 改为按会话文本判断;Whisper 中文输出为繁体,落库前按会话脚本归一(新增运行时依赖 chinese-conv@4.0.0,MIT、零依赖、字级映射、静态内联进桌面 / CLI 产物约 +56 / +113 KB;设置 transcription.chinese_script = auto | simplified | traditional,auto 按对话已有文字判断,繁体用户的会话保持繁体)。

做法(后端接入,阶段 B1):auto 语言按会话文本解析(core detectSessionLanguage:最近 300 条文本消息的 CJK 占比 ≥ 0.2 → zh,否则 en;因为 transformers.js 4.2.0 没有语言自动检测);transcribeAttachmentPcm 接收浏览器端 Web Audio 解码好的 16 kHz 单声道 PCM(上限 30 分钟);推理在 worker 线程(从语义索引抽出通用的 worker/thread-transport.ts,两者共用,语义索引行为不变):首个请求才起线程、PCM 走 transfer list、空闲 5 分钟释放约 1 GB 常驻内存、切换模型档位重建;[transcription] model / language 进配置文件;桌面 IPC transcription:{getConfig,setConfig,listPending,transcribePcm},CLI Web GET|PATCH /_web/transcription/config、GET /_web/sessions/:id/transcription/pending、POST /_web/sessions/:id/attachments/:attachmentId/transcribe(application/octet-stream,bodyLimit 115 MB,鉴权与其它 _web 路由一致);共享逻辑在 node-runtime/src/services/transcription-service.ts,入口层只做校验与开库。CLI Web 端到端:合成 3.4 s 中文 wav → 「今天天氣不錯,我們出去走走吧」,content 改写为 [语音 3秒] …。prompt_ids 简体提示实测不被 4.2.0 读取(generate() 里该参数被注释掉),中文仍可能输出繁体。

做法(前端,阶段 B2):渲染进程用 Web Audio 解码附件(AudioContext.decodeAudioData + OfflineAudioContext 重采样到单声道 16 kHz;Chromium 自带 mp3 / m4a / ogg / wav / flac,SILK / AMR 报「格式暂不支持」),平台适配器新增可选能力 transcription(桌面走 preload,CLI Web 走 /_web 路由,Web WASM 不实现即隐藏全部入口);会话页动作面板新增「转写语音」→ 弹窗列出待转写语音、模型与语言(可临时改)、首次下载体积提示,逐条串行解码 → 上传 PCM → 落库,进度与每条状态、失败原文、两条之间可取消,关闭时若有成功则刷新会话;设置 › AI 新增「语音转文字」区块(模型档位、默认语言);四语文案 31 个 key。CLI Web 已实机走完点击流程(更多 → 转写语音 → 弹窗 → 开始转写 → 已转写 → 消息文本更新为 [语音 3秒] … 简体);Electron 端只经 type-check / 单测,未实机点击。

未做:用法文档(上游目前没有 CLI 命令参考页,语义索引也无单独文档,保持一致);SILK / AMR 解码器(AudioDecoder 接口已留)。

7. 开发指南

pnpm test 前需 pnpm --filter chatlab-mcp build,改 packages/mcp-server/src 后需重新构建(CI 已有此步骤)。

验证

集成分支上:pnpm run type-check:all、eslint、prettier、cargo test(62)、pnpm run build:parser-wasm 与 napi 重建后产物与提交一致、全量 pnpm test(2 499 通过 + 1 条需显式启用的真模型冒烟)、真模型冒烟(CHATLAB_TRANSCRIPTION_SMOKE=1)通过、pnpm docs:build、git diff --check 全绿;新增一条迁移集成测试:v10 库经 DatabaseManager 打开后升到 v12 且 message_fts 与 message_attachment 同时存在。基准脚本 scripts/bench-message-search.mts、bench-telegram-parser.mts(含 --scan)、bench-discord-parser.mts、bench-transcription.mts 随本 PR 提交。

🤖 Generated with Claude Code

Zycheng114514 and others added 30 commits September 8, 2026 01:44
apps/cli imports chatlab-mcp through the workspace dependency, so on a
fresh clone pnpm test fails with ERR_MODULE_NOT_FOUND in
runtime-compat-startup.test.ts until the package is built. CI already
runs the build step; document it for local runs in both language guides.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Add a desktop UI scale preference with five presets (90%-150%), stored
as desktop.ui_scale. The main window applies the saved factor via
webPreferences.zoomFactor at creation and re-applies it on
did-finish-load, so the choice survives a restart without a first-frame
flash. Non-desktop platform adapters report the default scale and reject
writes; the settings row only renders on Electron.

Closes ChatLab#445

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tool and parameter descriptions were handed to the LLM in Chinese on every
runtime regardless of locale: the English metadata table in
packages/tools/src/tool-metadata.ts had no caller, the desktop translateTool
looked up i18n keys that exist in no locale file, and the MCP server had no
locale at all.

Fill the table for every tool whose definition text is Chinese (37 entries,
only translating what the definitions write in Chinese so the two copies
cannot drift), and route the four consumers through getLocalizedToolMetadata:
the cross-chat agent adapters, the CLI Web agent adapter, the desktop shared
tool adapter (replacing the dead translateTool), and the MCP server, which
now resolves its locale from config.locale.lang or the system locale for both
the standalone binary and the chatlab mcp subcommand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds scripts/bench-message-search.mts: builds a deterministic synthetic
chat database with better-sqlite3 and CHAT_DB_SCHEMA (no parser), then
measures the core searchMessagesByKeywords() path over a fixed twelve
query set (1 cold run on a fresh connection + N warm runs each).

Records p50/p95, total, and the sha256 of the full sorted hit-id set so
before/after runs can be compared for recall. When @openchatlab/core
exports ensureMessageSearchIndex the index is built after inserting and
its duration recorded; otherwise the run reports searchIndex: null and
measures the LIKE-only baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Queries 9 and 10 used 周末/打球, which only ever co-occur in the same
sample sentence, so 'any' and 'all' produced an identical hit set. Using
天气/打球 keeps the SQL shapes but makes the two modes differ in recall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Declares message_fts as an FTS5 external-content table over message.content
with trigram tokenization, plus insert/update/delete triggers, appended to
CHAT_DB_INDEXES so bulk importers still defer index work until after the
rows are written and browser opens create it automatically.

search-index.ts owns the query-path helpers: ensureMessageSearchIndex()
creates and backfills the index inside a transaction, canUseFtsKeywords()
rejects keyword sets the trigram tokenizer cannot match (< 3 code points),
and buildFtsMatchExpression() turns keywords into a quoted MATCH expression.

Coverage is measured against the FTS5 docsize shadow table: an
external-content table answers COUNT(*) from the content table, so it
cannot distinguish an empty index from a populated one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
buildMsgConditions gains a keywordMode: 'fts' emits
`msg.id IN (SELECT rowid FROM message_fts WHERE message_fts MATCH ?)`
instead of the LIKE scan. The three entry points — searchMessagesByKeywords,
searchMessagesLikeAsync and searchMessagesLike — pick it when the index
exists and every keyword clears the trigram floor, so all three runtimes get
the same routing from one place. `forceLike` opts a caller back out.

`sort: 'relevance'` orders by bm25 on the indexed path (the page joins the
FTS table because bm25 needs it in the FROM list; COUNT keeps the
IN-subquery) and falls back to timestamp order everywhere else.

searchMessagesLike now delegates to searchMessagesByKeywords, which also
fixes its unescaped LIKE pattern: a keyword containing % or _ used to match
messages it should not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion

Migration v11 calls ensureMessageSearchIndex, so existing sessions get the
index (and its backfill) the first time a current runtime opens them; the
four desktop locales gain the matching migration strings.

The streaming importer bulk-inserts before message_fts exists, so it
backfills once after CHAT_DB_INDEXES and reports the cost as a new
searchIndexMs stage timing. The incremental and push importers call the
same helper after their writes: normally two COUNT(*), a rebuild only when
the index was missing.

The four tests that asserted message_fts was absent now assert it is
present and covers every message. The two v8 fixtures build from
CHAT_DB_TABLES so they still describe a pre-index database, and a new
v10 -> v11 test proves messages written before the upgrade are findable
through the index afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The browser has no migration runner, so a session imported before the index
existed only gets it when a database open applies CHAT_DB_INDEXES. The open
path now backfills it there, between new 'search-index-checking' and
'search-index-ready' stages, and the import path backfills after its own
CHAT_DB_INDEXES rather than leaving the cost to the first search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
search_messages now asks for sort: 'relevance', so the AI receives the
best-matching messages instead of the most recent ones, and its description
says what actually happens (index above three characters, substring
matching below). deep_search_messages passes forceLike, which finally makes
it the distinct exhaustive path its name claims — until now it was the same
query as search_messages.

The desktop worker forwards sort/forceLike through to
searchMessagesLikeAsync so the Electron path behaves like the CLI one, and
the cross-chat agent prompt no longer tells the model that keyword search is
"literal LIKE".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getPendingMigrationInfos now returns version 11, so the pending-migration
test asserts its own localized message instead of stopping at v10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The streaming-import benchmark guarded against message_fts existing at all.
It now checks the opposite property that matters: after an import the index
must cover every message, since the rows are written before it exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e changes

Tests import the built output, so an outdated build silently runs old MCP
code. Say so in both language guides, as suggested in review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
did-finish-load fires again when the renderer is reloaded after a crash;
reading the setting at that point keeps a scale changed since startup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ession

The same private chat exported from both accounts carries identical
messages under different owners and disjoint platform message ID spaces.
Appending the second export to the first session stores every message a
second time (6 of 6 duplicated, 12 rows for 6 messages).

Refs ChatLab#358

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add AutoImportOptions.forceCreate, which plans a separate session with
create reason 'user-choice' regardless of what automatic matching would
find. Expose the existing analyzeAutoImportFile preview through the
desktop worker (chat:analyzeAutoImport) and a CLI Web
/_web/import/analyze-target route so the renderer can preselect the
target automatic matching would pick.

Transport: chat:importWithOptions accepts a `target` option and maps it
to explicitSessionId / forceCreate; /_web/import accepts targetMode and
targetSessionId; /_web/import/batch accepts only auto and new, since
appending several files to one chosen session has no defined meaning;
clb import gains --new-session, mutually exclusive with --session-id.

Refs ChatLab#376

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Single-file imports now open ImportTargetModal once the format is
settled: keep automatic matching (preselected, showing which session it
would append to and why), append to a session picked from the existing
session list, or create a separate session. ImportOptions gains a
`target` field that each adapter forwards to its backend, and a new
analyzeAutoImport service method previews the automatic decision so the
dialog can preselect it.

Web WASM has no incremental import, so its adapter reports a create
decision and the dialog stays hidden there; batch, directory, archive and
multi-chat imports keep their existing automatic behaviour.

Closes ChatLab#376

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same private chat exported from both accounts carries identical
messages under different owners and independent platform message ID
spaces. Appending the second export to the first session stored every
message again, because a message that already has a platform ID was only
compared by content against ID-less rows.

An append the user chose (explicitSessionId) is now marked
crossSourceAppend. When the exporting account differs from the session's
owner, the incoming platform message IDs are namespaced with a scope
derived from that account, and dedup compares the content fingerprint
against every candidate at the same timestamp and sender rather than only
ID-less ones. The namespace is derived from the source, so re-importing
the same export stays idempotent, and the session keeps its own owner so
the namespace does not drift.

Imports without that flag, and appends whose export shares the session's
owner, keep comparing platform IDs exactly as before.

Closes ChatLab#358

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Define `messages[].attachments[]` (kind, path, name, mimeType, size,
durationMs, width, height) in the ChatLab format specification, and turn
the reserved-field paragraph in the import specification into a pointer
to that structure.

The format version stays 0.0.2: attachments is an optional field that
older readers can ignore.

Refs ChatLab#170

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…inputs

Merging or re-importing a session produces a new chat database, so its
vectors live under a new db_path_hash and every chunk is embedded again
even when the text has not changed at all.

Before embedding a batch, look for an already indexed chunk in another
chat database whose embedding input is byte-identical, and copy its
vector instead. The key is embedding_input_hash, which every chunk row
already stores, so existing indexes are reusable without a backfill;
model, strategy, chunker identity and dim must all match for the copied
vector to mean anything.

A run that only copied vectors proves nothing about the local model, so
it no longer clears the model preload error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
carryOver enables the merged session and queues its build as soon as at
least one source session is indexed with the current model, so the user
does not have to ask for the index again: chunks whose embedding input
is unchanged copy the source vectors and only the new messages are
embedded.

The worker client tracks the merged session as an active build for the
duration, the way build does. Nothing polls status after a merge, so
without it the idle timer could terminate the worker while the build was
still running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The merge route knew which sessions were merged but had no way to tell
anyone, so the new session started with no vectors at all.

Remember the source sessionId on each exported merge handle and, once the
merged output has been imported, call onMergedSessionImported with the
new session and its sources. Desktop and CLI Web wire it to the semantic
index, which carries the existing vectors over. The callback runs in the
background: the merge response must not wait for an index build, and a
failure there must not fail the merge.

Closes ChatLab#378

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Attachments parsed from an export now have somewhere to live:

- shared-types gains `ParsedAttachment` and `ParsedMessage.attachments`
- `message_attachment` (schema version 11) stores one row per attachment,
  indexed by message; `meta.source_dir` records the directory the export
  file was imported from so relative paths stay resolvable
- `ensureAttachmentSchema` backs both the Node migration and the browser
  runtime, which replays DDL on open instead of running migrations
- the message inserters switch to `INSERT ... RETURNING id` so attachment
  rows are written in the same transaction as their messages; incremental
  and push imports only write attachments for messages that survive dedup
- `getMessageAttachments` / `fetchMessageAttachments` read them back per
  page, and `resolveAttachmentPath` resolves a relative path against the
  source directory while refusing anything that escapes it

Refs ChatLab#170

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tagram exports

Media messages keep their text marker and now also carry the file:

- ChatLab JSON/JSONL read `attachments`, skipping malformed entries; when a
  media message has none and its content is a bare file path, one attachment
  is inferred from it (issue ChatLab#170: WeFlow-converted WeChat exports). URLs are
  never inferred, so an export cannot make the app fetch a remote address.
- Telegram maps photo/file plus media_type, file_name, mime_type, duration
  and dimensions; Discord maps every attachment (url, fileName, fileSizeBytes)
  with the kind taken from the existing extension rules; Instagram maps
  photos, videos and audio_files.
- The Rust chatlab kernel gains the same attachment parsing and inference, so
  the native path no longer drops the field, and the browser runtime maps
  attachments from both its TS parser and the WASM kernel.

Closes ChatLab#170

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Attachment chips appear under each message in the chat record, batched one
query per page of messages:

- desktop registers the privileged `chatlab-media://` scheme and serves the
  file through `net.fetch` only after the shared path resolver confirms it
  sits inside the directory the export was imported from; the chip reveals
  the file in the OS file manager
- CLI Web serves the same files from `GET /_web/sessions/:id/attachments/:id`
  and its chips download; images render inline and browser-playable audio
  gets a player on both runtimes
- a runtime without attachment file access (Web WASM) shows the chip alone

Path resolution and content type live in one node-runtime service so both
entry points refuse the same paths.

Refs ChatLab#170

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cords

Built with wasm-pack 0.15.0 (the version CI installs) so the Web WASM
parser path carries attachments like the napi kernel does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The pull document still described attachments with filePath / dataUri from
the reserved-field era; the structure is now defined in the format spec.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Zycheng114514 and others added 11 commits September 8, 2026 18:07
`scripts/bench-transcription.mts` synthesizes a minute of Chinese and English
speech with macOS `say` (nothing is committed, no user audio is read) and
reports elapsed time, real-time factor, first-load time, peak RSS and model
download size for tiny / base / small, one model at a time.

Adds the opt-in real-model smoke test (CHATLAB_TRANSCRIPTION_SMOKE=1) that runs
the full WAV → PCM → 16 kHz → Whisper path, the only check that the pieces fit
together outside of fakes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uffers

Transformers.js 4.2.0 has no language detection: with no language token it
warns and transcribes as English, so Chinese voice messages came back as
nonsense. Resolve the language from the session's own text messages before
the audio reaches Whisper, and add the single-attachment PCM entry point the
desktop and CLI Web runtimes need once they decode audio in the browser.

- core: detectSessionLanguage / resolveTranscriptionLanguage (CJK vs Latin
  character ratio over the 300 most recent text messages)
- node-runtime: transcribeAttachmentPcm, with a 30-minute PCM ceiling and a
  coded error for non-audio attachments; transcribePcm no longer accepts auto
- config: [transcription] model / language, read as the CLI flag defaults

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Voice transcription needs the same worker plumbing the semantic index already
has (tsx bootstrap, correlated postMessage, graceful runtime close before
terminate), plus a transfer list so PCM buffers are handed over rather than
copied. Move the feature-agnostic half to worker/thread-transport.ts and keep
createSemanticIndexWorkerThreadTransport as the semantic index's entry point;
its behaviour, options and exported types are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Whisper's log-mel feature extraction runs in JavaScript and the model holds
about a gigabyte of ONNX tensors, so transcribing on the main thread would
freeze the Electron main process and the CLI Web server for the length of the
clip. Run it in a worker thread instead, started by the first request and
closed again after five idle minutes so a user who never records voice pays
nothing. PCM samples are copied once and handed to the worker through the
transfer list, leaving the caller's buffer intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The renderer can decode audio Chromium understands but the Node decoders do
not, so the desktop path is: decode in the renderer, send 16 kHz mono PCM over
IPC, transcribe in a worker thread, store the transcript. Settings, the pending
queue and the PCM write path are shared with the CLI Web routes in
node-runtime/src/services so the two entry layers cannot drift; the handlers
only open the session database and check their arguments.

An attachment id from another session is not in the session's own database, so
it fails as not found rather than writing across sessions. Buffers that are not
a whole number of floats, or longer than the 30 minute cap, are rejected before
the worker is started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CLI Web page decodes a voice attachment with Web Audio and posts the raw
16 kHz mono Float32 samples to /_web/sessions/:id/attachments/:id/transcribe,
which runs Whisper in a worker thread and stores the transcript. Settings and
the pending queue are served next to it, and the whole group shares the write
path with the desktop IPC handlers.

Thirty minutes of samples is about 115 MB, past both Fastify's 1 MB default and
this repo's 50 MB server limit, so the endpoint raises its own bodyLimit and
registers the octet-stream parser the raw body needs. Auth is unchanged: the
CLI's bearer hook is an onRequest hook on the whole instance and already covers
every /_web/ path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chromium reads mp3, m4a, ogg-opus, wav and flac, which the Node decoders
cannot, so the page decodes a voice attachment with Web Audio and hands the
backend mono 16 kHz PCM. Desktop goes over IPC and CLI Web over /_web, and the
adapter absorbs the difference; Web WASM has no backend, so it simply has no
transcription capability and useTranscriptionService() returns null there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The session tools panel gets a "transcribe voice" entry that lists the voice
attachments with no transcript yet and works through them one at a time:
decode in the page, send the PCM, store the result. Cancel takes effect between
two attachments — the one in flight is awaited — and a run that finishes with at
least one transcript reloads the session the same way an incremental import does.

The entry only appears where the platform adapter exposes a transcription
capability, so Web WASM never shows it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Settings > AI gains a voice-to-text section with the two values the backend
already stores: the Whisper size and the default language. The download sizes
and the "base or larger for Chinese" advice sit next to the size picker, since
that is where a user decides whether the larger model is worth the disk.

Both the section and its anchor are conditional on the platform having a
transcription capability, so Web WASM shows neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…closed

Emitting `transcribed` only from the footer button missed Esc, the overlay and
the modal's own close control, so a session whose voice messages had just been
transcribed kept showing the old placeholders until the page was revisited.
Moving it onto the close watcher covers every path.

While in there: an attachment interrupted mid-decode by Cancel goes back to
pending rather than being reported as a failure, so "transcribe the rest" picks
it up, and the settings pickers relabel when the interface language changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 85f73c87de

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

): string | null {
if (!relativePath) return null
if (isRemoteAttachmentPath(relativePath)) return relativePath
if (isAbsoluteAttachmentPath(relativePath)) return relativePath

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject absolute paths in attachment serving

When a ChatLab import supplies an absolute attachment path such as /etc/passwd, this branch returns it unchanged; resolveAttachmentFile() then exposes it through the authenticated CLI Web attachment route and the Electron media protocol despite both callers claiming to restrict files to the import directory. A CLI Web user who can upload an import can consequently read arbitrary files accessible to the server process, so file-serving paths must require containment within a trusted attachment root rather than accepting absolute paths from imported data.

Useful? React with 👍 / 👎.

Comment on lines +329 to +330
// Attachment paths in an export are relative to the export file, not to the preprocessed temp copy.
const sourceDir = path.dirname(path.resolve(filePath))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist CLI Web directory sources before cleanup

For POST /_web/import-directory, filePath is an entry under the route's temporary upload directory, so this stores that temporary directory in meta.source_dir; the route then unconditionally calls cleanupTemp(tmpDir) after import. Every relative image or audio attachment from a CLI Web directory upload therefore resolves to deleted files and immediately returns 404. The uploaded media must be copied to durable session storage, or its directory must remain alive for the session.

Useful? React with 👍 / 👎.

Comment on lines +674 to +678
if (msg.attachments?.length) {
const inserted = insertMessageReturningId.get(...messageParams) as { id: number } | undefined
if (inserted) {
for (const attachment of msg.attachments) {
attachmentRows.push({ messageId: inserted.id, attachment })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve each incremental import's attachment base

When a user appends an export located in a different directory, these attachment paths are stored unchanged while meta.source_dir remains the directory of the session's original import. resolveAttachmentFile() consequently resolves every newly appended relative path against the wrong folder, making those attachments unavailable even though the files still exist. Persist source provenance per attachment or copy/rebase attachments into a stable session-owned root during incremental import.

Useful? React with 👍 / 👎.

msg.content || null,
msg.replyToMessageId || null
msg.replyToMessageId || null,
msg.attachments?.length ? JSON.stringify(msg.attachments) : null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Export attachment rows into session merge handles

In the checked /_web/merge/from-sessions flow, exportSessionToJson() neither selects nor returns attachment rows, and the route maps only the existing message fields into TempDbWriter; therefore msg.attachments is always undefined here for handles created from existing sessions. Merging sessions silently drops all their attachments, so the session-export path must load and pass grouped attachment records into the temporary database.

Useful? React with 👍 / 👎.

Comment on lines +132 to +134
await service.transcribePcm(props.sessionId, item.attachment.id, pcm, language.value)
modelLoaded.value = true
item.state = 'done'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh after late transcription completion

When the modal is closed after PCM upload but while model inference is running, stopRun() invalidates the token but cannot cancel the backend call; if that call succeeds, this code still marks the item done without rechecking the token. The close watcher has already observed doneCount === 0, so it emits no transcribed event even though the database is updated later, leaving the visible message stale until another reload. Recheck the token after the awaited write and explicitly notify the parent when a cancelled run completes successfully.

AGENTS.md reference: AGENTS.md:L21-L22

Useful? React with 👍 / 👎.

Comment on lines +97 to +100
if (profile !== this.activeProfile) {
await transport.request('setProfile', [profile])
this.activeProfile = profile
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize profile switches with transcriptions

With concurrent requests using different model profiles, the profile change and transcription are not atomic: while a small request is awaiting setProfile, activeProfile still says base, so a concurrent base request skips its own switch and queues transcribePcm after the pending switch. The worker then transcribes that request with small, while the service records the configured base model ID. Serialize profile-dependent requests or send the requested profile as part of one worker operation.

AGENTS.md reference: AGENTS.md:L21-L22

Useful? React with 👍 / 👎.

Zycheng114514 and others added 7 commits September 8, 2026 22:16
…cript

Whisper writes Chinese in traditional characters no matter what the speaker
or the chat uses, so a transcript dropped into a simplified conversation
reads in a different script from every message around it and a search for
the simplified spelling misses it.

Convert once between inference and the database, to the script the session
is already written in (`auto`) or to whichever the new
`transcription.chinese_script` setting pins. English is never touched.

Detection reuses the sample the spoken-language guess already draws, so the
two answers can never come from different messages: `sampleRecentTextMessages`
is now shared out of core, and the simplified/traditional comparison lives in
node-runtime, which is where the `chinese-conv` dependency can go without
core losing its browser-safe, dependency-free build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The AI settings section grows a third row next to model and language, so a
user whose chat is in traditional characters (or who wants simplified in a
chat that mixes both) can pin the script instead of relying on the guess.
"Auto" stays the default and follows the conversation's own text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tools panel is always in header mode for regular users (the side layout
is debug-only), and header mode lists only the short header subset, so the
transcription entry added to the full list was unreachable. Build the header
subset from the capability-filtered tool list instead of a second array.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Decoding inside an OfflineAudioContext at Whisper's sample rate lets Chromium
resample once during decode, instead of decoding at the device rate and
resampling again in a second offline pass, and it holds no audio hardware.
Measured on a 16 kHz clip, both paths differ from the raw samples by at most
5e-5, so this is a simplification rather than a quality fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d121ed1737

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +565 to +567
const attachments = await useMessageService().getMessageAttachments(sessionId, pendingIds)
if (Object.keys(attachments).length === 0) return
attachmentsByMessageId.value = { ...attachmentsByMessageId.value, ...attachments }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Discard attachment responses from the previous session

When the user switches sessions while this asynchronous lookup is in flight, the session watcher clears the cache, but the old request can subsequently repopulate it with attachment metadata from the previous database. Since message and attachment IDs commonly overlap between session databases, the new session may then render stale names/types against its own attachment URLs and skip the correct lookup because those IDs remain marked as requested. Check that effectiveSessionId still equals the captured sessionId before committing the response, or cancel/version requests on session changes, and cover an out-of-order session-switch response.

AGENTS.md reference: AGENTS.md:L21-L22

Useful? React with 👍 / 👎.

Comment on lines +30 to +33
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve the import promise when the modal is dismissed

If the user dismisses this modal with Escape, the backdrop, or the modal close control rather than either footer button, UModal writes false through this setter but no cancel event is emitted. Consequently askImportTarget() never resolves and the pending file-import handler remains suspended. Emit cancellation when an open modal transitions to closed, or make the modal non-dismissible so every close path invokes handleClose().

Useful? React with 👍 / 👎.

Comment on lines +197 to +199
...(msg.photos ?? []).map((photo) => ({ kind: 'image' as const, path: photo.uri })),
...(msg.videos ?? []).map((video) => ({ kind: 'video' as const, path: video.uri })),
...(msg.audio_files ?? []).map((audio) => ({ kind: 'audio' as const, path: audio.uri })),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebase Instagram media paths against the export root

For an official Instagram layout such as messages/inbox/<thread>/message_1.json, these URIs are export-root-relative (messages/inbox/<thread>/photos/...), while streamImportSingle() records dirname(message_1.json) as meta.source_dir. Resolving the stored URI therefore duplicates the directory prefix and all newly imported Instagram photos, videos, and audio return 404 even when the original export remains present. Preserve the selected export root or rewrite each URI relative to the JSON file directory, and validate against the nested official directory layout.

Useful? React with 👍 / 👎.

@hellodigua

Copy link
Copy Markdown
Member

麻烦还是拆回多个不同的PR,一个功能一个PR这样好一些

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants