Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
56de090
docs(contributing): build chatlab-mcp before running pnpm test
Zycheng114514 Sep 8, 2026
f750b4d
feat(electron): add UI scale setting
Zycheng114514 Sep 8, 2026
eb2488f
feat(tools): localize tool descriptions by locale for every runtime
Zycheng114514 Sep 8, 2026
a0065b1
feat(search): add message search benchmark script
Zycheng114514 Sep 8, 2026
e366771
feat(search): differentiate any/all queries in the search benchmark
Zycheng114514 Sep 8, 2026
6bae810
feat(search): add FTS5 trigram index for message content
Zycheng114514 Sep 8, 2026
919b033
feat(search): route keyword search through the FTS5 index
Zycheng114514 Sep 8, 2026
bb864f4
feat(search): build the message search index on Node import and migra…
Zycheng114514 Sep 8, 2026
a662cfa
feat(search): create the message search index in the browser runtime
Zycheng114514 Sep 8, 2026
c3d399c
feat(search): rank AI search hits by relevance and split deep search off
Zycheng114514 Sep 8, 2026
22b2ddd
test(search): cover the v11 migration in the desktop migration list
Zycheng114514 Sep 8, 2026
a944206
test(search): assert the import benchmark backfills the search index
Zycheng114514 Sep 8, 2026
532cce9
docs(contributing): note that chatlab-mcp must be rebuilt after sourc…
Zycheng114514 Sep 8, 2026
a5fe5cf
fix(electron): reapply the current UI scale after renderer reloads
Zycheng114514 Sep 8, 2026
09aa99c
test(import): reproduce cross-source duplicates when appending to a s…
Zycheng114514 Sep 8, 2026
e5d9867
feat(import): carry a chosen import target through the backends
Zycheng114514 Sep 8, 2026
ba0a61f
feat(import): let users choose the import target session
Zycheng114514 Sep 8, 2026
6d6b09a
fix(import): dedupe cross-source messages when appending to a session
Zycheng114514 Sep 8, 2026
65b4b96
docs(standard): define the attachments field of the ChatLab format
Zycheng114514 Sep 8, 2026
d339ec7
feat(semantic-index): reuse existing vectors for identical embedding …
Zycheng114514 Sep 8, 2026
ba7757d
feat(semantic-index): carry the semantic index over to a merged session
Zycheng114514 Sep 8, 2026
531747c
feat(merge): hand the merged session to the semantic index
Zycheng114514 Sep 8, 2026
3ddbe0e
feat(core): add message_attachment table and meta.source_dir
Zycheng114514 Sep 8, 2026
2f04ba0
feat(parser): emit attachments for ChatLab, Telegram, Discord and Ins…
Zycheng114514 Sep 8, 2026
4378155
feat(records): show message attachments
Zycheng114514 Sep 8, 2026
740b92c
chore(parser-native): regenerate the wasm bindings with attachment re…
Zycheng114514 Sep 8, 2026
581ef1e
docs(standard): point the pull protocol's attachments at the format spec
Zycheng114514 Sep 8, 2026
67aeb8b
merge: integrate docs/test-prereq-mcp-build into round-1
Zycheng114514 Sep 8, 2026
22f8e36
merge: integrate feat/ui-scale into round-1
Zycheng114514 Sep 8, 2026
796f538
merge: integrate feat/tool-descriptions-i18n into round-1
Zycheng114514 Sep 8, 2026
4722bc1
merge: integrate feat/message-fts5 into round-1
Zycheng114514 Sep 8, 2026
1649244
merge: integrate feat/semantic-index-carryover into round-1
Zycheng114514 Sep 8, 2026
528df75
merge: integrate feat/import-target-choice into round-1
Zycheng114514 Sep 8, 2026
34675fe
merge: integrate feat/message-attachments into round-1
Zycheng114514 Sep 8, 2026
bb36af2
test(migrations): cover the v11 and v12 upgrade path on one v10 database
Zycheng114514 Sep 8, 2026
486037a
feat(parser): add Telegram parser benchmark script
Zycheng114514 Sep 8, 2026
d3cde6b
feat(parser-native): add Telegram kernel
Zycheng114514 Sep 8, 2026
1552ba9
feat(web): parse Telegram exports with the Rust WASM kernel
Zycheng114514 Sep 8, 2026
0e384a6
feat(parser): add Discord parser benchmark script
Zycheng114514 Sep 8, 2026
2ac7bf1
feat(parser-native): add Discord kernel
Zycheng114514 Sep 8, 2026
4235609
feat(parser-native): scan Telegram chats with the Rust kernel
Zycheng114514 Sep 8, 2026
402a02e
merge: integrate feat/native-telegram-discord into round-1
Zycheng114514 Sep 8, 2026
5873957
feat(transcription): add local whisper transcription service
Zycheng114514 Sep 8, 2026
0bc4533
feat(cli): add transcribe command
Zycheng114514 Sep 8, 2026
91694c5
feat(transcription): add transcription benchmark script
Zycheng114514 Sep 8, 2026
4a6de9c
feat(transcription): detect the session language and transcribe PCM b…
Zycheng114514 Sep 8, 2026
c777ec9
refactor(node-runtime): share the worker thread transport
Zycheng114514 Sep 8, 2026
9bcf369
feat(transcription): run whisper in a worker thread
Zycheng114514 Sep 9, 2026
7789595
feat(electron): add transcription IPC
Zycheng114514 Sep 9, 2026
b304370
feat(cli-web): add transcription routes
Zycheng114514 Sep 9, 2026
7dabd77
feat(transcription): add the renderer audio decoder and platform service
Zycheng114514 Sep 9, 2026
09ed636
feat(records): transcribe voice attachments from the session page
Zycheng114514 Sep 9, 2026
6876ae0
feat(settings): add transcription model and language settings
Zycheng114514 Sep 9, 2026
8ceaecc
fix(records): refresh the session however the transcription modal is …
Zycheng114514 Sep 9, 2026
85f73c8
merge: integrate feat/voice-transcription into round-1
Zycheng114514 Sep 9, 2026
a33137b
feat(transcription): normalize Chinese transcripts to the session's s…
Zycheng114514 Sep 9, 2026
eb9d079
feat(settings): choose the Chinese script for transcripts
Zycheng114514 Sep 9, 2026
51dad5f
merge: integrate feat/transcription-chinese-script into round-1
Zycheng114514 Sep 9, 2026
a91ecb7
fix(records): show the voice transcription tool in the header tools menu
Zycheng114514 Sep 9, 2026
8e59cb1
merge: integrate fix/transcription-header-entry into round-1
Zycheng114514 Sep 9, 2026
5c26933
refactor(records): decode voice attachments directly at 16 kHz
Zycheng114514 Sep 9, 2026
d121ed1
merge: integrate fix/transcription-decode-at-16k into round-1
Zycheng114514 Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(parser-native): scan Telegram chats with the Rust kernel
Listing the chats of a full export ran before any import: Node streamed the
file with stream-json (~20 MB/s) and the browser JSON.parsed the whole
export, which a several-hundred-MB export cannot afford just to fill the
chat picker.

The telegram kernel gains `scan: true`, which walks chats.list and counts
each chat's messages without materializing one, and both scanners try it
first (Node scanChats, browser scanBrowserMultiChatSource) and keep their
existing implementation as the fallback.

Single-chat mode now also refuses an export without a string name/type, a
numeric id or a messages array, matching what both TS single-chat parsers
reject, so falling back produces their `Invalid Telegram single-chat JSON
export` error instead of a kernel-only result.

Node scan, 102.3 MB / 3 chats: 21.4 -> 236.9 MB/s (11.1x), peak RSS 661 ->
654 MB. 514.8 MB: 21.2 -> 235.9 MB/s (11.1x), peak RSS 2296 -> 2279 MB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Loading branch information
Zycheng114514 and claude committed Sep 8, 2026
commit 42356098939492f9c446b47d78d98407b7dc837d
232 changes: 217 additions & 15 deletions packages/parser-native/src/telegram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,38 @@ struct TelegramMeta {
chat_index: Option<u32>,
}

/// One row of `metaJson()` in scan mode, matching the TelegramChatInfo shape
/// both TS scanners return.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct TelegramScanChat {
index: u32,
name: String,
#[serde(rename = "type")]
chat_type: String,
id: i64,
message_count: u64,
}

/// Shape of `metaJson()` in scan mode; members and messages stay empty.
#[derive(Serialize)]
struct TelegramScanMeta {
scan: bool,
chats: Vec<TelegramScanChat>,
}

struct TelegramOptions {
chat_index: u32,
single: bool,
scan: bool,
}

/// `chatIndex` (default 0) and `single` from the JS `formatOptions` blob.
/// `chatIndex` (default 0), `single` and `scan` from the JS `formatOptions` blob.
fn parse_options(options_json: Option<&str>) -> ScanResult<TelegramOptions> {
let mut options = TelegramOptions {
chat_index: 0,
single: false,
scan: false,
};
let Some(raw) = options_json else {
return Ok(options);
Expand All @@ -58,6 +80,7 @@ fn parse_options(options_json: Option<&str>) -> ScanResult<TelegramOptions> {
};

options.single = matches!(map.get("single"), Some(Value::Bool(true)));
options.scan = matches!(map.get("scan"), Some(Value::Bool(true)));
options.chat_index = match map.get("chatIndex") {
None | Some(Value::Null) => 0,
Some(Value::Number(number)) => match number.as_u64() {
Expand Down Expand Up @@ -491,19 +514,8 @@ fn parse_chat(
})
}

pub fn parse_telegram(
buf: &[u8],
input: &KernelInput,
on_progress: impl FnMut(u64, u64),
) -> ScanResult<KernelOutput> {
let options = parse_options(input.options_json.as_deref())?;
if options.single {
let spans = collect_chat_spans(buf)?;
return parse_chat(buf, &spans, None, SERVICE_SENDER_SINGLE, on_progress);
}

// Full export: chats.list[chatIndex]. Non-target chats are skimmed by the
// scanner and never materialized.
/// The raw `chats.list` array span of a full export.
fn find_chat_list(buf: &[u8]) -> ScanResult<&[u8]> {
let mut chats_raw: Option<&[u8]> = None;
walk_top_level(buf, |key, raw| {
if key == b"chats" {
Expand All @@ -520,7 +532,129 @@ pub fn parse_telegram(
}
Ok(())
})?;
let list_raw = list_raw.ok_or_else(|| scan_error("missing chats.list array", 0))?;
list_raw.ok_or_else(|| scan_error("missing chats.list array", 0))
}

/// Scan mode: the chat picker's list, without materializing a single message.
///
/// Replicates `scanChats` in formats/telegram-native.ts and
/// `scanTelegramChatsJson` in browser/telegram.ts, which agree on every field.
/// A chat whose shape those two disagree on (a missing `name`, a non-array
/// `messages`) is refused so each caller's TS scanner decides instead.
fn scan_chats(buf: &[u8], mut on_progress: impl FnMut(u64, u64)) -> ScanResult<KernelOutput> {
let list_raw = find_chat_list(buf)?;
let base_offset = list_raw.as_ptr() as usize - buf.as_ptr() as usize;
let mut chats: Vec<TelegramScanChat> = Vec::new();

for_each_array_element(list_raw, base_offset, |element, end_offset| {
let element_offset = end_offset.saturating_sub(element.len());
let mut name: Option<String> = None;
let mut chat_type: Option<String> = None;
let mut id: Option<i64> = None;
let mut message_count: Option<u64> = None;

walk_top_level(element, |key, raw| {
let field = match key {
b"name" => "name",
b"type" => "type",
b"id" => "id",
b"messages" => {
let mut count = 0u64;
for_each_array_element(raw, 0, |_, _| {
count += 1;
Ok(())
})?;
message_count = Some(count);
return Ok(());
}
_ => return Ok(()),
};
let value: Value = serde_json::from_slice(raw)
.map_err(|error| scan_error(format!("invalid chat {field}: {error}"), 0))?;
match (field, value) {
("name", Value::String(value)) => name = Some(value),
("type", Value::String(value)) => chat_type = Some(value),
("id", Value::Number(value)) => {
id = Some(
value
.as_i64()
.ok_or_else(|| scan_error("unsupported chat id", 0))?,
)
}
_ => return Err(scan_error(format!("unsupported chat {field}"), 0)),
}
Ok(())
})
.map_err(|error| scan_error(error.message, element_offset))?;

let (Some(name), Some(chat_type), Some(id), Some(message_count)) =
(name, chat_type, id, message_count)
else {
return Err(scan_error(
"chat is missing name, type, id or messages",
element_offset,
));
};

chats.push(TelegramScanChat {
index: chats.len() as u32,
// `chat.name || `Chat ${chat.id}`` in both TS scanners.
name: if name.is_empty() {
format!("Chat {id}")
} else {
name
},
chat_type,
id,
message_count,
});
on_progress(end_offset as u64, chats.len() as u64);
Ok(())
})?;

let meta_json = serde_json::to_string(&TelegramScanMeta { scan: true, chats })
.map_err(|error| scan_error(format!("meta serialization failed: {error}"), 0))?;
Ok(KernelOutput {
meta_json,
members: Vec::new(),
messages: Vec::new(),
})
}

/// Both single-chat TS parsers reject an export without a string `name`, a
/// string `type`, a numeric `id` and a `messages` array with
/// `Invalid Telegram single-chat JSON export`; refusing them here lets the
/// wrapper fall back and produce that same error.
fn require_single_chat_header(spans: &ChatSpans<'_>) -> ScanResult<()> {
let valid = matches!(spans.header.get("name"), Some(Value::String(_)))
&& matches!(spans.header.get("type"), Some(Value::String(_)))
&& matches!(spans.header.get("id"), Some(Value::Number(number)) if number.as_f64().is_some())
&& spans.messages.is_some();
if valid {
Ok(())
} else {
Err(scan_error("invalid Telegram single-chat export header", 0))
}
}

pub fn parse_telegram(
buf: &[u8],
input: &KernelInput,
on_progress: impl FnMut(u64, u64),
) -> ScanResult<KernelOutput> {
let options = parse_options(input.options_json.as_deref())?;
if options.scan {
return scan_chats(buf, on_progress);
}
if options.single {
let spans = collect_chat_spans(buf)?;
require_single_chat_header(&spans)?;
return parse_chat(buf, &spans, None, SERVICE_SENDER_SINGLE, on_progress);
}

// Full export: chats.list[chatIndex]. Non-target chats are skimmed by the
// scanner and never materialized.
let list_raw = find_chat_list(buf)?;

let mut target: Option<&[u8]> = None;
let mut index: u32 = 0;
Expand Down Expand Up @@ -713,6 +847,74 @@ mod tests {
assert_eq!(out.members[0].account_name, "System");
}

#[test]
fn scan_mode_lists_every_chat_without_materializing_messages() {
let out = parse(FULL_EXPORT, Some(r#"{"scan":true}"#));
assert!(out.members.is_empty());
assert!(out.messages.is_empty());
let scanned = meta(&out);
assert_eq!(scanned["scan"], true);
assert_eq!(
scanned["chats"],
serde_json::json!([
{"index": 0, "name": "干扰 A", "type": "personal_chat", "id": 11, "messageCount": 1},
{"index": 1, "name": "目标群", "type": "private_supergroup", "id": -1001, "messageCount": 5},
{"index": 2, "name": "干扰 B", "type": "personal_chat", "id": 13, "messageCount": 0}
])
);
}

#[test]
fn scan_mode_names_chats_the_way_both_ts_scanners_do() {
// An empty name falls back to `Chat ${id}`; a chat the two TS scanners
// disagree on is refused so each caller's own scanner decides.
let doc = r#"{"about": "Telegram", "chats": {"list": [
{"name": "", "type": "personal_chat", "id": 77, "messages": []}
]}}"#;
assert_eq!(
meta(&parse(doc, Some(r#"{"scan":true}"#)))["chats"][0]["name"],
"Chat 77"
);

for refused in [
r#"{"chats": {"list": [{"type": "personal_chat", "id": 77, "messages": []}]}}"#,
r#"{"chats": {"list": [{"name": "x", "type": "personal_chat", "id": 77}]}}"#,
r#"{"chats": {"list": [{"name": "x", "type": "personal_chat", "id": "77", "messages": []}]}}"#,
] {
let input = KernelInput {
primary_path: "/tmp/telegram.json".to_string(),
options_json: Some(r#"{"scan":true}"#.to_string()),
};
assert!(
parse_telegram(refused.as_bytes(), &input, |_, _| {})
.map(|_| ())
.is_err(),
"{refused}"
);
}
}

#[test]
fn single_chat_mode_requires_the_header_the_ts_parsers_validate() {
for refused in [
r#"{"type": "personal_chat", "id": 1, "messages": []}"#,
r#"{"name": "x", "id": 1, "messages": []}"#,
r#"{"name": "x", "type": "personal_chat", "messages": []}"#,
r#"{"name": "x", "type": "personal_chat", "id": 1}"#,
] {
let input = KernelInput {
primary_path: "/tmp/telegram.json".to_string(),
options_json: Some(r#"{"single":true}"#.to_string()),
};
assert!(
parse_telegram(refused.as_bytes(), &input, |_, _| {})
.map(|_| ())
.is_err(),
"{refused}"
);
}
}

#[test]
fn reports_a_missing_chat_index_instead_of_parsing_the_wrong_chat() {
let input = KernelInput {
Expand Down
28 changes: 27 additions & 1 deletion packages/parser/src/formats/telegram-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
ParsedMessage,
} from '../types'
import { getFileSize, createProgress } from '../utils'
import { loadNativeParser } from '../native/loader'
import {
mapChatType,
extractPlatformId,
Expand Down Expand Up @@ -83,14 +84,39 @@ export const feature: FormatFeature = {

// ==================== 扫描函数 ====================

/** Shape of metaJson() from the Rust telegram kernel in scan mode. */
interface TelegramScanMetaJson {
scan: true
chats: TelegramChatInfo[]
}

/**
* 用 Rust 内核扫描聊天列表;内核不可用、被 CHATLAB_DISABLE_NATIVE_PERF=1 关闭
* 或拒绝这个文件时返回 null,由调用方回退到下面的 stream-json 实现。
*/
async function scanChatsWithNative(filePath: string): Promise<TelegramChatInfo[] | null> {
const native = loadNativeParser()
if (!native) return null
try {
const parser = new native.NativeParser('telegram', filePath, JSON.stringify({ scan: true }))
await parser.parse()
return (JSON.parse(parser.metaJson()) as TelegramScanMetaJson).chats
} catch {
return null
}
}

/**
* 快速扫描 Telegram 导出 JSON,提取聊天列表
* 使用 stream-json 流式处理,避免全量加载大文件到内存
* 优先走 Rust 内核;不可用时用 stream-json 流式处理,避免全量加载大文件到内存
*
* @param filePath 文件路径
* @returns 聊天列表信息
*/
export async function scanChats(filePath: string): Promise<TelegramChatInfo[]> {
const nativeChats = await scanChatsWithNative(filePath)
if (nativeChats) return nativeChats

const chats: TelegramChatInfo[] = []

return new Promise<TelegramChatInfo[]>((resolve, reject) => {
Expand Down
26 changes: 26 additions & 0 deletions packages/parser/src/native/telegram-native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { tmpdir } from 'node:os'
import { describe, it } from 'node:test'

import { detectFormat, parseFile } from '../index'
import { scanChats } from '../formats/telegram-native'
import type { ParsedMember, ParsedMessage, ParsedMeta } from '../types'
import { loadNativeParser } from './loader'

Expand Down Expand Up @@ -362,6 +363,31 @@ describe('telegram native parser parity', { skip: !nativeAvailable() && 'native
])
})

it('lists the same chats as the stream-json scanner', async () => {
// The chat picker runs before any import, so a wrong list is user-visible
// even when the parse itself is fine.
const withEdgeCases = multiChatExport()
withEdgeCases.chats.list.push({ name: '', type: 'private_group', id: 904, messages: [] })
await withFixture('telegram.json', JSON.stringify(withEdgeCases, null, 2), async (filePath) => {
try {
delete process.env[ENV_KEY]
const nativeChats = await scanChats(filePath)
process.env[ENV_KEY] = '1'
const tsChats = await scanChats(filePath)
assert.deepEqual(nativeChats, tsChats)
assert.deepEqual(nativeChats, [
{ index: 0, name: '干扰聊天 A', type: 'personal_chat', id: 901, messageCount: 1 },
{ index: 1, name: '目标群 Target', type: 'private_supergroup', id: -1001234567890, messageCount: 12 },
// An empty name falls back to `Chat ${id}` in both scanners.
{ index: 2, name: 'Chat 903', type: 'personal_chat', id: 903, messageCount: 0 },
{ index: 3, name: 'Chat 904', type: 'private_group', id: 904, messageCount: 0 },
])
} finally {
delete process.env[ENV_KEY]
}
})
})

it('falls back to the TS parser for values the kernel refuses to guess at', async () => {
// The TS parser passes a null photo width straight into the attachment;
// the kernel refuses non-numeric dimensions, so the wrapper must restart
Expand Down
12 changes: 11 additions & 1 deletion packages/web-runtime/src/import/browser-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from './chatlab-parser'
import {
parseWithWasm,
scanTelegramChatsWithWasm,
type BrowserImportLogEvent,
type BrowserWasmFormatId,
type BrowserWasmParserLoader,
Expand Down Expand Up @@ -93,13 +94,22 @@ export async function detectBrowserImportFormat(source: BrowserParseSource): Pro

export async function scanBrowserMultiChatSource(
source: BrowserParseSource,
options: Pick<ParseBrowserSourceOptions, 'checkCancelled' | 'yieldEvery'> = {}
options: Pick<ParseBrowserSourceOptions, 'checkCancelled' | 'yieldEvery' | 'wasmLoader' | 'onLog'> = {}
): Promise<TelegramChatInfo[]> {
const formatId = await detectBrowserImportFormat(source)
if (formatId !== PARSER_FORMAT_IDS.TELEGRAM_NATIVE) {
throw new WebRuntimeError('NOT_MULTI_CHAT_FORMAT', 'The selected file is not a supported multi-chat export')
}
options.checkCancelled?.()
// Rust WASM first: the TS scanner JSON.parses the whole export, which a
// several-hundred-MB file cannot afford just to list its chats.
const wasmChats = await scanTelegramChatsWithWasm(source, {
checkCancelled: options.checkCancelled,
onLog: options.onLog,
loader: options.wasmLoader,
})
if (wasmChats) return wasmChats

const content = await source.text()
options.checkCancelled?.()
return scanTelegramChatsJson(content, options)
Expand Down
Loading