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(transcription): detect the session language and transcribe PCM b…
…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>
  • Loading branch information
Zycheng114514 and claude committed Sep 8, 2026
commit 4a6de9c754bac63ebaa1c8c1cc5c2c5a0f6687bd
25 changes: 17 additions & 8 deletions apps/cli/src/transcribe/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import path from 'node:path'
import type { Command } from 'commander'
import { resolveTranscriptionLanguage } from '@openchatlab/core'
import {
SemanticIndexConfigStore,
SEMANTIC_INDEX_CONFIG_FILE,
Expand All @@ -21,6 +22,7 @@ import {
type TranscriptionProfileId,
type TranscriptionSkip,
} from '@openchatlab/node-runtime'
import { loadConfig } from '@openchatlab/config'
import { initRuntime } from '../runtime'
import { resolveCliLocalEmbeddingRuntimeConfig } from '../semantic-index/local-runtime'

Expand All @@ -33,8 +35,8 @@ const SKIP_REASON_TEXT: Record<TranscriptionSkip['reason'], string> = {

interface TranscribeCommandOptions {
session: string
model: string
language: string
model?: string
language?: string
attachment?: string[]
dryRun?: boolean
}
Expand All @@ -44,8 +46,11 @@ export function registerTranscribeCommand(program: Command): void {
.command('transcribe')
.description('Transcribe the voice attachments of a session with a local Whisper model')
.requiredOption('--session <id>', 'Session ID whose voice attachments should be transcribed')
.option(`--model <${TRANSCRIPTION_PROFILE_IDS.join('|')}>`, 'Whisper model size', 'base')
.option(`--language <${LANGUAGES.join('|')}>`, 'Spoken language; auto leaves it to the model', 'auto')
.option(`--model <${TRANSCRIPTION_PROFILE_IDS.join('|')}>`, 'Whisper model size (default: [transcription] model)')
.option(
`--language <${LANGUAGES.join('|')}>`,
'Spoken language; auto reads it off the session (default: [transcription] language)'
)
.option('--attachment <id...>', 'Restrict the run to these attachment IDs')
.option('--dry-run', 'List what would be transcribed and why the rest is skipped')
.action(async (options: TranscribeCommandOptions) => {
Expand All @@ -59,8 +64,10 @@ export function registerTranscribeCommand(program: Command): void {
}

async function runTranscribe(options: TranscribeCommandOptions): Promise<void> {
const profile = parseProfile(options.model)
const language = parseLanguage(options.language)
// Unset flags fall back to the shared setting the desktop app and CLI Web write.
const configured = loadConfig().transcription
const profile = parseProfile(options.model ?? configured.model)
const language = parseLanguage(options.language ?? configured.language)
const attachmentIds = parseAttachmentIds(options.attachment)

const { pathProvider, dbManager } = initRuntime()
Expand All @@ -69,6 +76,8 @@ async function runTranscribe(options: TranscribeCommandOptions): Promise<void> {
const db = dbManager.openWritable(options.session)
if (!db) throw new Error(`Session ${options.session} not found`)

// Resolve `auto` here so the summary line names the language Whisper is told.
const resolvedLanguage = resolveTranscriptionLanguage(db, language)
const plan = planSessionTranscription(db, attachmentIds)
printSkipped(plan.skipped)

Expand All @@ -95,14 +104,14 @@ async function runTranscribe(options: TranscribeCommandOptions): Promise<void> {
profile,
})

console.log(`Transcribing ${plan.pending.length} attachment(s) with ${transcriber.modelId} (${language})`)
console.log(`Transcribing ${plan.pending.length} attachment(s) with ${transcriber.modelId} (${resolvedLanguage})`)
try {
const result = await transcribeSessionAttachments({
db,
transcriber,
sessionId: options.session,
attachmentIds,
language,
language: resolvedLanguage,
// stderr: stdout stays a clean summary for agent/script consumers.
onProgress: (progress) =>
console.error(` [${progress.completed}/${progress.total}] #${progress.attachmentId} ${progress.status}`),
Expand Down
3 changes: 2 additions & 1 deletion packages/config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
export { loadConfig, getConfigPath, getConfigDir, writeConfigField } from './loader'
export { setConfigField, ConfigSetError } from './set-config-field'
export type { ConfigSetResult, ConfigSetErrorReason } from './set-config-field'
export { configSchema, desktopConfigSchema, DEFAULT_API_PORT } from './schema'
export { configSchema, desktopConfigSchema, transcriptionConfigSchema, DEFAULT_API_PORT } from './schema'
export type {
ChatLabConfig,
LlmConfig,
Expand All @@ -17,6 +17,7 @@ export type {
UiConfig,
CliConfig,
DesktopConfig,
TranscriptionConfig,
} from './schema'
export {
loadAuthProfiles,
Expand Down
19 changes: 19 additions & 0 deletions packages/config/src/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,22 @@ test('accepts supported desktop UI scales and rejects out-of-range or non-numeri
assert.throws(() => configSchema.parse({ desktop: { ui_scale: uiScale } }))
}
})

test('defaults transcription to the base model and automatic language', () => {
const config = configSchema.parse({})

assert.equal(config.transcription.model, 'base')
assert.equal(config.transcription.language, 'auto')
})

test('accepts supported transcription models and languages and rejects the rest', () => {
for (const model of ['tiny', 'base', 'small']) {
assert.equal(configSchema.parse({ transcription: { model } }).transcription.model, model)
}
for (const language of ['auto', 'zh', 'en']) {
assert.equal(configSchema.parse({ transcription: { language } }).transcription.language, language)
}

assert.throws(() => configSchema.parse({ transcription: { model: 'large' } }))
assert.throws(() => configSchema.parse({ transcription: { language: 'ja' } }))
})
9 changes: 9 additions & 0 deletions packages/config/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ export const desktopConfigSchema = z.object({
ui_scale: z.number().min(0.8).max(2).default(1),
})

export const transcriptionConfigSchema = z.object({
/** Whisper size; base is the quality/download compromise (~80 MB). */
model: z.enum(['tiny', 'base', 'small']).default('base'),
/** `auto` picks zh or en from the session's own messages. */
language: z.enum(['auto', 'zh', 'en']).default('auto'),
})

export const configSchema = z.object({
llm: llmConfigSchema.default({}),
data: dataConfigSchema.default({}),
Expand All @@ -59,6 +66,7 @@ export const configSchema = z.object({
ui: uiConfigSchema.default({}),
cli: cliConfigSchema.default({}),
desktop: desktopConfigSchema.default({}),
transcription: transcriptionConfigSchema.default({}),
})

export type ChatLabConfig = z.infer<typeof configSchema>
Expand All @@ -69,3 +77,4 @@ export type LocaleConfig = z.infer<typeof localeConfigSchema>
export type UiConfig = z.infer<typeof uiConfigSchema>
export type CliConfig = z.infer<typeof cliConfigSchema>
export type DesktopConfig = z.infer<typeof desktopConfigSchema>
export type TranscriptionConfig = z.infer<typeof transcriptionConfigSchema>
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ export {
applyTranscript,
formatVoiceTranscription,
listPendingAudioAttachments,
detectSessionLanguage,
resolveTranscriptionLanguage,
} from './query'

// 查询类型
Expand Down Expand Up @@ -278,6 +280,8 @@ export type {
ApplyTranscriptInput,
ApplyTranscriptResult,
PendingAudioAttachment,
ResolvedTranscriptionLanguage,
TranscriptionLanguage,
} from './query'

// 消息类型语义映射
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/query/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export type { MessageAttachment } from './message-attachments'
export { applyTranscript, formatVoiceTranscription, listPendingAudioAttachments } from './attachment-transcripts'
export type { ApplyTranscriptInput, ApplyTranscriptResult, PendingAudioAttachment } from './attachment-transcripts'

export { detectSessionLanguage, resolveTranscriptionLanguage } from './session-language'
export type { ResolvedTranscriptionLanguage, TranscriptionLanguage } from './session-language'

export {
isChatSessionDb,
getSessionMeta,
Expand Down
91 changes: 91 additions & 0 deletions packages/core/src/query/session-language.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Session language detection.
*
* Guards transcription quality: Whisper transcribes a Chinese voice message as
* garbled English when it is told the wrong language, and Transformers.js has no
* detection of its own, so this heuristic is the only thing standing between a
* Chinese chat and unusable transcripts.
*/

import assert from 'node:assert/strict'
import test from 'node:test'
import { CHAT_DB_SCHEMA } from '../schema'
import { SqliteTestAdapter } from './__tests__/sqlite-test-adapter'
import { openTestSqliteDatabase } from '../../../../tests/helpers/sqlite.mts'
import { detectSessionLanguage, resolveTranscriptionLanguage } from './session-language'

interface MessageSeed {
content: string
/** MessageType.TEXT unless the case is about non-text rows being ignored. */
type?: number
}

function createSessionDb(seeds: MessageSeed[]) {
const raw = openTestSqliteDatabase()
raw.exec(CHAT_DB_SCHEMA)
raw.prepare(`INSERT INTO member (platform_id, account_name) VALUES ('u1', 'Alice')`).run()
const insert = raw.prepare(`INSERT INTO message (sender_id, ts, type, content) VALUES (1, ?, ?, ?)`)
for (const [index, seed] of seeds.entries()) {
insert.run(index + 1, seed.type ?? 0, seed.content)
}
return { raw, db: new SqliteTestAdapter(raw) }
}

const cases: Array<{ name: string; seeds: MessageSeed[]; expected: 'zh' | 'en' }> = [
{
name: 'a Chinese chat that also uses English words',
seeds: [
{ content: '今天的 deadline 是几点?' },
{ content: '我下午把 PR 发出来,你 review 一下' },
{ content: '好的,谢谢' },
],
expected: 'zh',
},
{
name: 'an English-only chat',
seeds: [
{ content: 'are we still on for tomorrow?' },
{ content: 'yes, same time as last week' },
{ content: 'great, see you then' },
],
expected: 'en',
},
{ name: 'a session with no messages', seeds: [], expected: 'en' },
{
name: 'a session whose only text is punctuation and numbers',
seeds: [{ content: '123' }, { content: '???' }],
expected: 'en',
},
{
name: 'a session whose Chinese lives in non-text messages only',
seeds: [
{ content: 'ok sounds good' },
// Voice/system rows are not evidence of what the members type.
{ content: '这是一条系统提示消息,内容全是中文', type: 80 },
],
expected: 'en',
},
]

for (const { name, seeds, expected } of cases) {
test(`detectSessionLanguage returns ${expected} for ${name}`, () => {
const { raw, db } = createSessionDb(seeds)
try {
assert.equal(detectSessionLanguage(db), expected)
} finally {
raw.close()
}
})
}

test('resolveTranscriptionLanguage only detects for auto', () => {
const { raw, db } = createSessionDb([{ content: '我们晚点再聊' }])
try {
assert.equal(resolveTranscriptionLanguage(db, 'auto'), 'zh')
// An explicit choice wins even when it contradicts the messages.
assert.equal(resolveTranscriptionLanguage(db, 'en'), 'en')
assert.equal(resolveTranscriptionLanguage(db, 'zh'), 'zh')
} finally {
raw.close()
}
})
82 changes: 82 additions & 0 deletions packages/core/src/query/session-language.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Which language a session is spoken in, used to pick Whisper's language token.
*
* Transformers.js 4.2.0 has no language detection: with no language token it
* warns and transcribes as English, so a Chinese voice message comes back as
* nonsense English. The caller has to decide before the audio reaches the model,
* and the only signal available without listening to it is the text already in
* the chat — a good one, because a chat log is written in the language its
* participants speak.
*
* Deliberately a character-ratio count and not word segmentation: the answer is
* a two-way choice on hundreds of messages, and the two scripts do not overlap.
*/

import { MessageType } from '@openchatlab/shared-types'
import type { DatabaseAdapter } from '../interfaces'
import { hasTable } from './filters'

/** What the user asked for; `auto` is resolved against the session's messages. */
export type TranscriptionLanguage = 'auto' | 'zh' | 'en'

/** What Whisper is actually told. */
export type ResolvedTranscriptionLanguage = 'zh' | 'en'

/** Messages sampled for the decision; recent ones describe how the chat reads today. */
const SAMPLE_SIZE = 300

/**
* Chinese, Japanese and Korean text runs far fewer characters than the Latin
* transcription of the same speech, so a fifth of the characters being CJK
* already means the chat is not an English one.
*/
const CJK_RATIO_THRESHOLD = 0.2

const CJK_CHAR_REGEX = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/gu
const LATIN_LETTER_REGEX = /\p{Script=Latin}/gu

/**
* Guess the session's spoken language from its text messages.
*
* Falls back to `en` for a session with no text at all — that is what Whisper
* would have done anyway, and it keeps an empty session from failing.
*/
export function detectSessionLanguage(db: DatabaseAdapter): ResolvedTranscriptionLanguage {
if (!hasTable(db, 'message')) return 'en'

const rows = db
.prepare(
`SELECT content
FROM message
WHERE type = ${MessageType.TEXT} AND content IS NOT NULL AND TRIM(content) <> ''
ORDER BY ts DESC
LIMIT ${SAMPLE_SIZE}`
)
.all() as unknown as Array<{ content: string }>

let cjk = 0
let latin = 0
for (const row of rows) {
cjk += countMatches(row.content, CJK_CHAR_REGEX)
latin += countMatches(row.content, LATIN_LETTER_REGEX)
}

const total = cjk + latin
if (total === 0) return 'en'
return cjk / total >= CJK_RATIO_THRESHOLD ? 'zh' : 'en'
}

/** Resolve `auto` once, at the entry point, so Whisper never sees it. */
export function resolveTranscriptionLanguage(
db: DatabaseAdapter,
language: TranscriptionLanguage
): ResolvedTranscriptionLanguage {
return language === 'auto' ? detectSessionLanguage(db) : language
}

function countMatches(text: string, regex: RegExp): number {
regex.lastIndex = 0
let count = 0
while (regex.exec(text) !== null) count++
return count
}
6 changes: 6 additions & 0 deletions packages/node-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,8 +499,11 @@ export {
planSessionTranscription,
registerAudioDecoder,
resolveTranscriptionModelCacheDir,
transcribeAttachmentPcm,
TranscribeAttachmentPcmError,
transcribeSessionAttachments,
DEFAULT_TRANSCRIPTION_PROFILE_ID,
MAX_TRANSCRIPTION_PCM_SAMPLES,
TRANSCRIPTION_MODEL_CACHE_DIR_ENV,
TRANSCRIPTION_PROFILE_IDS,
TRANSCRIPTION_PROFILES,
Expand All @@ -509,6 +512,9 @@ export {
export type {
AudioDecoder,
CreateTranscriberOptions,
ResolvedTranscriptionLanguage,
TranscribeAttachmentPcmErrorCode,
TranscribeAttachmentPcmResult,
Transcriber,
TranscriptionCandidate,
TranscriptionLanguage,
Expand Down
Loading