Repository navigation
feat: round 1 — FTS5 search, attachments, import target choice, embedding reuse, UI scale, localized tool descriptions, Telegram/Discord Rust kernels, local voice transcription - #459
Conversation
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>
`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>
There was a problem hiding this comment.
💡 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 |
There was a problem hiding this comment.
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 👍 / 👎.
| // Attachment paths in an export are relative to the export file, not to the preprocessed temp copy. | ||
| const sourceDir = path.dirname(path.resolve(filePath)) |
There was a problem hiding this comment.
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 👍 / 👎.
| 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 }) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 👍 / 👎.
| await service.transcribePcm(props.sessionId, item.attachment.id, pcm, language.value) | ||
| modelLoaded.value = true | ||
| item.state = 'done' |
There was a problem hiding this comment.
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 👍 / 👎.
| if (profile !== this.activeProfile) { | ||
| await transport.request('setProfile', [profile]) | ||
| this.activeProfile = profile | ||
| } |
There was a problem hiding this comment.
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 👍 / 👎.
…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>
44f5d34 to
d121ed1
Compare
There was a problem hiding this comment.
💡 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".
| const attachments = await useMessageService().getMessageAttachments(sessionId, pendingIds) | ||
| if (Object.keys(attachments).length === 0) return | ||
| attachmentsByMessageId.value = { ...attachmentsByMessageId.value, ...attachments } |
There was a problem hiding this comment.
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 👍 / 👎.
| const isOpen = computed({ | ||
| get: () => props.open, | ||
| set: (value) => emit('update:open', value), | ||
| }) |
There was a problem hiding this comment.
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 👍 / 👎.
| ...(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 })), |
There was a problem hiding this comment.
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 👍 / 👎.
|
麻烦还是拆回多个不同的PR,一个功能一个PR这样好一些 |
这一轮的九项改动各自在独立分支上开发并验收,按依赖顺序合并进本分支(
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 字查询不变。导入:messageWriteMs1 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 WebtargetMode/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 导出同理;两者只有 TSstream-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(整文件缓冲)。.wasm161 KB → 207 KB。行为变化:浏览器 Telegram 导入现在带附件(浏览器 TS 解析器本来不产出附件);浏览器全量导出里无
actor的 service 消息发送者从System变为系统,与桌面端一致。做法(Telegram 扫描):选聊天前的列表扫描也走内核(
scan: true:只数chats.list每项的消息数、不物化),NodescanChats与浏览器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语言按会话文本解析(coredetectSessionLanguage:最近 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进配置文件;桌面 IPCtranscription:{getConfig,setConfig,listPending,transcribePcm},CLI WebGET|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