diff --git a/.github/docker/chatlab-cli.Dockerfile b/.github/docker/chatlab-cli.Dockerfile index 3b3d9411d..0b7fd6568 100644 --- a/.github/docker/chatlab-cli.Dockerfile +++ b/.github/docker/chatlab-cli.Dockerfile @@ -9,10 +9,13 @@ ENV CHATLAB_NLP_DICT_DIR=/opt/chatlab/nlp ADD --checksum=sha256:139519822fe8ab9e10d9d07e68ea0451045380aedaf54ecc51e2a28c6b42a13f --chmod=0644 \ https://chatlab.fun/assets/nlp/zh-CN.dict /opt/chatlab/nlp/zh-CN.dict +COPY chatlab-cli-${CHATLAB_VERSION}.tgz /tmp/chatlab-cli.tgz + RUN test -n "$CHATLAB_VERSION" \ && apt-get update \ && apt-get install --yes --no-install-recommends g++ make python3 \ - && npm install --global --omit=dev "chatlab-cli@${CHATLAB_VERSION}" \ + && npm install --global --omit=dev /tmp/chatlab-cli.tgz \ + && rm /tmp/chatlab-cli.tgz \ && CHATLAB_SKIP_UPDATE_CHECK=1 clb runtime install local-embedding \ && npm cache clean --force \ && apt-get purge --yes --auto-remove g++ make python3 \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f1ef61655..88bbb3f52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -547,15 +547,30 @@ jobs: name: ChatLab-cli-build path: apps/cli - - name: Copy README for npm publish - run: cp README.md apps/cli/README.md + - name: Prepare package documentation + run: node apps/cli/scripts/sync-release-docs.mjs + + - name: Pack CLI release + working-directory: apps/cli + run: npm pack --ignore-scripts + + - name: Upload CLI package + uses: actions/upload-artifact@v6 + with: + name: ChatLab-cli-package + path: apps/cli/chatlab-cli-${{ needs.prepare_release.outputs.version }}.tgz + if-no-files-found: error + retention-days: 1 - name: Publish to npm working-directory: apps/cli - run: npm publish --ignore-scripts --provenance --tag ${{ needs.prepare_release.outputs.npm_tag }} + env: + CHATLAB_VERSION: ${{ needs.prepare_release.outputs.version }} + NPM_TAG: ${{ needs.prepare_release.outputs.npm_tag }} + run: npm publish "./chatlab-cli-${CHATLAB_VERSION}.tgz" --ignore-scripts --provenance --tag "$NPM_TAG" publish-docker: - # The image installs the published npm package and does not consume desktop release artifacts. + # Install the exact tarball sent to npm without waiting for registry propagation. needs: [prepare_release, publish-cli] if: needs.prepare_release.outputs.is_prerelease == 'false' runs-on: ubuntu-latest @@ -568,6 +583,12 @@ jobs: - name: Checkout image definition uses: actions/checkout@v5 + - name: Download CLI package + uses: actions/download-artifact@v8 + with: + name: ChatLab-cli-package + path: .github/docker + - name: Set up QEMU uses: docker/setup-qemu-action@v4 @@ -597,7 +618,7 @@ jobs: - name: Build and push uses: docker/build-push-action@v7 with: - context: . + context: .github/docker file: .github/docker/chatlab-cli.Dockerfile push: true platforms: linux/amd64,linux/arm64 diff --git a/AGENTS.md b/AGENTS.md index e8a8c01fa..a1690745a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ - 文档:开始开发前,请先查看公开开发指南 `docs/cn/contributing/development.md`。如果工作区存在 `.docs/`,再查看 `./.docs/README.md` 并阅读与当前需求相关的文档;`.docs/` 是可选的个人或团队私有开发上下文,可用于维护任务、决策、AI 协作记忆和临时规划,公开文档与公开 PR 不应依赖 `.docs/` 才能理解。 - 目标:用最小改动快速交付正确、可维护、可回归的业务结果 -- 每次完成任务后,对产生修改的文件进行类型检查、lint检查和format格式化,指定修改的文件路径去执行,确保代码质量 +- 每次完成任务后,按修改文件的类型和改动范围执行适用的类型检查、lint 和 format;lint 和 format 优先指定修改文件,类型检查按相关项目执行。具体命令见“命令与验证”。 - 在执行检查时,如果有其他与本次修改无关的报错,也需要一并修复 ## 实现原则 @@ -17,7 +17,7 @@ - 处理外部 review、bug 报告或架构建议时,必须先核对代码事实,再决定接受或反驳;不要因为 reviewer 表达确定就直接改代码 - 判断问题是否成立时,至少阅读相关函数、调用方、被调用方、相邻测试和现有文档;不能只看 diff 或单行评论 - 如果涉及依赖、框架、OpenAI/LLM SDK、数据库、打包发布等外部行为,应优先查官方文档、源码或类型定义,不凭记忆判断 -- 结论必须包含证据:涉及的文件/函数、当前行为、风险、建议修复方式,以及已运行或应运行的验证 +- 审查结论必须说明涉及的文件/函数、当前行为和判断依据;问题成立时,补充用户影响、建议修复方式,以及已运行或应运行的验证。 - 评估异步、并发、缓存、性能、外部依赖或资源异常问题时,必须证明完整因果链和现实可达性;不能只凭 Promise 理论上可永久 pending、竞态在抽象状态中存在或极端环境可能失败,就判定为严重 Bug - 严重度应同时考虑触发概率、用户影响和可恢复性,高影响后果不能单独替代可达性证据;如果前置异常与后续有害操作共享同一故障域,必须解释为什么前者失败或阻塞时后者仍能成功 - 多轮 AI review 应按根因和关键假设聚合;若新评论只是把同一未证实假设传播到另一个下游,且没有新的独立代码或运行时证据,默认降低优先级并停止无限追深 @@ -44,7 +44,7 @@ - 必须新增:修复真实的行为 Bug,或修改用户数据、数据库迁移、导入解析、去重、权限认证、配置/API Key 迁移、公开 API、跨端共享 service 等高风险逻辑时,应优先补充能在修改前失败的回归测试。已有测试完整覆盖时不重复新增。 - 通常不新增:文案、样式、类型、日志、注释、文档、常量、简单 getter、文件列表和无行为变化的重构。只运行相关类型检查、lint、format、构建或现有测试。 - 避免机械测试:不要通过扫描源码字符串、匹配完整 SQL、断言私有函数调用顺序、锁定日志文案或枚举每个常量来证明功能正确。Mock 调用次数只用于 adapter 的参数传递或权限边界,不用于锁定内部实现。 -- 提高信息密度:同一规则的多个输入优先使用表驱动测试或通用断言;下层已覆盖算法时,上层只验证调用链和返回契约。修改相关模块时,应搜索并合并或删除重复、过期和低价值测试。 +- 提高信息密度:同一规则的多个输入优先使用表驱动测试或通用断言;下层已覆盖算法时,上层只验证调用链和返回契约。修改相关模块时,检查受影响的测试;发现与本次变更直接相关的重复、过期或低价值用例时,再合并或删除。 - 测试分层:纯函数和参数规范化使用单元测试;SQL、迁移、导入写库、Fastify route 和跨包 service 优先使用轻量真实依赖或临时数据库;真实 Electron、浏览器、LLM 和网络只用于少量显式启用的 Smoke/E2E。 - 测试位置:模块单元测试放在被测文件附近;跨模块、集成、E2E 和测试工具放在根目录 `tests/` 或对应 app/package 的既有测试目录。 - Review 判断:缺少测试本身通常不是严重 Bug。Reviewer 必须指出具体未覆盖的用户风险;只有数据安全、迁移、权限、公开契约等高风险行为缺少必要验证时,才应阻塞当前 PR。 @@ -53,8 +53,8 @@ - 类型检查:Node/CLI/Electron 主进程相关改动运行 `pnpm run type-check:node`;前端/Vue 相关改动运行 `pnpm run type-check:web`;跨端或发布前改动运行 `pnpm run type-check:all` - Lint:优先对修改文件运行 `pnpm exec eslint `;需要全量修复时再运行 `pnpm lint` -- Format:优先对修改文件运行 `pnpm exec prettier --write `;大范围格式化才运行 `pnpm format` -- 单元/集成测试:日常默认运行 `pnpm test` 或 `pnpm run test:unit`;优先运行相关测试文件时用 `pnpm test -- path/to/file.test.ts` +- Format:优先对修改文件运行 `pnpm exec prettier --write `;大范围格式化才运行 `pnpm format`。`docs/**/*.md` 被 Prettier 默认忽略,格式化这些文件时使用 `pnpm exec prettier --write --ignore-path .gitignore `。 +- 单元/集成测试:按改动范围优先运行相关测试,使用 `pnpm test -- path/to/file.test.ts`;需要全量回归时运行 `pnpm test` 或 `pnpm run test:unit`。相关测试通过后,仅在新增改动、失败或具体未解决风险需要时,扩大或重复测试。 - 文档:修改公开文档或 VitePress 配置后运行 `pnpm docs:build`;只改 `.docs/` 私有任务文档时不需要构建公开文档站 - E2E/Smoke:`pnpm run test:e2e:launcher`、`pnpm run test:e2e:smoke` 和真实 LLM/真实 Electron/真实网络测试只在相关功能需要时运行,不加入默认 `pnpm test` - 最后检查:提交前运行 `git diff --check`,确认没有空白错误 @@ -87,7 +87,7 @@ ## 安全与发布 - 不得提交真实 API Key、token、用户聊天数据库、个人数据目录、日志、截图或包含隐私的导出文件 -- 修改依赖、lockfile、构建产物、发布脚本、版本号、changelog、npm publish、release 流程时,必须先获得明确确认 +- 修改依赖、lockfile、构建产物、发布脚本、版本号、changelog、npm publish、release 流程时,必须先获得明确确认。用户已在当前对话明确授权相应操作时,无需重复确认。 - 涉及 API Key、auth profile、配置迁移、数据目录迁移和数据库迁移时,必须优先保护已有用户数据,并提供回滚或失败中断策略 ## 提交规范 diff --git a/apps/cli/package.json b/apps/cli/package.json index deedf1a3f..e32576adc 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "chatlab-cli", - "version": "0.37.0", + "version": "0.37.7", "description": "ChatLab CLI & Service - Chat history analysis tool", "type": "module", "bin": { diff --git a/apps/cli/src/ai/agent-stream-runner.ts b/apps/cli/src/ai/agent-stream-runner.ts index 5fad67e0a..ca8b26b96 100644 --- a/apps/cli/src/ai/agent-stream-runner.ts +++ b/apps/cli/src/ai/agent-stream-runner.ts @@ -227,7 +227,7 @@ export function createCliRunAgentStream( timeFilter: params.timeFilter, maxMessagesLimit: params.maxMessagesLimit, }), - { maxToolResultTokens } + { maxToolResultTokens, locale } ) : [] diff --git a/apps/cli/src/ai/agent.test.ts b/apps/cli/src/ai/agent.test.ts new file mode 100644 index 000000000..380c5b030 --- /dev/null +++ b/apps/cli/src/ai/agent.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { tmpdir } from 'node:os' +import test from 'node:test' +import { AIChatManager } from '../../../../packages/node-runtime/src/ai/chats' +import type { AgentStreamChunk } from '@openchatlab/node-runtime' + +test('cancelling a session agent must not persist a compression summary', async (t) => { + let controller: AbortController + let compressionCalls = 0 + let compressionSignal: AbortSignal | undefined + await t.mock.module('../../../../packages/node-runtime/src/ai/compression/adapter-factory', { + namedExports: { + createCompressionLlmAdapter: (options: { onCompressing?: () => void }) => ({ + contextWindow: 1000, + compress: async (_prompt: string, _maxTokens: number, signal?: AbortSignal) => { + options.onCompressing?.() + compressionCalls++ + compressionSignal = signal + controller.abort() + return 'Summary returned after cancellation' + }, + }), + }, + }) + const { runServerAgent } = await import('./agent') + + for (const alreadyAborted of [true, false]) { + await t.test(alreadyAborted ? 'cancelled before compression' : 'cancelled during compression', async () => { + controller = new AbortController() + compressionCalls = 0 + compressionSignal = undefined + const dir = mkdtempSync(join(tmpdir(), 'chatlab-session-cancel-')) + const nativeBinding = process.env.CHATLAB_TEST_SQLITE_NATIVE_BINDING + const manager = new AIChatManager(dir, nativeBinding ? { nativeBinding } : undefined) + try { + const chat = manager.createAIChat('session-1', 'Cancellation', 'general_cn') + for (let index = 0; index < 6; index++) { + const message = manager.addMessage(chat.id, index % 2 ? 'assistant' : 'user', 'history '.repeat(200)) + manager.executeAiSQL(`UPDATE ai_message SET timestamp = ${1700000000 + index} WHERE id = '${message.id}'`) + } + const activeBefore = manager.getAIChat(chat.id)?.activeMessageId + const events: AgentStreamChunk[] = [] + if (alreadyAborted) controller.abort() + + await runServerAgent({ + userMessage: 'Continue', + aiChatId: chat.id, + aiChatManager: manager, + llmConfig: { provider: 'openai-compatible', model: 'test-model', apiKey: 'test-placeholder' }, + abortSignal: controller.signal, + onEvent: (event) => events.push(event), + }) + + assert.equal(manager.getLatestSummary(chat.id), null) + assert.equal(manager.getAIChat(chat.id)?.activeMessageId, activeBefore) + assert.equal(compressionCalls, alreadyAborted ? 0 : 1) + if (!alreadyAborted) assert.equal(compressionSignal, controller.signal) + assert.equal( + events.some((event) => event.type === 'compression_done'), + false + ) + assert.deepEqual( + events + .filter((event) => event.type === 'status') + .map((event) => event.status?.phase) + .filter((phase) => phase === 'aborted' || phase === 'completed' || phase === 'error'), + ['aborted'] + ) + assert.equal(events.filter((event) => event.type === 'done').length, 1) + } finally { + manager.close() + assert.equal(dirname(dir), tmpdir()) + assert.ok(basename(dir).startsWith('chatlab-session-cancel-')) + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) + } + }) + } +}) diff --git a/apps/cli/src/ai/agent.ts b/apps/cli/src/ai/agent.ts index c713213c4..7f59eeb4d 100644 --- a/apps/cli/src/ai/agent.ts +++ b/apps/cli/src/ai/agent.ts @@ -133,7 +133,8 @@ export async function runServerAgent(options: RunAgentOptions): Promise { systemPrompt, llmAdapter, aiChatManager, - aiLogger ?? undefined + aiLogger ?? undefined, + { signal: abortSignal } ) if (compressionResult.compressed) { onEvent({ @@ -252,6 +253,8 @@ export async function runServerAgent(options: RunAgentOptions): Promise { maxToolRounds: DEFAULT_MAX_TOOL_ROUNDS, abortSignal, providerSessionId: aiChatId, + locale, + logger: aiLogger ?? undefined, steerMessage, thinkingLevel: thinkingLevel as import('@openchatlab/core').ThinkingLevel | undefined, onConvertToLlm: (filteredMessages) => { @@ -267,12 +270,17 @@ export async function runServerAgent(options: RunAgentOptions): Promise { }, }) - if (result.error) { + if (result.error && !abortSignal?.aborted) { const friendlyMessage = formatAIError(result.error) - onEvent({ type: 'error', error: { name: 'AgentError', message: friendlyMessage } }) + onEvent({ + type: 'error', + error: { name: result.stopReason === 'length' ? 'OutputLimitError' : 'AgentError', message: friendlyMessage }, + }) } - handler.emitStatus('completed', cachedMessages, { force: true }) + handler.emitStatus(abortSignal?.aborted ? 'aborted' : result.error ? 'error' : 'completed', cachedMessages, { + force: true, + }) onEvent({ type: 'done', isFinished: true, usage: result.usage }) } catch (error) { const friendlyMessage = formatAIError(error) diff --git a/apps/cli/src/ai/chat-command.test.ts b/apps/cli/src/ai/chat-command.test.ts index e79ebbb19..0f5a33f41 100644 --- a/apps/cli/src/ai/chat-command.test.ts +++ b/apps/cli/src/ai/chat-command.test.ts @@ -498,6 +498,99 @@ describe('runChatTurn', () => { }) describe('runChatCommand', () => { + it('keeps the first truncated turn in the same interactive conversation for continuation', async () => { + const stdout = new MemoryWritable() + const stderr = new MemoryWritable() + const stdin = new PromptDrivenReadable(['first', 'continue', 'exit']) + const aiChatManager = createAIChatManager() + const chatIds: string[] = [] + stdout.onChunk = (text) => { + if (text.includes('chatlab> ')) stdin.pushNext() + } + await runChatCommand( + { sessionId: 'session-1' }, + { + dbManager: createDbManager(['session-1']), + pathProvider: {} as never, + aiChatManager, + stdout, + stderr, + stdin, + createRunAgentStream: () => async (params, onEvent) => { + chatIds.push(params.aiChatId) + onEvent({ type: 'content', content: params.userMessage === 'first' ? 'Partial answer' : 'Continued answer' }) + if (params.userMessage === 'first') { + onEvent({ type: 'error', error: { name: 'OutputLimitError', message: 'Output limit reached' } }) + } + onEvent({ type: 'done', isFinished: true }) + }, + } + ) + assert.deepEqual(chatIds, ['ai_chat_1', 'ai_chat_1']) + assert.match(stderr.text(), /Output limit reached/) + assert.deepEqual( + (aiChatManager as unknown as { __messages: Array<{ content: string }> }).__messages.map( + (message) => message.content + ), + ['first', 'Partial answer', 'continue', 'Continued answer'] + ) + }) + + it('outputs a persisted partial JSON result but still fails a one-shot truncated command', async () => { + const stdout = new MemoryWritable() + const aiChatManager = createAIChatManager() + await assert.rejects( + () => + runChatCommand( + { sessionId: 'session-1', question: 'hello', json: true }, + { + dbManager: createDbManager(['session-1']), + pathProvider: {} as never, + aiChatManager, + stdout, + createRunAgentStream: () => async (_params, onEvent) => { + onEvent({ + type: 'plan', + plan: { + type: 'plan', + version: 1, + status: 'executing', + plan: { + version: 1, + title: 'Review the evidence', + route: 'planned_execution', + intent: 'summary', + steps: [{ goal: 'Summarize the evidence', suggestedTools: [], evidenceNeeded: 'Recent messages' }], + successCriteria: ['Provide a complete summary'], + }, + }, + }) + onEvent({ type: 'content', content: 'Partial answer' }) + onEvent({ type: 'error', error: { name: 'OutputLimitError', message: 'Output limit reached' } }) + onEvent({ + type: 'done', + isFinished: true, + usage: { + promptTokens: 10, + completionTokens: 10, + totalTokens: 20, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + }) + }, + } + ), + { name: 'OutputLimitError' } + ) + const result = JSON.parse(stdout.text()) + assert.equal(result.aiChatId, 'ai_chat_1') + assert.equal(result.answer, 'Partial answer') + assert.equal(result.error.name, 'OutputLimitError') + assert.equal(result.contentBlocks.find((block: ContentBlock) => block.type === 'plan')?.status, 'skipped') + assert.equal((aiChatManager as unknown as { __messages: unknown[] }).__messages.length, 2) + }) + it('keeps interactive mode alive after a single failed turn', async () => { const stdout = new MemoryWritable() const stderr = new MemoryWritable() diff --git a/apps/cli/src/ai/chat-command.ts b/apps/cli/src/ai/chat-command.ts index 537a6d5f6..341901093 100644 --- a/apps/cli/src/ai/chat-command.ts +++ b/apps/cli/src/ai/chat-command.ts @@ -10,6 +10,7 @@ import type { TokenUsageData, } from '@openchatlab/node-runtime' import type { ChartPayload, PathProvider } from '@openchatlab/core' +import { extractToolResultText, truncateToolResultText } from '@openchatlab/core' import { getDefaultGeneralAssistantId } from '@openchatlab/shared-types' import { createCliRunAgentStream } from './agent-stream-runner' @@ -45,6 +46,8 @@ export interface ChatTurnResult { aiChatId: string question: string answer: string + /** Present for a persisted but incomplete turn, never for a successful completion. */ + error?: { name: string; message: string } events?: AgentStreamChunk[] contentBlocks?: ContentBlock[] usage: { @@ -161,9 +164,10 @@ export async function runChatTurn( const startedAt = Date.now() let answer = '' let tokenUsage: TokenUsageData | null = null - let streamError: Error | null = null + const streamState: { error: Error | null } = { error: null } const events: AgentStreamChunk[] = [] const contentBlocks: ContentBlock[] = [] + const toolBlocks = new Map>() let hasReplayContentBlocks = false const runAgentStream = (deps.createRunAgentStream ?? createCliRunAgentStream)(deps.dbManager, deps.aiChatManager) @@ -269,7 +273,7 @@ export async function runChatTurn( } if (chunk.type === 'error') { removePlanDraftBlocks() - streamError = createAgentStreamError(chunk.error) + streamState.error = createAgentStreamError(chunk.error) return } if (chunk.type === 'plan_delta' && chunk.planDelta) { @@ -292,6 +296,31 @@ export async function runChatTurn( appendChartBlocks(extractChartPayloads(chunk.toolResult)) return } + if (chunk.type === 'tool_start' && chunk.toolName && chunk.toolName !== 'render_chart' && chunk.toolCallId) { + const block: Extract = { + type: 'tool', + tool: { + name: chunk.toolName, + displayName: chunk.toolName, + status: 'running', + toolCallId: chunk.toolCallId, + params: chunk.toolParams, + }, + } + toolBlocks.set(chunk.toolCallId, block) + contentBlocks.push(block) + hasReplayContentBlocks = true + return + } + if (chunk.type === 'tool_result' && chunk.toolCallId) { + const block = toolBlocks.get(chunk.toolCallId) + if (block) { + block.tool.status = chunk.toolIsError ? 'error' : 'done' + block.tool.isError = chunk.toolIsError === true + block.tool.result = truncateToolResultText(extractToolResultText(chunk.toolResult)) + } + return + } if (chunk.type === 'content' && chunk.content) { answer += chunk.content appendTextBlock(chunk.content) @@ -302,7 +331,7 @@ export async function runChatTurn( for (let index = contentBlocks.length - 1; index >= 0; index--) { const block = contentBlocks[index] if (block.type === 'plan') { - block.status = 'done' + block.status = streamState.error ? 'skipped' : 'done' break } } @@ -311,7 +340,15 @@ export async function runChatTurn( new AbortController().signal ) - if (streamError) throw streamError + const streamError = streamState.error + if (streamError && streamError.name !== 'OutputLimitError') throw streamError + if (streamError) { + contentBlocks.push({ + type: 'error', + error: { name: streamError.name, message: streamError.message, stack: null }, + }) + hasReplayContentBlocks = true + } if (!options.json && options.stream !== false && answer && !answer.endsWith('\n')) { write(stdout, '\n') @@ -332,6 +369,7 @@ export async function runChatTurn( aiChatId: target.aiChatId, question: options.question, answer, + ...(streamError ? { error: { name: streamError.name, message: streamError.message } } : {}), ...(options.includeEvents ? { events } : {}), ...(hasReplayContentBlocks ? { contentBlocks } : {}), usage: { @@ -352,6 +390,7 @@ export async function runChatCommand(options: ChatCommandOptions, deps: ChatComm } else if (options.stream === false) { write(stdout, `${result.answer}\n`) } + if (result.error) throw createAgentStreamError(result.error) return } @@ -368,6 +407,7 @@ export async function runChatCommand(options: ChatCommandOptions, deps: ChatComm try { const result = await runChatTurn({ ...options, aiChatId, question, json: false, stream: true }, deps) aiChatId = result.aiChatId + if (result.error) write(stderr, `${result.error.message}\n`) } catch (error) { write(stderr, `${error instanceof Error ? error.message : String(error)}\n`) } diff --git a/apps/cli/src/ai/tool-adapter.ts b/apps/cli/src/ai/tool-adapter.ts index 683febfff..56bcb714f 100644 --- a/apps/cli/src/ai/tool-adapter.ts +++ b/apps/cli/src/ai/tool-adapter.ts @@ -12,7 +12,12 @@ import type { RawMessage, ToolTimeRange, } from '@openchatlab/tools' -import { CoreDataProvider, executeToolForAgent, toAgentToolParameters } from '@openchatlab/tools' +import { + CoreDataProvider, + executeToolForAgent, + getLocalizedToolMetadata, + toAgentToolParameters, +} from '@openchatlab/tools' import type { DatabaseAdapter } from '@openchatlab/core' import { applyPreprocessingPipeline, @@ -56,6 +61,8 @@ export interface ServerToolContext { export interface AdaptToolsOptions { maxToolResultTokens?: number + /** Request locale; decides whether the LLM sees Chinese or English tool descriptions. */ + locale?: string } export function adaptToolsForAgent( @@ -66,13 +73,14 @@ export function adaptToolsForAgent( const tokenBudget = options?.maxToolResultTokens ?? DEFAULT_MAX_TOOL_RESULT_TOKENS const chartSchemaGateState = createChartSchemaGateState() - return tools.map((tool) => - wrapWithChartSchemaGate( + return tools.map((tool) => { + const localized = getLocalizedToolMetadata(tool, options?.locale) + return wrapWithChartSchemaGate( { name: tool.name, label: tool.name, - description: tool.description, - parameters: toAgentToolParameters(tool.inputSchema) as any, + description: localized.description, + parameters: toAgentToolParameters(localized.inputSchema) as any, executionMode: tool.executionMode, async execute(_toolCallId: string, params: unknown, signal, onUpdate) { const ctx = getContext() @@ -122,5 +130,5 @@ export function adaptToolsForAgent( }, chartSchemaGateState ) - ) + }) } diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index 4534c967d..73c867d80 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -12,7 +12,7 @@ import { hasPendingElectronDataWarning, verifyCliDataPath, } from '@openchatlab/node-runtime' -import { startMcpServer } from 'chatlab-mcp' +import { resolveMcpLocale, startMcpServer } from 'chatlab-mcp' import { getVersion } from './version' import { resolveCliPath } from './paths' import { assertCliDataDirCompatible } from './runtime-compat' @@ -43,5 +43,5 @@ export function initMcpRuntime(): DatabaseManager { export async function startCliMcpServer(): Promise { const dbManager = initMcpRuntime() - await startMcpServer({ version: getVersion(), dbManager }) + await startMcpServer({ version: getVersion(), dbManager, locale: resolveMcpLocale(loadConfig()) }) } diff --git a/apps/cli/src/sync/routes.test.ts b/apps/cli/src/sync/routes.test.ts index 86bc3539a..513343030 100644 --- a/apps/cli/src/sync/routes.test.ts +++ b/apps/cli/src/sync/routes.test.ts @@ -2,17 +2,17 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import test from 'node:test' -import Fastify from 'fastify' +import test, { type TestContext } from 'node:test' import { DataSourceManager } from '@openchatlab/sync' import { registerAutomationRoutes, type AutomationRouteContext } from '@openchatlab/http-routes' +import { createApiServer } from '@openchatlab/http-routes/server' function makeTempDir(): string { const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-sync-routes-')) } -test('DELETE import session deletes imported local session when deleteData=true', async () => { +async function createFixture(t: TestContext) { const settingsDir = makeTempDir() const dsManager = new DataSourceManager(settingsDir) const ds = dsManager.add({ @@ -24,7 +24,11 @@ test('DELETE import session deletes imported local session when deleteData=true' dsManager.updateSession(ds.id, session.id, { targetSessionId: 'local-1' }) const deletedSessionIds: string[] = [] - const app = Fastify() + const app = createApiServer() + t.after(async () => { + await app.close() + fs.rmSync(settingsDir, { recursive: true, force: true }) + }) const pullEngine: AutomationRouteContext['pullEngine'] = { triggerPull: async () => ({ success: true, newMessageCount: 0 }), triggerPullAll: async () => ({ success: true, newMessageCount: 0 }), @@ -41,15 +45,58 @@ test('DELETE import session deletes imported local session when deleteData=true' }, }) await app.ready() + return { app, dsManager, ds, session, deletedSessionIds, file: path.join(settingsDir, 'data-sources.json') } +} +test('DELETE import session deletes imported local session when deleteData=true', async (t) => { + const { app, dsManager, ds, session, deletedSessionIds } = await createFixture(t) const resp = await app.inject({ method: 'DELETE', url: `/_web/automation/data-sources/${ds.id}/sessions/${session.id}?deleteData=true`, }) - await app.close() - assert.equal(resp.statusCode, 200) assert.deepEqual(deletedSessionIds, ['local-1']) assert.equal(dsManager.get(ds.id)?.sessions.length, 0) }) + +test('corrupt data source config returns an error instead of an empty list or a successful replacement', async (t) => { + const { app, file } = await createFixture(t) + const broken = '[{"token":"private-test-token"} INVALID]' + fs.writeFileSync(file, broken) + const list = await app.inject({ method: 'GET', url: '/_web/automation/data-sources' }) + assert.equal(list.statusCode, 500) + assert.equal(list.json().success, false) + assert.ok(!list.body.includes('private-test-token')) + const add = await app.inject({ + method: 'POST', + url: '/_web/automation/data-sources', + payload: { baseUrl: 'https://another.test', token: '', intervalMinutes: 60 }, + }) + assert.equal(add.statusCode, 500) + assert.equal(fs.readFileSync(file, 'utf-8'), broken) +}) + +test('failed config persistence cannot report success or delete an imported local chat', async (t) => { + const { app, file, ds, session, deletedSessionIds } = await createFixture(t) + const original = fs.readFileSync(file, 'utf-8') + const renameSync = fs.renameSync + t.mock.method(fs, 'renameSync', (from: fs.PathLike, to: fs.PathLike) => { + if (to === file) throw Object.assign(new Error('Replacement denied'), { code: 'EPERM' }) + return renameSync(from, to) + }) + const update = await app.inject({ + method: 'PATCH', + url: `/_web/automation/data-sources/${ds.id}`, + payload: { name: 'Must not be saved' }, + }) + assert.equal(update.statusCode, 500) + assert.equal(update.json().success, false) + const remove = await app.inject({ + method: 'DELETE', + url: `/_web/automation/data-sources/${ds.id}/sessions/${session.id}?deleteData=true`, + }) + assert.equal(remove.statusCode, 500) + assert.deepEqual(deletedSessionIds, []) + assert.equal(fs.readFileSync(file, 'utf-8'), original) +}) diff --git a/apps/desktop/main/ai/agent-stream-runner.ts b/apps/desktop/main/ai/agent-stream-runner.ts index 7cf11424b..8896e898e 100644 --- a/apps/desktop/main/ai/agent-stream-runner.ts +++ b/apps/desktop/main/ai/agent-stream-runner.ts @@ -212,7 +212,8 @@ export function createElectronRunAgentStream( }) }), getAIChatManager(), - compressionLogger + compressionLogger, + { signal: abortSignal } ) if (compressionResult.compressed && compressionResult.summaryContent) { @@ -352,9 +353,13 @@ export function createElectronRunAgentStream( skillCtx ) + let lastUsage: AgentStreamChunk['usage'] try { await agent.executeStream(userMessage, (chunk: AgentStreamChunk) => { if (abortSignal.aborted) return + if (chunk.type === 'status' && chunk.status) { + lastUsage = chunk.status.totalUsage + } onEvent(chunk as SharedAgentStreamChunk) }) } catch (error) { @@ -367,6 +372,7 @@ export function createElectronRunAgentStream( if (!serializedError.url && activeAIConfig.baseUrl) serializedError.url = activeAIConfig.baseUrl aiLogger.error('AgentStream', `Agent execution error: ${requestId}`, serializedError) onEvent({ type: 'error', error: serializedError, isFinished: true }) + onEvent({ type: 'done', isFinished: true, usage: lastUsage }) } } } diff --git a/apps/desktop/main/ai/agent/index.ts b/apps/desktop/main/ai/agent/index.ts index 8d948d963..0feb88386 100644 --- a/apps/desktop/main/ai/agent/index.ts +++ b/apps/desktop/main/ai/agent/index.ts @@ -232,6 +232,8 @@ export class Agent { maxToolRounds, abortSignal: this.abortSignal, providerSessionId: this.context.aiChatId, + locale: this.locale, + logger: aiLogger, steerMessage: answerWithoutToolsPrompt, thinkingLevel: this.config.thinkingLevel, streamFn: errorCapturingStreamFn, @@ -299,6 +301,7 @@ export class Agent { requestBody?: string } } + if (result.stopReason === 'length') agentError.name = 'OutputLimitError' const lastMsg = [...result.finalMessages].reverse().find((m) => m.role === 'assistant') as | (PiAssistantMessage & { provider?: string; model?: string; api?: string }) | undefined @@ -319,7 +322,8 @@ export class Agent { const apiPath = typeof apiType === 'string' ? pathMap[apiType] : undefined ctx.url = apiPath ? baseUrl.replace(/\/+$/, '') + apiPath : baseUrl } - if (lastRequestPayload) { + // Output truncation is explained by completion metadata, not the private request body. + if (lastRequestPayload && result.stopReason !== 'length') { try { ctx.requestBody = JSON.stringify(lastRequestPayload, null, 2) } catch { diff --git a/apps/desktop/main/ai/tools/index.ts b/apps/desktop/main/ai/tools/index.ts index 1efd18b6b..753cc9d14 100644 --- a/apps/desktop/main/ai/tools/index.ts +++ b/apps/desktop/main/ai/tools/index.ts @@ -8,7 +8,6 @@ import type { AgentTool } from '@openchatlab/node-runtime' import type { ToolContext, TruncationStrategy } from './types' import { isAnalysisToolAllowed } from './tool-filter' -import { t as i18nT } from '../../i18n' import { applyPreprocessingPipeline, type PreprocessableMessage } from '@openchatlab/node-runtime' import { aiLogger } from '../logger' import { getSkillConfig } from '../skills/manager' @@ -51,36 +50,6 @@ const TRUNCATION_STRATEGY_MAP = new Map( // 导出类型 export * from './types' -/** - * 翻译 AgentTool 的描述(工具级 + 参数级) - * - * i18n 键命名规则: - * - 工具描述:ai.tools.{toolName}.desc - * - 参数描述:ai.tools.{toolName}.params.{paramName} - */ -function translateTool(tool: AgentTool): AgentTool { - const name = tool.name - - const descKey = `ai.tools.${name}.desc` - const translatedDesc = i18nT(descKey) - - const params = tool.parameters as Record - if (params?.properties && typeof params.properties === 'object') { - for (const [paramName, param] of Object.entries(params.properties as Record>)) { - const paramKey = `ai.tools.${name}.params.${paramName}` - const translated = i18nT(paramKey) - if (translated !== paramKey) { - param.description = translated - } - } - } - - return { - ...tool, - description: translatedDesc !== descKey ? translatedDesc : tool.description, - } -} - /** * 预处理包装层 * 拦截工具的 execute 结果:如果 details 中包含 rawMessages, @@ -147,7 +116,6 @@ export async function getAllTools(context: ToolContext, allowedTools?: string[]) const chartSchemaGateState = createChartSchemaGateState() return [...coreTools, ...analysisTools, ...evidenceTools, ...semanticTools] - .map(translateTool) .map((t) => wrapWithChartSchemaGate(t, chartSchemaGateState)) .map((t) => wrapWithPreprocessing(t, context)) } diff --git a/apps/desktop/main/ai/tools/shared-tool-adapter.ts b/apps/desktop/main/ai/tools/shared-tool-adapter.ts index b12defc92..b87db306d 100644 --- a/apps/desktop/main/ai/tools/shared-tool-adapter.ts +++ b/apps/desktop/main/ai/tools/shared-tool-adapter.ts @@ -7,6 +7,7 @@ import { executeToolForAgent, + getLocalizedToolMetadata, toAgentToolParameters, type ToolDefinition, type ToolExecutionContext, @@ -58,13 +59,14 @@ export function adaptSharedTool(tool: ToolDefinition): ToolRegistryEntry { category: tool.category ?? 'core', truncationStrategy: tool.truncationStrategy, factory(context: ToolContext): AgentTool { + // Tool and parameter descriptions follow the chat locale: Chinese locales keep the definition text, + // every other locale gets the English metadata table. + const localized = getLocalizedToolMetadata(tool, context.locale) return { name: tool.name, label: tool.name, - // 保留英文原始描述作为 fallback:translateTool 会在 i18n key 命中时覆盖为译文, - // 缺 key 时回退到此英文描述,避免把裸 i18n key 当作工具描述传给 LLM。 - description: tool.description, - parameters: toAgentToolParameters(tool.inputSchema) as any, + description: localized.description, + parameters: toAgentToolParameters(localized.inputSchema) as any, executionMode: tool.executionMode, async execute(_toolCallId: string, params: unknown, signal, onUpdate) { return executeToolForAgent( diff --git a/apps/desktop/main/ipc/window.ts b/apps/desktop/main/ipc/window.ts index 119a9642c..1bc680495 100644 --- a/apps/desktop/main/ipc/window.ts +++ b/apps/desktop/main/ipc/window.ts @@ -4,7 +4,8 @@ import { ipcMain, app, dialog, clipboard, shell, nativeTheme } from 'electron' import * as fs from 'fs/promises' -import { loadConfig, setConfigField } from '@openchatlab/config' +import { desktopConfigSchema, loadConfig, setConfigField } from '@openchatlab/config' +import { appLogger } from '@openchatlab/node-runtime' import type { DesktopCloseBehavior } from '@openchatlab/shared-types' import type { IpcContext } from './types' import { simulateUpdateDialog, manualCheckForUpdates } from '../update/manager' @@ -224,6 +225,26 @@ export function registerWindowHandlers(ctx: IpcContext): void { } }) + // ==================== Desktop UI scale ==================== + ipcMain.handle('app:getDesktopUiScale', () => loadConfig().desktop.ui_scale) + + ipcMain.handle('app:setDesktopUiScale', (_, scale: number) => { + const parsed = desktopConfigSchema.shape.ui_scale.safeParse(scale) + if (typeof scale !== 'number' || !parsed.success) { + return { success: false, error: 'Unsupported desktop UI scale' } + } + + try { + setConfigField('desktop.ui_scale', String(parsed.data)) + win.webContents.setZoomFactor(parsed.data) + appLogger.info('window', `Desktop UI scale changed to ${parsed.data}`) + return { success: true } + } catch (error) { + appLogger.error('window', 'Failed to change desktop UI scale', error) + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }) + // ==================== Windows 关闭行为 ==================== ipcMain.handle('app:getDesktopCloseBehavior', () => { return loadConfig().desktop.close_behavior diff --git a/apps/desktop/main/window/main-window.ts b/apps/desktop/main/window/main-window.ts index 887bdd491..18e98b45e 100644 --- a/apps/desktop/main/window/main-window.ts +++ b/apps/desktop/main/window/main-window.ts @@ -28,6 +28,7 @@ export async function createMainWindow(paths: MainWindowPaths): Promise { + // Reapply the preference after navigation, overriding Chromium's per-origin zoom cache. + win.webContents.setZoomFactor(loadConfig().desktop.ui_scale) setTimeout(() => { currentMainWindow?.webContents.send('app-started') }, 500) diff --git a/apps/desktop/preload/apis/core.ts b/apps/desktop/preload/apis/core.ts index 757081b86..7f0809a9f 100644 --- a/apps/desktop/preload/apis/core.ts +++ b/apps/desktop/preload/apis/core.ts @@ -123,5 +123,9 @@ export const extendedApi = { setDesktopCloseBehavior: (behavior: DesktopCloseBehavior): Promise<{ success: boolean; error?: string }> => { return ipcRenderer.invoke('app:setDesktopCloseBehavior', behavior) }, + getDesktopUiScale: (): Promise => ipcRenderer.invoke('app:getDesktopUiScale'), + setDesktopUiScale: (scale: number): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('app:setDesktopUiScale', scale) + }, }, } diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index c968f576f..93a34e8b1 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -194,6 +194,8 @@ interface Api { setOpenAtLogin: (enabled: boolean) => Promise<{ success: boolean; error?: string }> getDesktopCloseBehavior: () => Promise setDesktopCloseBehavior: (behavior: DesktopCloseBehavior) => Promise<{ success: boolean; error?: string }> + getDesktopUiScale: () => Promise + setDesktopUiScale: (scale: number) => Promise<{ success: boolean; error?: string }> } } diff --git a/changelogs/cn.json b/changelogs/cn.json index 2d7066428..b9731bfca 100644 --- a/changelogs/cn.json +++ b/changelogs/cn.json @@ -1,4 +1,238 @@ [ + { + "version": "0.37.7", + "date": "2026-10-30", + "summary": "修复 Discord 私聊识别与重复导入匹配,完善 MCP 只读查询和安全校验。", + "changes": [ + { + "type": "fix", + "items": [ + { + "text": "修复 Discord 私聊被识别为群聊的问题,并保持同一频道重复、增量导入时的会话匹配" + }, + { + "text": "【MCP】为查询工具补充只读标记,便于 AI 客户端正确识别工具权限", + "contributors": ["moesnow"] + }, + { + "text": "【MCP】查询不再自动迁移数据库或修改数据目录兼容标记,旧数据库需先通过桌面端或 CLI 升级", + "contributors": ["moesnow"] + }, + { + "text": "【MCP】加强会话路径和 SQL 校验,防止读取数据目录之外的文件或执行非查询操作", + "contributors": ["moesnow"] + } + ] + } + ] + }, + { + "version": "0.37.6", + "date": "2026-10-22", + "summary": "完善导入格式失败引导,支持将记忆设为会话默认页面,并修复 MCP 数组参数类型问题。", + "changes": [ + { + "type": "fix", + "items": [ + { + "text": "无法识别聊天记录格式时提供说明和 AI 转换指南,支持单文件与批量导入,并适配多语言" + }, + { + "text": "支持将记忆设为进入聊天会话后的默认页面", + "contributors": ["KokerZhou"] + }, + { + "text": "【MCP】修复字符串、数字和布尔值数组参数被错误识别为字符串、导致工具调用失败的问题", + "contributors": ["moesnow"] + } + ] + } + ] + }, + { + "version": "0.37.5", + "date": "2026-09-28", + "summary": "统一右侧面板交互,新增桌面界面缩放,并修复会话加载、数据源配置与代理模型下载问题。", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "统一聊天记录、联系人详情和聊天话题的右侧面板,支持页头展开收起、拖动调宽,并保留收起前的浏览位置" + }, + { + "text": "【桌面端】新增界面缩放设置" + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "修复部分 Windows 用户升级后因重复写入数据目录兼容标记而无法加载会话列表的问题" + }, + { + "text": "修复远程数据源配置读取或保存失败时可能覆盖原配置、误报成功的问题,保留原文件并明确提示错误" + }, + { + "text": "修复使用代理时本地语义模型下载后无法写入缓存、导致模型加载失败的问题", + "contributors": ["huaqian0226"] + } + ] + } + ] + }, + { + "version": "0.37.4", + "date": "2026-09-23", + "summary": "优化 AI 工具结果的上下文控制,修复 Qwen 话题总结问题,并完善会话加载失败提示。", + "changes": [ + { + "type": "fix", + "items": [ + { + "text": "修复 AI 多轮或并行工具调用的结果累积后可能超出模型上下文限制的问题,按请求裁剪工具结果并保留完整对话记录", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "修复部分 Qwen 模型在话题总结时因默认开启思考而无法生成结果的问题" + }, + { + "text": "话题总结达到模型输出上限时显示明确提示,避免无效重试造成额外消耗" + }, + { + "text": "会话列表加载失败时完整展示错误详情,并在应用日志中保留完整错误堆栈,便于排查升级后的权限等问题" + }, + { + "text": "补齐 AI 词频与回复速度分析工具的英文时间参数说明" + }, + { + "text": "【CLI】Docker 镜像改用与 npm 发布相同的安装包,避免 npm 同步延迟导致镜像发布失败" + } + ] + } + ] + }, + { + "version": "0.37.3", + "date": "2026-09-22", + "summary": "修复 AI 时间筛选、Qwen 模型兼容与续聊问题,完善多语言支持和消息上下文查询。", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "完善 AI 工具说明的中英文适配,统一桌面端、CLI Web 与 MCP 的语言处理", + "contributors": ["Zycheng114514"] + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "修复 AI 词频与回复速度分析忽略所选时间范围的问题,明确提问时间、页面筛选与默认范围的优先级", + "contributors": ["liuyy2"] + }, + { + "text": "修复部分 Qwen 模型在 AI 对话、话题总结时因消息角色不兼容而报错的问题" + }, + { + "text": "修复补录历史消息后上下文可能错位的问题,并优化消息上下文检索性能", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "补齐语言偏好卡片中缺失的日语标签", + "contributors": ["Zycheng114514"] + }, + { + "text": "【CLI】修复 AI 续聊遗漏已完成工具调用结果的问题,保留工具结果与错误状态供后续对话使用", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "【CLI】修复 Docker 镜像发布可能因 npm 新版本尚未同步而失败的问题" + } + ] + } + ] + }, + { + "version": "0.37.2", + "date": "2026-09-18", + "summary": "修复 AI 回复截断与停止后的续聊问题,完善表情识别、技能入口和下载安全校验。", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "词频与语言偏好分析支持识别抖音表情代码,避免表情名称混入高频词", + "contributors": ["babybitter"] + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "修复 AI 回复截断后部分入口丢失已生成内容或用量的问题,支持保存后继续对话" + }, + { + "text": "修复手动停止 AI 后已生成内容未保存、继续提问可能丢失上下文的问题", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "修复停止 AI 请求后上下文压缩仍继续执行的问题,避免额外模型调用和摘要写入", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "修复下载接口的文件名校验,防止文件被写入下载目录之外", + "contributors": ["ttsdj"] + } + ] + }, + { + "type": "refactor", + "items": [ + { + "text": "统一外部 Agent 的导入、转换和分析技能入口,按用户语言回复,并兼容已发布版本的旧导入安装命令" + } + ] + } + ] + }, + { + "version": "0.37.1", + "date": "2026-09-04", + "summary": "新增贡献者署名,优化 Docker 启动,并修复 AI 工具循环、文本统计和关系图资源占用。", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "语言偏好、词频和口头禅分析支持读取语音转写内容", + "contributors": ["babybitter"] + }, + { + "text": "【CLI】Docker 镜像内置默认中文分词词典,首次启动无需下载", + "contributors": ["KokerZhou"] + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "修复 AI 工具调用达到轮次上限后可能重复生成最终回答、工具错误被记为成功及上下文重复写入的问题", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "修复系统通知和分享占位污染词频、语言偏好与口头禅统计,并保留用户表情内容", + "contributors": ["babybitter"] + } + ] + } + ] + }, { "version": "0.37.0", "date": "2026-08-28", @@ -11,19 +245,37 @@ "新增长期记忆,支持管理偏好、关于我、联系人和群聊记忆,并按问题自动调用相关内容", "长期记忆支持关联来源消息,AI 形成的结论可返回原对话核验", "统一全局与单会话 AI 的对话列表、输入框和 @ 选择交互", - "聊天记录查看器支持拖拽调整宽度并记住布局", + { + "text": "聊天记录查看器支持拖拽调整宽度并记住布局", + "contributors": ["KokerZhou"] + }, "分析页将 SQL 实验室和调试工具统一收纳到“更多”", - "【CLI】支持通过 Unix Domain Socket 启动 Web 服务" + { + "text": "【CLI】支持通过 Unix Domain Socket 启动 Web 服务", + "contributors": ["KokerZhou"] + } ] }, { "type": "fix", "items": [ "修复 WeFlow 与缺少稳定消息 ID 的聊天记录在增量导入、合并时可能过度去重的问题,避免真实消息丢失", - "修复 AI 回复失败后错误状态、会话列表和后续提问可能异常的问题", - "修复 AI 对话保存和编辑时可能留下不完整轮次的问题,并仅允许编辑最新一轮提问", - "修复长对话压缩可能丢失消息边界和记忆来源关联的问题", - "修复同名 AI 服务配置可能读取错误凭据的问题", + { + "text": "修复 AI 回复失败后错误状态、会话列表和后续提问可能异常的问题", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "修复 AI 对话保存和编辑时可能留下不完整轮次的问题,并仅允许编辑最新一轮提问", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "修复长对话压缩可能丢失消息边界和记忆来源关联的问题", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "修复同名 AI 服务配置可能读取错误凭据的问题", + "contributors": ["Sanssssssssssssssss"] + }, "修复切换或返回分析页面时可能重复加载或继续显示导入前旧数据的问题", "完善跨对话分析的时间范围、联系人解析、扫描预算和截断提示" ] @@ -50,7 +302,12 @@ }, { "type": "fix", - "items": ["导入文件夹改为独立按钮,避免与选择文件互相抢入口"] + "items": [ + { + "text": "导入文件夹改为独立按钮,避免与选择文件互相抢入口", + "contributors": ["kongjiyu"] + } + ] }, { "type": "perf", @@ -140,7 +397,10 @@ "items": [ "修复 QQ 自动导入在 UID/UIN 变化、缺失 owner 或合并会话中的匹配与成员映射问题,避免重复会话和成员身份错乱", "修复合并导入因平台消息 ID 冲突或私聊身份不足导致的消息误删,并保留有效 owner 与会话身份信息", - "修复动态关键词排行请求、快速切换与多色模式问题,避免结果过期或未知类型导致页面崩溃", + { + "text": "修复动态关键词排行请求、快速切换与多色模式问题,避免结果过期或未知类型导致页面崩溃", + "contributors": ["kelaocai"] + }, "统一洞察加载过渡,并恢复年度活动日历配色与关系视图切换控件" ] }, @@ -161,7 +421,10 @@ "QQ Shuakami 与 QQ Chat Exporter 导入接入 Rust 原生解析,提升大型导出文件的处理稳定性", "多文件导入新增安全并发调度,避免同一会话并发写入,并提供逐文件进度和失败详情", "新增隐私安全的匿名产品统计,仅上报白名单内的运行环境与功能使用事件", - "【Web CLI】本地语义索引运行组件改为首次使用时按需安装,Docker 镜像继续预装所需组件" + { + "text": "【Web CLI】本地语义索引运行组件改为首次使用时按需安装,Docker 镜像继续预装所需组件", + "contributors": ["kelaocai"] + } ] }, { @@ -240,19 +503,35 @@ "type": "fix", "items": [ "修复纯 Web 端并发访问 OPFS、概览筛选竞争、词频统计和关系分析过滤问题", - "前端图标改为本地打包,避免运行时依赖外部图标服务", + { + "text": "前端图标改为本地打包,避免运行时依赖外部图标服务", + "contributors": ["KokerZhou"] + }, "统一 ChatLab JSONL 自动识别与格式校验规则,并拒绝无法解析的 owner 信息", "【桌面端】阻止迁移过程删除当前数据目录,并拒绝新旧数据目录相互嵌套或指向同一路径", - "【CLI】Docker 部署持久化用户数据与系统状态,并默认仅向 localhost 暴露端口" + { + "text": "【CLI】Docker 部署持久化用户数据与系统状态,并默认仅向 localhost 暴露端口", + "contributors": ["KokerZhou"] + } ] }, { "type": "docs", - "items": ["新增 Docker 部署指南,并完善 AI 分析价值与本地隐私说明"] + "items": [ + { + "text": "新增 Docker 部署指南,并完善 AI 分析价值与本地隐私说明", + "contributors": ["KokerZhou"] + } + ] }, { "type": "ci", - "items": ["发布 amd64 与 arm64 架构的 ChatLab CLI Docker 镜像"] + "items": [ + { + "text": "发布 amd64 与 arm64 架构的 ChatLab CLI Docker 镜像", + "contributors": ["KokerZhou"] + } + ] } ] }, @@ -263,7 +542,12 @@ "changes": [ { "type": "feat", - "items": ["【桌面端】新增应用锁,支持密码、立即锁定、启动时解锁和闲置超时自动锁定"] + "items": [ + { + "text": "【桌面端】新增应用锁,支持密码、立即锁定、启动时解锁和闲置超时自动锁定", + "contributors": ["xyz-225648"] + } + ] }, { "type": "fix", @@ -271,7 +555,10 @@ "数据目录迁移后保留旧数据并提示人工确认清理,避免迁移或重试过程中误删源文件", "大体积 ChatLab JSONL 改为分批流式处理,避免导入卡死并保留合并导入中的成员头像", "统一桌面端与 CLI Web 的多文件批量导入流程,支持串行进度、取消和结果汇总", - "【桌面端】应用锁增加失败次数限制、冷却时间和启动状态保护,避免刷新或重启绕过锁定" + { + "text": "【桌面端】应用锁增加失败次数限制、冷却时间和启动状态保护,避免刷新或重启绕过锁定", + "contributors": ["xyz-225648"] + } ] } ] @@ -334,7 +621,10 @@ "items": [ "统一各端 Push 导入的校验、去重、成员推断和 dry-run 结果,避免预览与实际写入不一致", "补导历史消息后,聊天记录按时间与消息 ID 稳定排序,避免旧消息出现在末尾", - "修复 QCE / QQ Chat Exporter 私聊被误判为群聊并产生“0”成员的问题" + { + "text": "修复 QCE / QQ Chat Exporter 私聊被误判为群聊并产生“0”成员的问题", + "contributors": ["shuakami"] + } ] } ] @@ -359,7 +649,10 @@ "items": [ "修复 ChatLab JSON 导出不可直接重新导入的问题,并保留成员、owner 和别名元数据", "增强自动增量导入的去重、混合消息身份识别和 ChatLab 重复导入区分能力", - "WhatsApp TXT 解析支持带括号时间戳和点号 AM/PM 标记" + { + "text": "WhatsApp TXT 解析支持带括号时间戳和点号 AM/PM 标记", + "contributors": ["hh7418695"] + } ] }, { @@ -415,7 +708,10 @@ "修复 SQL 实验室导出 CSV 失败的问题", "导入 JSONL 等流式成员数据时保留头像,支持增量导入新头像", "打开 AI 日志改为共享路由处理,本机 CLI Web 和桌面端行为保持一致", - "概览身份卡跟随当前选择的时间范围刷新" + { + "text": "概览身份卡跟随当前选择的时间范围刷新", + "contributors": ["qikairo7"] + } ] }, { @@ -584,8 +880,14 @@ "type": "fix", "items": [ "修复多关键词消息搜索功能", - "修复词云词典加载 UX 回归问题", - "修复排行榜标签与 emoji 清洗", + { + "text": "修复词云词典加载 UX 回归问题", + "contributors": ["qikairo7"] + }, + { + "text": "修复排行榜标签与 emoji 清洗", + "contributors": ["qikairo7"] + }, "修复 v8 数据库迁移缺少国际化文本", "【CLI】修复工具适配器中会话上下文传递与预处理缺失" ] @@ -612,8 +914,14 @@ { "type": "fix", "items": [ - "修复高频词统计中含有平台回复消息占位符的问题", - "修复词云文本中包含媒体占位符的问题", + { + "text": "修复高频词统计中含有平台回复消息占位符的问题", + "contributors": ["qikairo7"] + }, + { + "text": "修复词云文本中包含媒体占位符的问题", + "contributors": ["qikairo7"] + }, "修复 Jieba 自定义词典变更后语言偏好缓存与词频缓存未失效的问题", "修复时效性分析缓存未按日失效的问题", "修复时间轴面板默认滚动位置及会话跳转功能失效的问题", @@ -719,7 +1027,10 @@ "修复 AI 助手消息未填满聊天区域宽度", "优化分析计划生成区块的展示样式", "修复思考深度选择跨重启不持久的问题", - "修复 JSONL 时间戳规范化导致增量导入异常", + { + "text": "修复 JSONL 时间戳规范化导致增量导入异常", + "contributors": ["cnYui"] + }, "【桌面端】修复 execute_sql 工具未注册到桌面端工具注册表" ] }, @@ -776,8 +1087,14 @@ { "type": "feat", "items": [ - "新增斜杠触发的 AI 图表运行时,支持在对话中生成并渲染 ECharts 图表", - "优化 AI 图表结果展示体验,补充图表渲染状态和多类型图表适配" + { + "text": "新增斜杠触发的 AI 图表运行时,支持在对话中生成并渲染 ECharts 图表", + "contributors": ["CEQ151"] + }, + { + "text": "优化 AI 图表结果展示体验,补充图表渲染状态和多类型图表适配", + "contributors": ["CEQ151"] + } ] }, { @@ -787,7 +1104,10 @@ "修复预设问题标签和侧边栏元素层叠顺序异常", "【CLI Web】修复开发后端生命周期清理不完整的问题", "【桌面端】修复自动技能未注册图表工具,导致图表技能无法完整执行的问题", - "【桌面端】修复 Windows 标题栏覆盖层切换主题后缓存未刷新和显示不平滑的问题" + { + "text": "【桌面端】修复 Windows 标题栏覆盖层切换主题后缓存未刷新和显示不平滑的问题", + "contributors": ["CEQ151"] + } ] }, { @@ -860,7 +1180,10 @@ "items": [ "修复数据目录迁移与数据库迁移流程中的多处边界问题,避免旧库字段缺失或路径切换导致读取失败", "修复数据目录迁移提示在旧数据不存在或目录变更后仍错误显示的问题", - "修复增量导入后侧边栏消息数量未刷新的问题", + { + "text": "修复增量导入后侧边栏消息数量未刷新的问题", + "contributors": ["CEQ151"] + }, "修复侧边栏折叠状态仅存于 sessionStorage 导致刷新后丢失的问题", "修复 AI 配置编辑时已保存密钥未正确启用拉取模型和验证按钮的问题", "修复 AI 共享 SSE 流式响应的稳定性问题", @@ -925,7 +1248,10 @@ { "type": "feat", "items": [ - "新增消息派生(Fork)功能,可从任意 AI 回复处创建独立分支会话", + { + "text": "新增消息派生(Fork)功能,可从任意 AI 回复处创建独立分支会话", + "contributors": ["CEQ151"] + }, "状态栏新增推理强度选择器,支持为每个推理模型单独记忆并切换思维等级", "推理等级控制新增 default/auto 选项,扩展对 Kimi、Doubao、Gemini 等更多模型家族的覆盖", "消息分析视图拆分为「类型分析」和「时间分析」两个标签页,增加丰富统计洞察卡片", @@ -1144,7 +1470,10 @@ "items": [ "修复 Web 模式下会话索引、跨域代理、Demo 保护和运行时异常等问题", "修复 Web 模式下应用版本显示为空的问题", - "修复 AI Agent 证据检索、Web 端事件流和错误格式化等问题", + { + "text": "修复 AI Agent 证据检索、Web 端事件流和错误格式化等问题", + "contributors": ["CEQ151"] + }, "修复同步展示与导入逻辑不一致的问题", "修复 CLI 开发模式下 ESM 模块解析问题", "修复 CLI 安装后可能找不到既有桌面端数据的问题", @@ -1255,7 +1584,10 @@ { "type": "feat", "items": [ - "订阅会话时支持按类型筛选与选择", + { + "text": "订阅会话时支持按类型筛选与选择", + "contributors": ["xunchahaha"] + }, "支持远程会话分页发现与按需加载更多", "支持为数据源配置每次拉取的消息条数" ] @@ -1457,7 +1789,10 @@ "type": "fix", "items": [ "修复 WhatsApp 时间解析正则与行匹配正则宽严不一致问题", - "修复 WhatsApp 12 小时制时间与 NNBSP 字符解析兼容性问题" + { + "text": "修复 WhatsApp 12 小时制时间与 NNBSP 字符解析兼容性问题", + "contributors": ["kongjiyu"] + } ] }, { @@ -1532,7 +1867,10 @@ "优化 AI 对话样式与整体交互体验", "支持 FTS5 全文搜索并新增快速搜索工具", "精简部分工具搜索参数,降低 token 开销", - "新增 Electron 应用 E2E 测试框架,支持端口管理与实例隔离" + { + "text": "新增 Electron 应用 E2E 测试框架,支持端口管理与实例隔离", + "contributors": ["l17728"] + } ] }, { @@ -1545,7 +1883,12 @@ }, { "type": "test", - "items": ["补充可复用的 E2E 启动器冒烟测试能力"] + "items": [ + { + "text": "补充可复用的 E2E 启动器冒烟测试能力", + "contributors": ["l17728"] + } + ] }, { "type": "docs", @@ -1607,7 +1950,10 @@ { "type": "fix", "items": [ - "修复消息去重误判,并统一空字符串去重语义", + { + "text": "修复消息去重误判,并统一空字符串去重语义", + "contributors": ["lnexin"] + }, "修复 AI 会话链路与前端 type-check 错误", "增加默认 assistant 兜底逻辑,修复异常场景", "修复每日消息趋势不展示的问题" @@ -1674,7 +2020,10 @@ "items": [ "新增聊天记录预处理管道", "新增预处理设置界面与配置管理能力", - "Agent 支持基于会话的上下文时间线与运行状态展示", + { + "text": "Agent 支持基于会话的上下文时间线与运行状态展示", + "contributors": ["n-WN"] + }, "新增 AI 调试模式并增强日志可观测性" ] }, @@ -1771,7 +2120,10 @@ "type": "fix", "items": [ "修复增量更新后会话索引生成范围不准确的问题(fix #79)", - "修复迁移与会话扫描时误处理非聊天 SQLite 文件的问题" + { + "text": "修复迁移与会话扫描时误处理非聊天 SQLite 文件的问题", + "contributors": ["n-WN"] + } ] }, { @@ -1801,7 +2153,12 @@ }, { "type": "fix", - "items": ["修复 LINE 聊天记录解析问题"] + "items": [ + { + "text": "修复 LINE 聊天记录解析问题", + "contributors": ["haotien91"] + } + ] }, { "type": "docs", @@ -1817,9 +2174,15 @@ { "type": "feat", "items": [ - "设置内新增数据目录位置配置", + { + "text": "设置内新增数据目录位置配置", + "contributors": ["xunchahaha"] + }, "数据存储目录迁移逻辑优化", - "目录切换新增确认弹窗", + { + "text": "目录切换新增确认弹窗", + "contributors": ["xunchahaha"] + }, "解析逻辑优化(WeFlow / Echotrace)" ] }, @@ -1829,12 +2192,21 @@ "修复 Windows 自定义筛选时消息量过大导致崩溃的问题", "修复第三方中转 API 调用 tool_call 导致对话异常结束的问题", "修复部分 WhatsApp 聊天记录无法正确识别的问题", - "修复管理页面表头层级显示问题" + { + "text": "修复管理页面表头层级显示问题", + "contributors": ["xunchahaha"] + } ] }, { "type": "refactor", - "items": ["重构 session 查询模块", "完善迁移日志输出"] + "items": [ + "重构 session 查询模块", + { + "text": "完善迁移日志输出", + "contributors": ["xunchahaha"] + } + ] } ] }, @@ -2010,7 +2382,10 @@ "支持增量导入", "支持批量导入", "样式优化", - "Windows 端支持原生窗口控制并实现主题同步 (#31)" + { + "text": "Windows 端支持原生窗口控制并实现主题同步 (#31)", + "contributors": ["JiQingzhe2004"] + } ] }, { @@ -2128,7 +2503,10 @@ "支持配置代理(resolve #7)", "优化AI模型界面交互", "添加用户配置API教程", - "1、新增GLM两个免费调用模型 2、新增豆包服务商和对应的最新模型", + { + "text": "1、新增GLM两个免费调用模型 2、新增豆包服务商和对应的最新模型", + "contributors": ["JiQingzhe2004"] + }, "AI回复不输出think内容" ] } diff --git a/changelogs/cn.md b/changelogs/cn.md index 99ba886e0..d4b0be5e7 100644 --- a/changelogs/cn.md +++ b/changelogs/cn.md @@ -1,5 +1,104 @@ # 更新日志 +## v0.37.7 (2026-10-30) + +> 修复 Discord 私聊识别与重复导入匹配,完善 MCP 只读查询和安全校验。 + +### 🐛 修复 + +- 修复 Discord 私聊被识别为群聊的问题,并保持同一频道重复、增量导入时的会话匹配 +- 【MCP】为查询工具补充只读标记,便于 AI 客户端正确识别工具权限 (by [@moesnow](https://github.com/moesnow)) +- 【MCP】查询不再自动迁移数据库或修改数据目录兼容标记,旧数据库需先通过桌面端或 CLI 升级 (by [@moesnow](https://github.com/moesnow)) +- 【MCP】加强会话路径和 SQL 校验,防止读取数据目录之外的文件或执行非查询操作 (by [@moesnow](https://github.com/moesnow)) + +## v0.37.6 (2026-10-22) + +> 完善导入格式失败引导,支持将记忆设为会话默认页面,并修复 MCP 数组参数类型问题。 + +### 🐛 修复 + +- 无法识别聊天记录格式时提供说明和 AI 转换指南,支持单文件与批量导入,并适配多语言 +- 支持将记忆设为进入聊天会话后的默认页面 (by [@KokerZhou](https://github.com/KokerZhou)) +- 【MCP】修复字符串、数字和布尔值数组参数被错误识别为字符串、导致工具调用失败的问题 (by [@moesnow](https://github.com/moesnow)) + +## v0.37.5 (2026-09-28) + +> 统一右侧面板交互,新增桌面界面缩放,并修复会话加载、数据源配置与代理模型下载问题。 + +### ✨ 新功能 + +- 统一聊天记录、联系人详情和聊天话题的右侧面板,支持页头展开收起、拖动调宽,并保留收起前的浏览位置 +- 【桌面端】新增界面缩放设置 + +### 🐛 修复 + +- 修复部分 Windows 用户升级后因重复写入数据目录兼容标记而无法加载会话列表的问题 +- 修复远程数据源配置读取或保存失败时可能覆盖原配置、误报成功的问题,保留原文件并明确提示错误 +- 修复使用代理时本地语义模型下载后无法写入缓存、导致模型加载失败的问题 (by [@huaqian0226](https://github.com/huaqian0226)) + +## v0.37.4 (2026-09-23) + +> 优化 AI 工具结果的上下文控制,修复 Qwen 话题总结问题,并完善会话加载失败提示。 + +### 🐛 修复 + +- 修复 AI 多轮或并行工具调用的结果累积后可能超出模型上下文限制的问题,按请求裁剪工具结果并保留完整对话记录 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 修复部分 Qwen 模型在话题总结时因默认开启思考而无法生成结果的问题 +- 话题总结达到模型输出上限时显示明确提示,避免无效重试造成额外消耗 +- 会话列表加载失败时完整展示错误详情,并在应用日志中保留完整错误堆栈,便于排查升级后的权限等问题 +- 补齐 AI 词频与回复速度分析工具的英文时间参数说明 +- 【CLI】Docker 镜像改用与 npm 发布相同的安装包,避免 npm 同步延迟导致镜像发布失败 + +## v0.37.3 (2026-09-22) + +> 修复 AI 时间筛选、Qwen 模型兼容与续聊问题,完善多语言支持和消息上下文查询。 + +### ✨ 新功能 + +- 完善 AI 工具说明的中英文适配,统一桌面端、CLI Web 与 MCP 的语言处理 (by [@Zycheng114514](https://github.com/Zycheng114514)) + +### 🐛 修复 + +- 修复 AI 词频与回复速度分析忽略所选时间范围的问题,明确提问时间、页面筛选与默认范围的优先级 (by [@liuyy2](https://github.com/liuyy2)) +- 修复部分 Qwen 模型在 AI 对话、话题总结时因消息角色不兼容而报错的问题 +- 修复补录历史消息后上下文可能错位的问题,并优化消息上下文检索性能 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 补齐语言偏好卡片中缺失的日语标签 (by [@Zycheng114514](https://github.com/Zycheng114514)) +- 【CLI】修复 AI 续聊遗漏已完成工具调用结果的问题,保留工具结果与错误状态供后续对话使用 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 【CLI】修复 Docker 镜像发布可能因 npm 新版本尚未同步而失败的问题 + +## v0.37.2 (2026-09-18) + +> 修复 AI 回复截断与停止后的续聊问题,完善表情识别、技能入口和下载安全校验。 + +### ✨ 新功能 + +- 词频与语言偏好分析支持识别抖音表情代码,避免表情名称混入高频词 (by [@babybitter](https://github.com/babybitter)) + +### 🐛 修复 + +- 修复 AI 回复截断后部分入口丢失已生成内容或用量的问题,支持保存后继续对话 +- 修复手动停止 AI 后已生成内容未保存、继续提问可能丢失上下文的问题 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 修复停止 AI 请求后上下文压缩仍继续执行的问题,避免额外模型调用和摘要写入 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 修复下载接口的文件名校验,防止文件被写入下载目录之外 (by [@ttsdj](https://github.com/ttsdj)) + +### ♻️ 重构 + +- 统一外部 Agent 的导入、转换和分析技能入口,按用户语言回复,并兼容已发布版本的旧导入安装命令 + +## v0.37.1 (2026-09-04) + +> 新增贡献者署名,优化 Docker 启动,并修复 AI 工具循环、文本统计和关系图资源占用。 + +### ✨ 新功能 + +- 语言偏好、词频和口头禅分析支持读取语音转写内容 (by [@babybitter](https://github.com/babybitter)) +- 【CLI】Docker 镜像内置默认中文分词词典,首次启动无需下载 (by [@KokerZhou](https://github.com/KokerZhou)) + +### 🐛 修复 + +- 修复 AI 工具调用达到轮次上限后可能重复生成最终回答、工具错误被记为成功及上下文重复写入的问题 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 修复系统通知和分享占位污染词频、语言偏好与口头禅统计,并保留用户表情内容 (by [@babybitter](https://github.com/babybitter)) + ## v0.37.0 (2026-08-28) > 新增跨对话 AI 分析与长期记忆,修复导入去重、AI回复失败等大量问题,并优化洞察性能和聊天记录查看体验。
目前的跨对话分析测试不足,仍然为实验性功能,后续版本会继续优化和完善。 @@ -10,17 +109,17 @@ - 新增长期记忆,支持管理偏好、关于我、联系人和群聊记忆,并按问题自动调用相关内容 - 长期记忆支持关联来源消息,AI 形成的结论可返回原对话核验 - 统一全局与单会话 AI 的对话列表、输入框和 @ 选择交互 -- 聊天记录查看器支持拖拽调整宽度并记住布局 +- 聊天记录查看器支持拖拽调整宽度并记住布局 (by [@KokerZhou](https://github.com/KokerZhou)) - 分析页将 SQL 实验室和调试工具统一收纳到“更多” -- 【CLI】支持通过 Unix Domain Socket 启动 Web 服务 +- 【CLI】支持通过 Unix Domain Socket 启动 Web 服务 (by [@KokerZhou](https://github.com/KokerZhou)) ### 🐛 修复 - 修复 WeFlow 与缺少稳定消息 ID 的聊天记录在增量导入、合并时可能过度去重的问题,避免真实消息丢失 -- 修复 AI 回复失败后错误状态、会话列表和后续提问可能异常的问题 -- 修复 AI 对话保存和编辑时可能留下不完整轮次的问题,并仅允许编辑最新一轮提问 -- 修复长对话压缩可能丢失消息边界和记忆来源关联的问题 -- 修复同名 AI 服务配置可能读取错误凭据的问题 +- 修复 AI 回复失败后错误状态、会话列表和后续提问可能异常的问题 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 修复 AI 对话保存和编辑时可能留下不完整轮次的问题,并仅允许编辑最新一轮提问 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 修复长对话压缩可能丢失消息边界和记忆来源关联的问题 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 修复同名 AI 服务配置可能读取错误凭据的问题 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) - 修复切换或返回分析页面时可能重复加载或继续显示导入前旧数据的问题 - 完善跨对话分析的时间范围、联系人解析、扫描预算和截断提示 @@ -41,7 +140,7 @@ ### 🐛 修复 -- 导入文件夹改为独立按钮,避免与选择文件互相抢入口 +- 导入文件夹改为独立按钮,避免与选择文件互相抢入口 (by [@kongjiyu](https://github.com/kongjiyu)) ### ⚡ 性能 @@ -109,7 +208,7 @@ - 修复 QQ 自动导入在 UID/UIN 变化、缺失 owner 或合并会话中的匹配与成员映射问题,避免重复会话和成员身份错乱 - 修复合并导入因平台消息 ID 冲突或私聊身份不足导致的消息误删,并保留有效 owner 与会话身份信息 -- 修复动态关键词排行请求、快速切换与多色模式问题,避免结果过期或未知类型导致页面崩溃 +- 修复动态关键词排行请求、快速切换与多色模式问题,避免结果过期或未知类型导致页面崩溃 (by [@kelaocai](https://github.com/kelaocai)) - 统一洞察加载过渡,并恢复年度活动日历配色与关系视图切换控件 ### ⚡ 性能 @@ -125,7 +224,7 @@ - QQ Shuakami 与 QQ Chat Exporter 导入接入 Rust 原生解析,提升大型导出文件的处理稳定性 - 多文件导入新增安全并发调度,避免同一会话并发写入,并提供逐文件进度和失败详情 - 新增隐私安全的匿名产品统计,仅上报白名单内的运行环境与功能使用事件 -- 【Web CLI】本地语义索引运行组件改为首次使用时按需安装,Docker 镜像继续预装所需组件 +- 【Web CLI】本地语义索引运行组件改为首次使用时按需安装,Docker 镜像继续预装所需组件 (by [@kelaocai](https://github.com/kelaocai)) ### 🐛 修复 @@ -183,18 +282,18 @@ ### 🐛 修复 - 修复纯 Web 端并发访问 OPFS、概览筛选竞争、词频统计和关系分析过滤问题 -- 前端图标改为本地打包,避免运行时依赖外部图标服务 +- 前端图标改为本地打包,避免运行时依赖外部图标服务 (by [@KokerZhou](https://github.com/KokerZhou)) - 统一 ChatLab JSONL 自动识别与格式校验规则,并拒绝无法解析的 owner 信息 - 【桌面端】阻止迁移过程删除当前数据目录,并拒绝新旧数据目录相互嵌套或指向同一路径 -- 【CLI】Docker 部署持久化用户数据与系统状态,并默认仅向 localhost 暴露端口 +- 【CLI】Docker 部署持久化用户数据与系统状态,并默认仅向 localhost 暴露端口 (by [@KokerZhou](https://github.com/KokerZhou)) ### 📝 文档 -- 新增 Docker 部署指南,并完善 AI 分析价值与本地隐私说明 +- 新增 Docker 部署指南,并完善 AI 分析价值与本地隐私说明 (by [@KokerZhou](https://github.com/KokerZhou)) ### 👷 CI -- 发布 amd64 与 arm64 架构的 ChatLab CLI Docker 镜像 +- 发布 amd64 与 arm64 架构的 ChatLab CLI Docker 镜像 (by [@KokerZhou](https://github.com/KokerZhou)) ## v0.32.0 (2026-07-18) @@ -202,14 +301,14 @@ ### ✨ 新功能 -- 【桌面端】新增应用锁,支持密码、立即锁定、启动时解锁和闲置超时自动锁定 +- 【桌面端】新增应用锁,支持密码、立即锁定、启动时解锁和闲置超时自动锁定 (by [@xyz-225648](https://github.com/xyz-225648)) ### 🐛 修复 - 数据目录迁移后保留旧数据并提示人工确认清理,避免迁移或重试过程中误删源文件 - 大体积 ChatLab JSONL 改为分批流式处理,避免导入卡死并保留合并导入中的成员头像 - 统一桌面端与 CLI Web 的多文件批量导入流程,支持串行进度、取消和结果汇总 -- 【桌面端】应用锁增加失败次数限制、冷却时间和启动状态保护,避免刷新或重启绕过锁定 +- 【桌面端】应用锁增加失败次数限制、冷却时间和启动状态保护,避免刷新或重启绕过锁定 (by [@xyz-225648](https://github.com/xyz-225648)) ## v0.31.3 (2026-07-17) @@ -253,7 +352,7 @@ - 统一各端 Push 导入的校验、去重、成员推断和 dry-run 结果,避免预览与实际写入不一致 - 补导历史消息后,聊天记录按时间与消息 ID 稳定排序,避免旧消息出现在末尾 -- 修复 QCE / QQ Chat Exporter 私聊被误判为群聊并产生“0”成员的问题 +- 修复 QCE / QQ Chat Exporter 私聊被误判为群聊并产生“0”成员的问题 (by [@shuakami](https://github.com/shuakami)) ## v0.31.0 (2026-07-13) @@ -271,7 +370,7 @@ - 修复 ChatLab JSON 导出不可直接重新导入的问题,并保留成员、owner 和别名元数据 - 增强自动增量导入的去重、混合消息身份识别和 ChatLab 重复导入区分能力 -- WhatsApp TXT 解析支持带括号时间戳和点号 AM/PM 标记 +- WhatsApp TXT 解析支持带括号时间戳和点号 AM/PM 标记 (by [@hh7418695](https://github.com/hh7418695)) ### ♻️ 重构 @@ -314,7 +413,7 @@ - 修复 SQL 实验室导出 CSV 失败的问题 - 导入 JSONL 等流式成员数据时保留头像,支持增量导入新头像 - 打开 AI 日志改为共享路由处理,本机 CLI Web 和桌面端行为保持一致 -- 概览身份卡跟随当前选择的时间范围刷新 +- 概览身份卡跟随当前选择的时间范围刷新 (by [@qikairo7](https://github.com/qikairo7)) ### 📝 文档 @@ -445,8 +544,8 @@ ### 🐛 修复 - 修复多关键词消息搜索功能 -- 修复词云词典加载 UX 回归问题 -- 修复排行榜标签与 emoji 清洗 +- 修复词云词典加载 UX 回归问题 (by [@qikairo7](https://github.com/qikairo7)) +- 修复排行榜标签与 emoji 清洗 (by [@qikairo7](https://github.com/qikairo7)) - 修复 v8 数据库迁移缺少国际化文本 - 【CLI】修复工具适配器中会话上下文传递与预处理缺失 @@ -466,8 +565,8 @@ ### 🐛 修复 -- 修复高频词统计中含有平台回复消息占位符的问题 -- 修复词云文本中包含媒体占位符的问题 +- 修复高频词统计中含有平台回复消息占位符的问题 (by [@qikairo7](https://github.com/qikairo7)) +- 修复词云文本中包含媒体占位符的问题 (by [@qikairo7](https://github.com/qikairo7)) - 修复 Jieba 自定义词典变更后语言偏好缓存与词频缓存未失效的问题 - 修复时效性分析缓存未按日失效的问题 - 修复时间轴面板默认滚动位置及会话跳转功能失效的问题 @@ -555,7 +654,7 @@ - 修复 AI 助手消息未填满聊天区域宽度 - 优化分析计划生成区块的展示样式 - 修复思考深度选择跨重启不持久的问题 -- 修复 JSONL 时间戳规范化导致增量导入异常 +- 修复 JSONL 时间戳规范化导致增量导入异常 (by [@cnYui](https://github.com/cnYui)) - 【桌面端】修复 execute_sql 工具未注册到桌面端工具注册表 ### test @@ -600,8 +699,8 @@ ### ✨ 新功能 -- 新增斜杠触发的 AI 图表运行时,支持在对话中生成并渲染 ECharts 图表 -- 优化 AI 图表结果展示体验,补充图表渲染状态和多类型图表适配 +- 新增斜杠触发的 AI 图表运行时,支持在对话中生成并渲染 ECharts 图表 (by [@CEQ151](https://github.com/CEQ151)) +- 优化 AI 图表结果展示体验,补充图表渲染状态和多类型图表适配 (by [@CEQ151](https://github.com/CEQ151)) ### 🐛 修复 @@ -609,7 +708,7 @@ - 修复预设问题标签和侧边栏元素层叠顺序异常 - 【CLI Web】修复开发后端生命周期清理不完整的问题 - 【桌面端】修复自动技能未注册图表工具,导致图表技能无法完整执行的问题 -- 【桌面端】修复 Windows 标题栏覆盖层切换主题后缓存未刷新和显示不平滑的问题 +- 【桌面端】修复 Windows 标题栏覆盖层切换主题后缓存未刷新和显示不平滑的问题 (by [@CEQ151](https://github.com/CEQ151)) ### ♻️ 重构 @@ -666,7 +765,7 @@ - 修复数据目录迁移与数据库迁移流程中的多处边界问题,避免旧库字段缺失或路径切换导致读取失败 - 修复数据目录迁移提示在旧数据不存在或目录变更后仍错误显示的问题 -- 修复增量导入后侧边栏消息数量未刷新的问题 +- 修复增量导入后侧边栏消息数量未刷新的问题 (by [@CEQ151](https://github.com/CEQ151)) - 修复侧边栏折叠状态仅存于 sessionStorage 导致刷新后丢失的问题 - 修复 AI 配置编辑时已保存密钥未正确启用拉取模型和验证按钮的问题 - 修复 AI 共享 SSE 流式响应的稳定性问题 @@ -717,7 +816,7 @@ ### ✨ 新功能 -- 新增消息派生(Fork)功能,可从任意 AI 回复处创建独立分支会话 +- 新增消息派生(Fork)功能,可从任意 AI 回复处创建独立分支会话 (by [@CEQ151](https://github.com/CEQ151)) - 状态栏新增推理强度选择器,支持为每个推理模型单独记忆并切换思维等级 - 推理等级控制新增 default/auto 选项,扩展对 Kimi、Doubao、Gemini 等更多模型家族的覆盖 - 消息分析视图拆分为「类型分析」和「时间分析」两个标签页,增加丰富统计洞察卡片 @@ -900,7 +999,7 @@ - 修复 Web 模式下会话索引、跨域代理、Demo 保护和运行时异常等问题 - 修复 Web 模式下应用版本显示为空的问题 -- 修复 AI Agent 证据检索、Web 端事件流和错误格式化等问题 +- 修复 AI Agent 证据检索、Web 端事件流和错误格式化等问题 (by [@CEQ151](https://github.com/CEQ151)) - 修复同步展示与导入逻辑不一致的问题 - 修复 CLI 开发模式下 ESM 模块解析问题 - 修复 CLI 安装后可能找不到既有桌面端数据的问题 @@ -990,7 +1089,7 @@ ### ✨ 新功能 -- 订阅会话时支持按类型筛选与选择 +- 订阅会话时支持按类型筛选与选择 (by [@xunchahaha](https://github.com/xunchahaha)) - 支持远程会话分页发现与按需加载更多 - 支持为数据源配置每次拉取的消息条数 @@ -1147,7 +1246,7 @@ ### 🐛 修复 - 修复 WhatsApp 时间解析正则与行匹配正则宽严不一致问题 -- 修复 WhatsApp 12 小时制时间与 NNBSP 字符解析兼容性问题 +- 修复 WhatsApp 12 小时制时间与 NNBSP 字符解析兼容性问题 (by [@kongjiyu](https://github.com/kongjiyu)) ### 🔧 杂项 @@ -1210,7 +1309,7 @@ - 优化 AI 对话样式与整体交互体验 - 支持 FTS5 全文搜索并新增快速搜索工具 - 精简部分工具搜索参数,降低 token 开销 -- 新增 Electron 应用 E2E 测试框架,支持端口管理与实例隔离 +- 新增 Electron 应用 E2E 测试框架,支持端口管理与实例隔离 (by [@l17728](https://github.com/l17728)) ### 🐛 修复 @@ -1223,7 +1322,7 @@ ### test -- 补充可复用的 E2E 启动器冒烟测试能力 +- 补充可复用的 E2E 启动器冒烟测试能力 (by [@l17728](https://github.com/l17728)) ### 📝 文档 @@ -1274,7 +1373,7 @@ ### 🐛 修复 -- 修复消息去重误判,并统一空字符串去重语义 +- 修复消息去重误判,并统一空字符串去重语义 (by [@lnexin](https://github.com/lnexin)) - 修复 AI 会话链路与前端 type-check 错误 - 增加默认 assistant 兜底逻辑,修复异常场景 - 修复每日消息趋势不展示的问题 @@ -1333,7 +1432,7 @@ - 新增聊天记录预处理管道 - 新增预处理设置界面与配置管理能力 -- Agent 支持基于会话的上下文时间线与运行状态展示 +- Agent 支持基于会话的上下文时间线与运行状态展示 (by [@n-WN](https://github.com/n-WN)) - 新增 AI 调试模式并增强日志可观测性 ### 🐛 修复 @@ -1413,7 +1512,7 @@ ### 🐛 修复 - 修复增量更新后会话索引生成范围不准确的问题(fix #79) -- 修复迁移与会话扫描时误处理非聊天 SQLite 文件的问题 +- 修复迁移与会话扫描时误处理非聊天 SQLite 文件的问题 (by [@n-WN](https://github.com/n-WN)) ### ♻️ 重构 @@ -1437,7 +1536,7 @@ ### 🐛 修复 -- 修复 LINE 聊天记录解析问题 +- 修复 LINE 聊天记录解析问题 (by [@haotien91](https://github.com/haotien91)) ### 📝 文档 @@ -1449,9 +1548,9 @@ ### ✨ 新功能 -- 设置内新增数据目录位置配置 +- 设置内新增数据目录位置配置 (by [@xunchahaha](https://github.com/xunchahaha)) - 数据存储目录迁移逻辑优化 -- 目录切换新增确认弹窗 +- 目录切换新增确认弹窗 (by [@xunchahaha](https://github.com/xunchahaha)) - 解析逻辑优化(WeFlow / Echotrace) ### 🐛 修复 @@ -1459,12 +1558,12 @@ - 修复 Windows 自定义筛选时消息量过大导致崩溃的问题 - 修复第三方中转 API 调用 tool_call 导致对话异常结束的问题 - 修复部分 WhatsApp 聊天记录无法正确识别的问题 -- 修复管理页面表头层级显示问题 +- 修复管理页面表头层级显示问题 (by [@xunchahaha](https://github.com/xunchahaha)) ### ♻️ 重构 - 重构 session 查询模块 -- 完善迁移日志输出 +- 完善迁移日志输出 (by [@xunchahaha](https://github.com/xunchahaha)) ## v0.9.2 (2026-02-02) @@ -1603,7 +1702,7 @@ - 支持增量导入 - 支持批量导入 - 样式优化 -- Windows 端支持原生窗口控制并实现主题同步 (#31) +- Windows 端支持原生窗口控制并实现主题同步 (#31) (by [@JiQingzhe2004](https://github.com/JiQingzhe2004)) ### 🔧 杂项 @@ -1697,7 +1796,7 @@ - 支持配置代理(resolve #7) - 优化AI模型界面交互 - 添加用户配置API教程 -- 1、新增GLM两个免费调用模型 2、新增豆包服务商和对应的最新模型 +- 1、新增GLM两个免费调用模型 2、新增豆包服务商和对应的最新模型 (by [@JiQingzhe2004](https://github.com/JiQingzhe2004)) - AI回复不输出think内容 ## v0.1.3 (2025-12-25) diff --git a/changelogs/en.json b/changelogs/en.json index f9fef0c95..f987cea09 100644 --- a/changelogs/en.json +++ b/changelogs/en.json @@ -1,4 +1,238 @@ [ + { + "version": "0.37.7", + "date": "2026-10-30", + "summary": "Fix Discord direct message detection and repeat-import matching, and improve MCP read-only queries and safety checks.", + "changes": [ + { + "type": "fix", + "items": [ + { + "text": "Fix Discord direct messages being imported as group chats and ensure repeated and incremental imports of the same channel match the existing session." + }, + { + "text": "[MCP] Add read-only annotations to query tools so AI clients can correctly identify their permissions.", + "contributors": ["moesnow"] + }, + { + "text": "[MCP] Stop queries from automatically migrating databases or updating data-directory compatibility markers. Older databases must first be upgraded through the desktop app or CLI.", + "contributors": ["moesnow"] + }, + { + "text": "[MCP] Strengthen session path and SQL validation to prevent access to files outside the data directory and execution of non-query operations.", + "contributors": ["moesnow"] + } + ] + } + ] + }, + { + "version": "0.37.6", + "date": "2026-10-22", + "summary": "Improve guidance for unrecognized import formats, allow Memory as the default session page, and fix MCP array parameter types.", + "changes": [ + { + "type": "fix", + "items": [ + { + "text": "Show an explanation and an AI conversion guide when a chat record format is not recognized, with multilingual support for single-file and batch imports." + }, + { + "text": "Allow Memory to be set as the default page when opening a chat session.", + "contributors": ["KokerZhou"] + }, + { + "text": "[MCP] Fix tool calls failing because string, number, and boolean array parameters were incorrectly treated as strings.", + "contributors": ["moesnow"] + } + ] + } + ] + }, + { + "version": "0.37.5", + "date": "2026-09-28", + "summary": "Unify right-side panels, add desktop UI scaling, and fix session loading, data source configuration, and model downloads through proxies.", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "Unify the right-side panels for chat records, contact details, and chat topics, with a header toggle, drag-to-resize support, and browsing position preserved when collapsed" + }, + { + "text": "[Desktop] Add a UI scale setting" + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "Fix session lists failing to load for some Windows users after upgrading due to unnecessary rewrites of data directory compatibility markers" + }, + { + "text": "Fix remote data source configuration read or write failures overwriting existing settings or incorrectly reporting success; preserve the original file and show a clear error" + }, + { + "text": "Fix local embedding models failing to load because downloads through a proxy were not written to the cache", + "contributors": ["huaqian0226"] + } + ] + } + ] + }, + { + "version": "0.37.4", + "date": "2026-09-23", + "summary": "Improve context budgeting for AI tool results, fix Qwen topic summaries, and show clearer session-loading errors.", + "changes": [ + { + "type": "fix", + "items": [ + { + "text": "Fix accumulated results from multi-round or parallel AI tool calls exceeding the model's context limit by trimming results per request while preserving the full conversation history", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "Fix topic summaries failing on some Qwen models when thinking is enabled by default" + }, + { + "text": "Show a clear message when topic summaries hit the model's output limit, avoiding unnecessary usage from ineffective retries" + }, + { + "text": "Show full error details when the session list fails to load and retain complete stack traces in application logs to help diagnose permission and other issues after upgrading" + }, + { + "text": "Complete the English time parameter descriptions for AI word frequency and reply speed analysis tools" + }, + { + "text": "[CLI] Build Docker images with the same package published to npm to avoid image publishing failures caused by registry propagation delays" + } + ] + } + ] + }, + { + "version": "0.37.3", + "date": "2026-09-22", + "summary": "Fix AI time filters, Qwen compatibility, and conversation continuation, and improve multilingual support and message context retrieval.", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "Improve Chinese and English AI tool descriptions and unify language handling across Desktop, CLI Web, and MCP", + "contributors": ["Zycheng114514"] + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "Fix AI word frequency and reply speed analysis ignoring the selected time range, and clarify the priority of explicitly requested dates, page filters, and defaults", + "contributors": ["liuyy2"] + }, + { + "text": "Fix message role compatibility errors with some Qwen models in AI chats and topic summaries" + }, + { + "text": "Fix incorrect message context after backfilling older messages and improve context retrieval performance", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "Add missing Japanese labels to the language preference card", + "contributors": ["Zycheng114514"] + }, + { + "text": "[CLI] Preserve completed tool results and error states when continuing AI conversations so follow-up questions retain the full tool history", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "[CLI] Fix Docker image publishing failures caused by delays in the new npm version becoming available" + } + ] + } + ] + }, + { + "version": "0.37.2", + "date": "2026-09-18", + "summary": "Fix issues resuming AI conversations after truncated or stopped replies, and improve emoji recognition, skill entry points, and download safety.", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "Recognize Douyin emoji codes in word frequency and language preference analysis so emoji names no longer appear as frequent words", + "contributors": ["babybitter"] + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "Fix cases where truncated AI replies lost generated content or token usage, allowing saved conversations to be continued" + }, + { + "text": "Save content received before an AI reply is stopped manually, preserving context for follow-up questions", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "Cancel context compression when an AI request is stopped to prevent further model calls and summary writes", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "Validate filenames in the download endpoint to prevent writes outside the downloads directory", + "contributors": ["ttsdj"] + } + ] + }, + { + "type": "refactor", + "items": [ + { + "text": "Unify external Agent skills for import, conversion, and analysis, follow the user's language, and retain compatibility with import skill installation commands from released versions" + } + ] + } + ] + }, + { + "version": "0.37.1", + "date": "2026-09-04", + "summary": "Add contributor attribution, improve Docker startup, and fix AI tool loops, text statistics, and relationship graph resource usage.", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "Include voice transcriptions in language preference, word frequency, and catchphrase analysis", + "contributors": ["babybitter"] + }, + { + "text": "【CLI】Bundle the default Chinese NLP dictionary in Docker images to avoid downloading it on first startup", + "contributors": ["KokerZhou"] + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "Fix duplicate final responses after AI tool calls reach the turn limit, tool errors recorded as successful, and duplicated context writes", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "Exclude system notifications and share placeholders from word frequency, language preference, and catchphrase statistics while preserving user emoji content", + "contributors": ["babybitter"] + } + ] + } + ] + }, { "version": "0.37.0", "date": "2026-08-28", @@ -11,19 +245,37 @@ "Add long-term memory for managing preferences, details about yourself, contacts, and groups, with relevant entries loaded automatically for each question", "Link long-term memories to source messages so AI-derived conclusions can be verified in the original conversation", "Unify conversation lists, composers, and @ mention selection across global and per-chat AI conversations", - "Let users resize the chat record viewer by dragging and remember the chosen width", + { + "text": "Let users resize the chat record viewer by dragging and remember the chosen width", + "contributors": ["KokerZhou"] + }, "Move SQL Lab and debug tools into a unified More section on analysis pages", - "【CLI】Support starting the Web service over a Unix Domain Socket" + { + "text": "【CLI】Support starting the Web service over a Unix Domain Socket", + "contributors": ["KokerZhou"] + } ] }, { "type": "fix", "items": [ "Fix excessive deduplication when incrementally importing or merging WeFlow records and messages without stable IDs, preventing valid messages from being lost", - "Fix error state, conversation list, and follow-up issues after an AI reply fails", - "Prevent incomplete turns when saving or editing AI conversations, and allow editing only the latest user turn", - "Fix long-conversation compression losing message boundaries or links to memory sources", - "Fix AI services with the same configuration name loading the wrong credentials", + { + "text": "Fix error state, conversation list, and follow-up issues after an AI reply fails", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "Prevent incomplete turns when saving or editing AI conversations, and allow editing only the latest user turn", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "Fix long-conversation compression losing message boundaries or links to memory sources", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "Fix AI services with the same configuration name loading the wrong credentials", + "contributors": ["Sanssssssssssssssss"] + }, "Fix analysis pages reloading duplicate data or showing stale results after switching away and back", "Improve time ranges, contact resolution, scan budgets, and truncation notices for cross-chat analysis" ] @@ -52,7 +304,12 @@ }, { "type": "fix", - "items": ["Turn folder import into a dedicated button so it no longer competes with file selection"] + "items": [ + { + "text": "Turn folder import into a dedicated button so it no longer competes with file selection", + "contributors": ["kongjiyu"] + } + ] }, { "type": "perf", @@ -147,7 +404,10 @@ "items": [ "Fix QQ automatic import matching and member mapping when UID/UIN values change, owner metadata is missing, or sessions have been merged, preventing duplicate sessions and incorrect member identities", "Prevent merge imports from incorrectly removing messages when platform message IDs conflict or private-chat identity is incomplete, while preserving valid owner and session identity metadata", - "Fix request handling, rapid switching, and multi-color mode in dynamic keyword rankings, preventing stale results and crashes caused by unknown types", + { + "text": "Fix request handling, rapid switching, and multi-color mode in dynamic keyword rankings, preventing stale results and crashes caused by unknown types", + "contributors": ["kelaocai"] + }, "Unify loading transitions across Insights, and restore annual activity calendar colors and relationship view controls" ] }, @@ -170,7 +430,10 @@ "Use the Rust native parser for QQ Shuakami and QQ Chat Exporter imports to improve reliability with large exports", "Add safe concurrent scheduling for multi-file imports, preventing concurrent writes to the same session while reporting per-file progress and failures", "Add privacy-preserving anonymous product analytics that only report allowlisted environment and feature usage events", - "【CLI Web】Install the local semantic-index runtime on first use, while keeping it preinstalled in the Docker image" + { + "text": "【CLI Web】Install the local semantic-index runtime on first use, while keeping it preinstalled in the Docker image", + "contributors": ["kelaocai"] + } ] }, { @@ -253,19 +516,35 @@ "type": "fix", "items": [ "Fix concurrent OPFS access, overview filter race conditions, word frequency totals, and relationship analysis filters in the browser app", - "Bundle frontend icons locally to remove the runtime dependency on external icon services", + { + "text": "Bundle frontend icons locally to remove the runtime dependency on external icon services", + "contributors": ["KokerZhou"] + }, "Align ChatLab JSONL auto-detection with format validation and reject unresolved owner metadata", "【Desktop】Prevent migrations from deleting the active data directory and reject nested or equivalent legacy and active data paths", - "【CLI】Persist user data and system state in Docker deployments, and bind published ports to localhost by default" + { + "text": "【CLI】Persist user data and system state in Docker deployments, and bind published ports to localhost by default", + "contributors": ["KokerZhou"] + } ] }, { "type": "docs", - "items": ["Add a Docker deployment guide and clarify the benefits of AI analysis and local data privacy"] + "items": [ + { + "text": "Add a Docker deployment guide and clarify the benefits of AI analysis and local data privacy", + "contributors": ["KokerZhou"] + } + ] }, { "type": "ci", - "items": ["Publish ChatLab CLI Docker images for amd64 and arm64"] + "items": [ + { + "text": "Publish ChatLab CLI Docker images for amd64 and arm64", + "contributors": ["KokerZhou"] + } + ] } ] }, @@ -277,7 +556,10 @@ { "type": "feat", "items": [ - "【Desktop】Add an app lock with password protection, immediate locking, a startup unlock prompt, and automatic locking after inactivity" + { + "text": "【Desktop】Add an app lock with password protection, immediate locking, a startup unlock prompt, and automatic locking after inactivity", + "contributors": ["xyz-225648"] + } ] }, { @@ -286,7 +568,10 @@ "Keep old data after migrating the data directory and prompt for manual cleanup, preventing source files from being deleted during migrations or retries", "Process large ChatLab JSONL files in streaming batches to prevent import freezes and preserve member avatars in merged imports", "Unify multi-file batch imports across Desktop and CLI Web with sequential progress, cancellation, and result summaries", - "【Desktop】Add failed-attempt limits, cooldowns, and startup state protection to the app lock so refreshes or restarts cannot bypass it" + { + "text": "【Desktop】Add failed-attempt limits, cooldowns, and startup state protection to the app lock so refreshes or restarts cannot bypass it", + "contributors": ["xyz-225648"] + } ] } ] @@ -349,7 +634,10 @@ "items": [ "Align validation, deduplication, member inference, and dry-run results for Push imports across runtimes", "Keep backfilled chat history in chronological order using timestamps and message IDs", - "Fix QCE / QQ Chat Exporter private chats being detected as groups and creating a phantom “0” member" + { + "text": "Fix QCE / QQ Chat Exporter private chats being detected as groups and creating a phantom “0” member", + "contributors": ["shuakami"] + } ] } ] @@ -374,7 +662,10 @@ "items": [ "Fix ChatLab JSON exports so they can be imported again while preserving member, owner, and alias metadata", "Improve automatic incremental import deduplication, mixed message identity handling, and ChatLab reimport disambiguation", - "Support bracketed timestamps and dotted AM/PM markers in WhatsApp TXT parsing" + { + "text": "Support bracketed timestamps and dotted AM/PM markers in WhatsApp TXT parsing", + "contributors": ["hh7418695"] + } ] }, { @@ -435,7 +726,10 @@ "Fix CSV export failures in SQL Lab", "Preserve avatars when importing JSONL and other streamed member data, including incremental avatar imports", "Open AI logs through the shared route so local CLI Web and Desktop behave consistently", - "Refresh the overview identity card when the selected time range changes" + { + "text": "Refresh the overview identity card when the selected time range changes", + "contributors": ["qikairo7"] + } ] }, { @@ -606,8 +900,14 @@ "type": "fix", "items": [ "Fix multi-keyword message search", - "Fix UX regression in word cloud dictionary loading", - "Fix ranking label formatting and emoji cleanup", + { + "text": "Fix UX regression in word cloud dictionary loading", + "contributors": ["qikairo7"] + }, + { + "text": "Fix ranking label formatting and emoji cleanup", + "contributors": ["qikairo7"] + }, "Add missing i18n text for the v8 database migration", "[CLI] Fix missing session context forwarding and preprocessing in the tool adapter" ] @@ -634,8 +934,14 @@ { "type": "fix", "items": [ - "Fix catchphrase stats including platform reply message placeholders", - "Fix word cloud text including media placeholders", + { + "text": "Fix catchphrase stats including platform reply message placeholders", + "contributors": ["qikairo7"] + }, + { + "text": "Fix word cloud text including media placeholders", + "contributors": ["qikairo7"] + }, "Fix language-preference and word-frequency caches not being invalidated when the Jieba custom dictionary changes", "Fix time-sensitive analytics cache entries not expiring daily", "Fix timeline panel default scroll position and session jump not working", @@ -744,7 +1050,10 @@ "Fix assistant messages not filling the full chat area width", "Improve the visual layout of the analysis plan generation block", "Fix thinking-level selection not persisting across restarts", - "Fix JSONL timestamp normalization causing incremental import failures", + { + "text": "Fix JSONL timestamp normalization causing incremental import failures", + "contributors": ["cnYui"] + }, "Desktop: Fix `execute_sql` tool not being registered in the desktop tool registry" ] }, @@ -803,8 +1112,14 @@ { "type": "feat", "items": [ - "Add a slash-activated AI chart runtime that can generate and render ECharts charts in conversations", - "Improve the AI chart result experience with clearer rendering states and support for more chart types" + { + "text": "Add a slash-activated AI chart runtime that can generate and render ECharts charts in conversations", + "contributors": ["CEQ151"] + }, + { + "text": "Improve the AI chart result experience with clearer rendering states and support for more chart types", + "contributors": ["CEQ151"] + } ] }, { @@ -814,7 +1129,10 @@ "Fix preset question chips and sidebar elements stacking incorrectly", "CLI Web: Fix incomplete cleanup of the development backend lifecycle", "Desktop: Register the chart tool for automatic skills so chart skills can run correctly", - "Desktop: Refresh the Windows title bar overlay cache after theme changes and smooth its display" + { + "text": "Desktop: Refresh the Windows title bar overlay cache after theme changes and smooth its display", + "contributors": ["CEQ151"] + } ] }, { @@ -889,7 +1207,10 @@ "items": [ "Fix multiple edge cases in data directory and database migrations to prevent missing legacy columns or failed reads after path changes", "Fix legacy data migration prompts appearing incorrectly when no legacy data exists or after directory changes", - "Fix sidebar message counts not refreshing after incremental imports", + { + "text": "Fix sidebar message counts not refreshing after incremental imports", + "contributors": ["CEQ151"] + }, "Fix sidebar collapsed state being lost after refresh because it was stored only in sessionStorage", "Fix fetch-models and validate buttons not being enabled when editing AI settings with a saved key", "Fix stability issues in shared AI SSE streaming", @@ -956,7 +1277,10 @@ { "type": "feat", "items": [ - "Add a Fork button to AI replies to branch the conversation from any point into an independent session", + { + "text": "Add a Fork button to AI replies to branch the conversation from any point into an independent session", + "contributors": ["CEQ151"] + }, "Add a per-model thinking level selector in the status bar, with the choice remembered per model slot", "Extend thinking level controls with default and auto options, covering Kimi, Doubao, Gemini, and more model families", "Split the message analysis view into Type Analysis and Time Analysis tabs, each with enriched statistical insight cards", @@ -1184,7 +1508,10 @@ "items": [ "Fix Web-mode issues around session indexes, CORS proxying, demo safeguards, and runtime errors", "Fix the app version showing as empty in Web mode", - "Fix AI Agent evidence retrieval, Web event streaming, and error formatting issues", + { + "text": "Fix AI Agent evidence retrieval, Web event streaming, and error formatting issues", + "contributors": ["CEQ151"] + }, "Fix inconsistencies between sync display and import logic", "Fix ESM module resolution in CLI development mode", "Fix cases where the CLI could not find existing desktop data after installation", @@ -1299,7 +1626,10 @@ { "type": "feat", "items": [ - "Add session type filtering and selection when subscribing to sessions.", + { + "text": "Add session type filtering and selection when subscribing to sessions.", + "contributors": ["xunchahaha"] + }, "Add paginated remote session discovery with on-demand loading.", "Allow each data source to configure how many messages are fetched per request." ] @@ -1503,7 +1833,10 @@ "type": "fix", "items": [ "Fix inconsistency between WhatsApp time-parsing regex rules and line-matching regex rules.", - "Fix compatibility issues when parsing WhatsApp 12-hour time formats and NNBSP characters." + { + "text": "Fix compatibility issues when parsing WhatsApp 12-hour time formats and NNBSP characters.", + "contributors": ["kongjiyu"] + } ] }, { @@ -1584,7 +1917,10 @@ "Improve AI chat styling and overall interaction flow.", "Add FTS5 full-text search support with a quick search tool.", "Trim search parameters in selected tools to reduce token usage.", - "Add an E2E testing framework for Electron apps with port management and instance isolation." + { + "text": "Add an E2E testing framework for Electron apps with port management and instance isolation.", + "contributors": ["l17728"] + } ] }, { @@ -1600,7 +1936,12 @@ }, { "type": "test", - "items": ["Add reusable smoke-test coverage for the E2E app launcher."] + "items": [ + { + "text": "Add reusable smoke-test coverage for the E2E app launcher.", + "contributors": ["l17728"] + } + ] }, { "type": "docs", @@ -1662,7 +2003,10 @@ { "type": "fix", "items": [ - "Fix false positives in message deduplication and unify empty-string deduplication behavior", + { + "text": "Fix false positives in message deduplication and unify empty-string deduplication behavior", + "contributors": ["lnexin"] + }, "Fix AI conversation flow issues and frontend type-check errors", "Add a fallback default assistant for edge cases", "Fix daily message trends not rendering" @@ -1729,7 +2073,10 @@ "items": [ "Add a chat-history preprocessing pipeline.", "Add preprocessing settings UI and configuration management.", - "Add session-based context timelines and runtime status for the Agent.", + { + "text": "Add session-based context timelines and runtime status for the Agent.", + "contributors": ["n-WN"] + }, "Add an AI debug mode with improved log observability." ] }, @@ -1836,7 +2183,10 @@ "type": "fix", "items": [ "Fix inaccurate session index generation scope after incremental updates (fix #79).", - "Fix non-chat SQLite files being incorrectly processed during migration and session scanning." + { + "text": "Fix non-chat SQLite files being incorrectly processed during migration and session scanning.", + "contributors": ["n-WN"] + } ] }, { @@ -1866,7 +2216,12 @@ }, { "type": "fix", - "items": ["Fix parsing issues for LINE chat logs."] + "items": [ + { + "text": "Fix parsing issues for LINE chat logs.", + "contributors": ["haotien91"] + } + ] }, { "type": "docs", @@ -1882,9 +2237,15 @@ { "type": "feat", "items": [ - "Add a data directory location setting", + { + "text": "Add a data directory location setting", + "contributors": ["xunchahaha"] + }, "Optimize data directory migration logic", - "Add a confirmation dialog for directory switching", + { + "text": "Add a confirmation dialog for directory switching", + "contributors": ["xunchahaha"] + }, "Improve parser logic (WeFlow / Echotrace)" ] }, @@ -1894,12 +2255,21 @@ "Fix crashes on Windows when custom filtering processes large message volumes", "Fix conversations ending early when third-party relay APIs call tool_call", "Fix some WhatsApp chat logs not being detected correctly", - "Fix manage page header stacking above settings" + { + "text": "Fix manage page header stacking above settings", + "contributors": ["xunchahaha"] + } ] }, { "type": "refactor", - "items": ["Refactor session query module", "Improve migration logging"] + "items": [ + "Refactor session query module", + { + "text": "Improve migration logging", + "contributors": ["xunchahaha"] + } + ] } ] }, @@ -2079,7 +2449,10 @@ "Support incremental import", "Support batch import", "Style improvements", - "Support native window controls and theme sync on Windows (#31)" + { + "text": "Support native window controls and theme sync on Windows (#31)", + "contributors": ["JiQingzhe2004"] + } ] }, { @@ -2197,7 +2570,10 @@ "Support proxy configuration (resolve #7)", "Improve AI model UI interactions", "Add API tutorial for user configuration", - "Add two free GLM models; add Doubao provider and latest models", + { + "text": "Add two free GLM models; add Doubao provider and latest models", + "contributors": ["JiQingzhe2004"] + }, "AI replies no longer output think content" ] } diff --git a/changelogs/en.md b/changelogs/en.md index 6917e2ce0..b4e421e1c 100644 --- a/changelogs/en.md +++ b/changelogs/en.md @@ -1,5 +1,104 @@ # Changelog +## v0.37.7 (2026-10-30) + +> Fix Discord direct message detection and repeat-import matching, and improve MCP read-only queries and safety checks. + +### 🐛 Bug Fixes + +- Fix Discord direct messages being imported as group chats and ensure repeated and incremental imports of the same channel match the existing session. +- [MCP] Add read-only annotations to query tools so AI clients can correctly identify their permissions. (by [@moesnow](https://github.com/moesnow)) +- [MCP] Stop queries from automatically migrating databases or updating data-directory compatibility markers. Older databases must first be upgraded through the desktop app or CLI. (by [@moesnow](https://github.com/moesnow)) +- [MCP] Strengthen session path and SQL validation to prevent access to files outside the data directory and execution of non-query operations. (by [@moesnow](https://github.com/moesnow)) + +## v0.37.6 (2026-10-22) + +> Improve guidance for unrecognized import formats, allow Memory as the default session page, and fix MCP array parameter types. + +### 🐛 Bug Fixes + +- Show an explanation and an AI conversion guide when a chat record format is not recognized, with multilingual support for single-file and batch imports. +- Allow Memory to be set as the default page when opening a chat session. (by [@KokerZhou](https://github.com/KokerZhou)) +- [MCP] Fix tool calls failing because string, number, and boolean array parameters were incorrectly treated as strings. (by [@moesnow](https://github.com/moesnow)) + +## v0.37.5 (2026-09-28) + +> Unify right-side panels, add desktop UI scaling, and fix session loading, data source configuration, and model downloads through proxies. + +### ✨ Features + +- Unify the right-side panels for chat records, contact details, and chat topics, with a header toggle, drag-to-resize support, and browsing position preserved when collapsed +- [Desktop] Add a UI scale setting + +### 🐛 Bug Fixes + +- Fix session lists failing to load for some Windows users after upgrading due to unnecessary rewrites of data directory compatibility markers +- Fix remote data source configuration read or write failures overwriting existing settings or incorrectly reporting success; preserve the original file and show a clear error +- Fix local embedding models failing to load because downloads through a proxy were not written to the cache (by [@huaqian0226](https://github.com/huaqian0226)) + +## v0.37.4 (2026-09-23) + +> Improve context budgeting for AI tool results, fix Qwen topic summaries, and show clearer session-loading errors. + +### 🐛 Bug Fixes + +- Fix accumulated results from multi-round or parallel AI tool calls exceeding the model's context limit by trimming results per request while preserving the full conversation history (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- Fix topic summaries failing on some Qwen models when thinking is enabled by default +- Show a clear message when topic summaries hit the model's output limit, avoiding unnecessary usage from ineffective retries +- Show full error details when the session list fails to load and retain complete stack traces in application logs to help diagnose permission and other issues after upgrading +- Complete the English time parameter descriptions for AI word frequency and reply speed analysis tools +- [CLI] Build Docker images with the same package published to npm to avoid image publishing failures caused by registry propagation delays + +## v0.37.3 (2026-09-22) + +> Fix AI time filters, Qwen compatibility, and conversation continuation, and improve multilingual support and message context retrieval. + +### ✨ Features + +- Improve Chinese and English AI tool descriptions and unify language handling across Desktop, CLI Web, and MCP (by [@Zycheng114514](https://github.com/Zycheng114514)) + +### 🐛 Bug Fixes + +- Fix AI word frequency and reply speed analysis ignoring the selected time range, and clarify the priority of explicitly requested dates, page filters, and defaults (by [@liuyy2](https://github.com/liuyy2)) +- Fix message role compatibility errors with some Qwen models in AI chats and topic summaries +- Fix incorrect message context after backfilling older messages and improve context retrieval performance (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- Add missing Japanese labels to the language preference card (by [@Zycheng114514](https://github.com/Zycheng114514)) +- [CLI] Preserve completed tool results and error states when continuing AI conversations so follow-up questions retain the full tool history (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- [CLI] Fix Docker image publishing failures caused by delays in the new npm version becoming available + +## v0.37.2 (2026-09-18) + +> Fix issues resuming AI conversations after truncated or stopped replies, and improve emoji recognition, skill entry points, and download safety. + +### ✨ Features + +- Recognize Douyin emoji codes in word frequency and language preference analysis so emoji names no longer appear as frequent words (by [@babybitter](https://github.com/babybitter)) + +### 🐛 Bug Fixes + +- Fix cases where truncated AI replies lost generated content or token usage, allowing saved conversations to be continued +- Save content received before an AI reply is stopped manually, preserving context for follow-up questions (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- Cancel context compression when an AI request is stopped to prevent further model calls and summary writes (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- Validate filenames in the download endpoint to prevent writes outside the downloads directory (by [@ttsdj](https://github.com/ttsdj)) + +### ♻️ Refactoring + +- Unify external Agent skills for import, conversion, and analysis, follow the user's language, and retain compatibility with import skill installation commands from released versions + +## v0.37.1 (2026-09-04) + +> Add contributor attribution, improve Docker startup, and fix AI tool loops, text statistics, and relationship graph resource usage. + +### ✨ Features + +- Include voice transcriptions in language preference, word frequency, and catchphrase analysis (by [@babybitter](https://github.com/babybitter)) +- 【CLI】Bundle the default Chinese NLP dictionary in Docker images to avoid downloading it on first startup (by [@KokerZhou](https://github.com/KokerZhou)) + +### 🐛 Bug Fixes + +- Fix duplicate final responses after AI tool calls reach the turn limit, tool errors recorded as successful, and duplicated context writes (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- Exclude system notifications and share placeholders from word frequency, language preference, and catchphrase statistics while preserving user emoji content (by [@babybitter](https://github.com/babybitter)) + ## v0.37.0 (2026-08-28) > Add cross-chat AI analysis and long-term memory, fix issues including import deduplication and failed AI replies, and improve Insights performance and the chat record viewer.
Cross-chat analysis remains experimental due to limited testing and will continue to improve in future releases. @@ -10,17 +109,17 @@ - Add long-term memory for managing preferences, details about yourself, contacts, and groups, with relevant entries loaded automatically for each question - Link long-term memories to source messages so AI-derived conclusions can be verified in the original conversation - Unify conversation lists, composers, and @ mention selection across global and per-chat AI conversations -- Let users resize the chat record viewer by dragging and remember the chosen width +- Let users resize the chat record viewer by dragging and remember the chosen width (by [@KokerZhou](https://github.com/KokerZhou)) - Move SQL Lab and debug tools into a unified More section on analysis pages -- 【CLI】Support starting the Web service over a Unix Domain Socket +- 【CLI】Support starting the Web service over a Unix Domain Socket (by [@KokerZhou](https://github.com/KokerZhou)) ### 🐛 Bug Fixes - Fix excessive deduplication when incrementally importing or merging WeFlow records and messages without stable IDs, preventing valid messages from being lost -- Fix error state, conversation list, and follow-up issues after an AI reply fails -- Prevent incomplete turns when saving or editing AI conversations, and allow editing only the latest user turn -- Fix long-conversation compression losing message boundaries or links to memory sources -- Fix AI services with the same configuration name loading the wrong credentials +- Fix error state, conversation list, and follow-up issues after an AI reply fails (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- Prevent incomplete turns when saving or editing AI conversations, and allow editing only the latest user turn (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- Fix long-conversation compression losing message boundaries or links to memory sources (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- Fix AI services with the same configuration name loading the wrong credentials (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) - Fix analysis pages reloading duplicate data or showing stale results after switching away and back - Improve time ranges, contact resolution, scan budgets, and truncation notices for cross-chat analysis @@ -41,7 +140,7 @@ ### 🐛 Bug Fixes -- Turn folder import into a dedicated button so it no longer competes with file selection +- Turn folder import into a dedicated button so it no longer competes with file selection (by [@kongjiyu](https://github.com/kongjiyu)) ### ⚡ Performance @@ -109,7 +208,7 @@ - Fix QQ automatic import matching and member mapping when UID/UIN values change, owner metadata is missing, or sessions have been merged, preventing duplicate sessions and incorrect member identities - Prevent merge imports from incorrectly removing messages when platform message IDs conflict or private-chat identity is incomplete, while preserving valid owner and session identity metadata -- Fix request handling, rapid switching, and multi-color mode in dynamic keyword rankings, preventing stale results and crashes caused by unknown types +- Fix request handling, rapid switching, and multi-color mode in dynamic keyword rankings, preventing stale results and crashes caused by unknown types (by [@kelaocai](https://github.com/kelaocai)) - Unify loading transitions across Insights, and restore annual activity calendar colors and relationship view controls ### ⚡ Performance @@ -125,7 +224,7 @@ - Use the Rust native parser for QQ Shuakami and QQ Chat Exporter imports to improve reliability with large exports - Add safe concurrent scheduling for multi-file imports, preventing concurrent writes to the same session while reporting per-file progress and failures - Add privacy-preserving anonymous product analytics that only report allowlisted environment and feature usage events -- 【CLI Web】Install the local semantic-index runtime on first use, while keeping it preinstalled in the Docker image +- 【CLI Web】Install the local semantic-index runtime on first use, while keeping it preinstalled in the Docker image (by [@kelaocai](https://github.com/kelaocai)) ### 🐛 Bug Fixes @@ -183,18 +282,18 @@ ### 🐛 Bug Fixes - Fix concurrent OPFS access, overview filter race conditions, word frequency totals, and relationship analysis filters in the browser app -- Bundle frontend icons locally to remove the runtime dependency on external icon services +- Bundle frontend icons locally to remove the runtime dependency on external icon services (by [@KokerZhou](https://github.com/KokerZhou)) - Align ChatLab JSONL auto-detection with format validation and reject unresolved owner metadata - 【Desktop】Prevent migrations from deleting the active data directory and reject nested or equivalent legacy and active data paths -- 【CLI】Persist user data and system state in Docker deployments, and bind published ports to localhost by default +- 【CLI】Persist user data and system state in Docker deployments, and bind published ports to localhost by default (by [@KokerZhou](https://github.com/KokerZhou)) ### 📝 Documentation -- Add a Docker deployment guide and clarify the benefits of AI analysis and local data privacy +- Add a Docker deployment guide and clarify the benefits of AI analysis and local data privacy (by [@KokerZhou](https://github.com/KokerZhou)) ### 👷 CI -- Publish ChatLab CLI Docker images for amd64 and arm64 +- Publish ChatLab CLI Docker images for amd64 and arm64 (by [@KokerZhou](https://github.com/KokerZhou)) ## v0.32.0 (2026-07-18) @@ -202,14 +301,14 @@ ### ✨ Features -- 【Desktop】Add an app lock with password protection, immediate locking, a startup unlock prompt, and automatic locking after inactivity +- 【Desktop】Add an app lock with password protection, immediate locking, a startup unlock prompt, and automatic locking after inactivity (by [@xyz-225648](https://github.com/xyz-225648)) ### 🐛 Bug Fixes - Keep old data after migrating the data directory and prompt for manual cleanup, preventing source files from being deleted during migrations or retries - Process large ChatLab JSONL files in streaming batches to prevent import freezes and preserve member avatars in merged imports - Unify multi-file batch imports across Desktop and CLI Web with sequential progress, cancellation, and result summaries -- 【Desktop】Add failed-attempt limits, cooldowns, and startup state protection to the app lock so refreshes or restarts cannot bypass it +- 【Desktop】Add failed-attempt limits, cooldowns, and startup state protection to the app lock so refreshes or restarts cannot bypass it (by [@xyz-225648](https://github.com/xyz-225648)) ## v0.31.3 (2026-07-17) @@ -253,7 +352,7 @@ - Align validation, deduplication, member inference, and dry-run results for Push imports across runtimes - Keep backfilled chat history in chronological order using timestamps and message IDs -- Fix QCE / QQ Chat Exporter private chats being detected as groups and creating a phantom “0” member +- Fix QCE / QQ Chat Exporter private chats being detected as groups and creating a phantom “0” member (by [@shuakami](https://github.com/shuakami)) ## v0.31.0 (2026-07-13) @@ -271,7 +370,7 @@ - Fix ChatLab JSON exports so they can be imported again while preserving member, owner, and alias metadata - Improve automatic incremental import deduplication, mixed message identity handling, and ChatLab reimport disambiguation -- Support bracketed timestamps and dotted AM/PM markers in WhatsApp TXT parsing +- Support bracketed timestamps and dotted AM/PM markers in WhatsApp TXT parsing (by [@hh7418695](https://github.com/hh7418695)) ### ♻️ Refactoring @@ -314,7 +413,7 @@ - Fix CSV export failures in SQL Lab - Preserve avatars when importing JSONL and other streamed member data, including incremental avatar imports - Open AI logs through the shared route so local CLI Web and Desktop behave consistently -- Refresh the overview identity card when the selected time range changes +- Refresh the overview identity card when the selected time range changes (by [@qikairo7](https://github.com/qikairo7)) ### 📝 Documentation @@ -445,8 +544,8 @@ ### 🐛 Bug Fixes - Fix multi-keyword message search -- Fix UX regression in word cloud dictionary loading -- Fix ranking label formatting and emoji cleanup +- Fix UX regression in word cloud dictionary loading (by [@qikairo7](https://github.com/qikairo7)) +- Fix ranking label formatting and emoji cleanup (by [@qikairo7](https://github.com/qikairo7)) - Add missing i18n text for the v8 database migration - [CLI] Fix missing session context forwarding and preprocessing in the tool adapter @@ -466,8 +565,8 @@ ### 🐛 Bug Fixes -- Fix catchphrase stats including platform reply message placeholders -- Fix word cloud text including media placeholders +- Fix catchphrase stats including platform reply message placeholders (by [@qikairo7](https://github.com/qikairo7)) +- Fix word cloud text including media placeholders (by [@qikairo7](https://github.com/qikairo7)) - Fix language-preference and word-frequency caches not being invalidated when the Jieba custom dictionary changes - Fix time-sensitive analytics cache entries not expiring daily - Fix timeline panel default scroll position and session jump not working @@ -555,7 +654,7 @@ - Fix assistant messages not filling the full chat area width - Improve the visual layout of the analysis plan generation block - Fix thinking-level selection not persisting across restarts -- Fix JSONL timestamp normalization causing incremental import failures +- Fix JSONL timestamp normalization causing incremental import failures (by [@cnYui](https://github.com/cnYui)) - Desktop: Fix `execute_sql` tool not being registered in the desktop tool registry ### test @@ -600,8 +699,8 @@ ### ✨ Features -- Add a slash-activated AI chart runtime that can generate and render ECharts charts in conversations -- Improve the AI chart result experience with clearer rendering states and support for more chart types +- Add a slash-activated AI chart runtime that can generate and render ECharts charts in conversations (by [@CEQ151](https://github.com/CEQ151)) +- Improve the AI chart result experience with clearer rendering states and support for more chart types (by [@CEQ151](https://github.com/CEQ151)) ### 🐛 Bug Fixes @@ -609,7 +708,7 @@ - Fix preset question chips and sidebar elements stacking incorrectly - CLI Web: Fix incomplete cleanup of the development backend lifecycle - Desktop: Register the chart tool for automatic skills so chart skills can run correctly -- Desktop: Refresh the Windows title bar overlay cache after theme changes and smooth its display +- Desktop: Refresh the Windows title bar overlay cache after theme changes and smooth its display (by [@CEQ151](https://github.com/CEQ151)) ### ♻️ Refactoring @@ -666,7 +765,7 @@ - Fix multiple edge cases in data directory and database migrations to prevent missing legacy columns or failed reads after path changes - Fix legacy data migration prompts appearing incorrectly when no legacy data exists or after directory changes -- Fix sidebar message counts not refreshing after incremental imports +- Fix sidebar message counts not refreshing after incremental imports (by [@CEQ151](https://github.com/CEQ151)) - Fix sidebar collapsed state being lost after refresh because it was stored only in sessionStorage - Fix fetch-models and validate buttons not being enabled when editing AI settings with a saved key - Fix stability issues in shared AI SSE streaming @@ -717,7 +816,7 @@ ### ✨ Features -- Add a Fork button to AI replies to branch the conversation from any point into an independent session +- Add a Fork button to AI replies to branch the conversation from any point into an independent session (by [@CEQ151](https://github.com/CEQ151)) - Add a per-model thinking level selector in the status bar, with the choice remembered per model slot - Extend thinking level controls with default and auto options, covering Kimi, Doubao, Gemini, and more model families - Split the message analysis view into Type Analysis and Time Analysis tabs, each with enriched statistical insight cards @@ -900,7 +999,7 @@ - Fix Web-mode issues around session indexes, CORS proxying, demo safeguards, and runtime errors - Fix the app version showing as empty in Web mode -- Fix AI Agent evidence retrieval, Web event streaming, and error formatting issues +- Fix AI Agent evidence retrieval, Web event streaming, and error formatting issues (by [@CEQ151](https://github.com/CEQ151)) - Fix inconsistencies between sync display and import logic - Fix ESM module resolution in CLI development mode - Fix cases where the CLI could not find existing desktop data after installation @@ -990,7 +1089,7 @@ ### ✨ Features -- Add session type filtering and selection when subscribing to sessions. +- Add session type filtering and selection when subscribing to sessions. (by [@xunchahaha](https://github.com/xunchahaha)) - Add paginated remote session discovery with on-demand loading. - Allow each data source to configure how many messages are fetched per request. @@ -1147,7 +1246,7 @@ ### 🐛 Bug Fixes - Fix inconsistency between WhatsApp time-parsing regex rules and line-matching regex rules. -- Fix compatibility issues when parsing WhatsApp 12-hour time formats and NNBSP characters. +- Fix compatibility issues when parsing WhatsApp 12-hour time formats and NNBSP characters. (by [@kongjiyu](https://github.com/kongjiyu)) ### 🔧 Chores @@ -1210,7 +1309,7 @@ - Improve AI chat styling and overall interaction flow. - Add FTS5 full-text search support with a quick search tool. - Trim search parameters in selected tools to reduce token usage. -- Add an E2E testing framework for Electron apps with port management and instance isolation. +- Add an E2E testing framework for Electron apps with port management and instance isolation. (by [@l17728](https://github.com/l17728)) ### 🐛 Bug Fixes @@ -1223,7 +1322,7 @@ ### test -- Add reusable smoke-test coverage for the E2E app launcher. +- Add reusable smoke-test coverage for the E2E app launcher. (by [@l17728](https://github.com/l17728)) ### 📝 Documentation @@ -1274,7 +1373,7 @@ ### 🐛 Bug Fixes -- Fix false positives in message deduplication and unify empty-string deduplication behavior +- Fix false positives in message deduplication and unify empty-string deduplication behavior (by [@lnexin](https://github.com/lnexin)) - Fix AI conversation flow issues and frontend type-check errors - Add a fallback default assistant for edge cases - Fix daily message trends not rendering @@ -1333,7 +1432,7 @@ - Add a chat-history preprocessing pipeline. - Add preprocessing settings UI and configuration management. -- Add session-based context timelines and runtime status for the Agent. +- Add session-based context timelines and runtime status for the Agent. (by [@n-WN](https://github.com/n-WN)) - Add an AI debug mode with improved log observability. ### 🐛 Bug Fixes @@ -1413,7 +1512,7 @@ ### 🐛 Bug Fixes - Fix inaccurate session index generation scope after incremental updates (fix #79). -- Fix non-chat SQLite files being incorrectly processed during migration and session scanning. +- Fix non-chat SQLite files being incorrectly processed during migration and session scanning. (by [@n-WN](https://github.com/n-WN)) ### ♻️ Refactoring @@ -1437,7 +1536,7 @@ ### 🐛 Bug Fixes -- Fix parsing issues for LINE chat logs. +- Fix parsing issues for LINE chat logs. (by [@haotien91](https://github.com/haotien91)) ### 📝 Documentation @@ -1449,9 +1548,9 @@ ### ✨ Features -- Add a data directory location setting +- Add a data directory location setting (by [@xunchahaha](https://github.com/xunchahaha)) - Optimize data directory migration logic -- Add a confirmation dialog for directory switching +- Add a confirmation dialog for directory switching (by [@xunchahaha](https://github.com/xunchahaha)) - Improve parser logic (WeFlow / Echotrace) ### 🐛 Bug Fixes @@ -1459,12 +1558,12 @@ - Fix crashes on Windows when custom filtering processes large message volumes - Fix conversations ending early when third-party relay APIs call tool_call - Fix some WhatsApp chat logs not being detected correctly -- Fix manage page header stacking above settings +- Fix manage page header stacking above settings (by [@xunchahaha](https://github.com/xunchahaha)) ### ♻️ Refactoring - Refactor session query module -- Improve migration logging +- Improve migration logging (by [@xunchahaha](https://github.com/xunchahaha)) ## v0.9.2 (2026-02-02) @@ -1603,7 +1702,7 @@ - Support incremental import - Support batch import - Style improvements -- Support native window controls and theme sync on Windows (#31) +- Support native window controls and theme sync on Windows (#31) (by [@JiQingzhe2004](https://github.com/JiQingzhe2004)) ### 🔧 Chores @@ -1697,7 +1796,7 @@ - Support proxy configuration (resolve #7) - Improve AI model UI interactions - Add API tutorial for user configuration -- Add two free GLM models; add Doubao provider and latest models +- Add two free GLM models; add Doubao provider and latest models (by [@JiQingzhe2004](https://github.com/JiQingzhe2004)) - AI replies no longer output think content ## v0.1.3 (2025-12-25) diff --git a/changelogs/ja.json b/changelogs/ja.json index 0597fd75f..8fa3613ec 100644 --- a/changelogs/ja.json +++ b/changelogs/ja.json @@ -1,4 +1,238 @@ [ + { + "version": "0.37.7", + "date": "2026-10-30", + "summary": "Discord の DM 判定と再インポート時の会話照合を修正し、MCP の読み取り専用クエリと安全性チェックを改善しました。", + "changes": [ + { + "type": "fix", + "items": [ + { + "text": "Discord の DM がグループチャットとして認識される問題を修正し、同じチャンネルの再インポートや差分インポートで既存の会話と正しく照合できるようにしました。" + }, + { + "text": "【MCP】クエリツールに読み取り専用の注釈を追加し、AI クライアントがツールの権限を正しく認識できるようにしました。", + "contributors": ["moesnow"] + }, + { + "text": "【MCP】クエリ時にデータベースの移行やデータディレクトリの互換性マーカーの更新を行わないようにしました。古いデータベースは、先にデスクトップ版または CLI で更新する必要があります。", + "contributors": ["moesnow"] + }, + { + "text": "【MCP】会話のファイルパスと SQL の検証を強化し、データディレクトリ外のファイルへのアクセスや、クエリ以外の操作の実行を防ぐようにしました。", + "contributors": ["moesnow"] + } + ] + } + ] + }, + { + "version": "0.37.6", + "date": "2026-10-22", + "summary": "認識できない形式のインポート時の案内を改善し、メモリーを会話の初期表示ページに設定できるようにしました。MCP の配列パラメーターの型も修正しました。", + "changes": [ + { + "type": "fix", + "items": [ + { + "text": "チャット記録の形式を認識できない場合に、説明と AI 変換ガイドを表示するようにしました。単一ファイル・一括インポートと多言語表示に対応しています。" + }, + { + "text": "会話を開いたときに最初に表示するページとして、メモリーを選択できるようにしました。", + "contributors": ["KokerZhou"] + }, + { + "text": "【MCP】文字列・数値・真偽値の配列パラメーターが文字列として扱われ、ツールの呼び出しに失敗する問題を修正しました。", + "contributors": ["moesnow"] + } + ] + } + ] + }, + { + "version": "0.37.5", + "date": "2026-09-28", + "summary": "右側パネルの操作を統一し、デスクトップ版に表示倍率の設定を追加。会話の読み込み、データソースの設定、プロキシ経由のモデルダウンロードの問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "チャット履歴・連絡先の詳細・チャットの話題を表示する右側パネルを統一。ヘッダーからの開閉とドラッグによる幅調整に対応し、閉じる前の閲覧位置を保持" + }, + { + "text": "【デスクトップ版】表示倍率の設定を追加" + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "一部の Windows 環境で、アップグレード後にデータディレクトリの互換性マーカーを不要に書き直すことで会話一覧を読み込めなくなる問題を修正" + }, + { + "text": "リモートデータソースの設定の読み込み・保存に失敗した際、既存の設定を上書きしたり成功と誤って表示したりする問題を修正。元のファイルを保持し、明確なエラーを表示" + }, + { + "text": "プロキシ経由でダウンロードしたローカル埋め込みモデルがキャッシュに保存されず、読み込みに失敗する問題を修正", + "contributors": ["huaqian0226"] + } + ] + } + ] + }, + { + "version": "0.37.4", + "date": "2026-09-23", + "summary": "AI ツール実行結果のコンテキスト管理を改善し、Qwen での話題の要約と会話一覧の読み込みエラー表示を修正しました。", + "changes": [ + { + "type": "fix", + "items": [ + { + "text": "AI ツールの複数回・並列実行で結果が蓄積し、モデルのコンテキスト上限を超える問題を修正。会話履歴は保持したまま、リクエストごとにツールの実行結果を短縮", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "一部の Qwen モデルで思考モードが既定で有効になり、話題の要約を生成できない問題を修正" + }, + { + "text": "話題の要約がモデルの出力上限に達した際に明確なメッセージを表示し、無駄な再試行による追加消費を防止" + }, + { + "text": "会話一覧の読み込み失敗時にエラーの詳細を省略せず表示し、アプリのログにも完全なスタックトレースを記録。アップグレード後の権限エラーなどを調査しやすく改善" + }, + { + "text": "AI の単語頻度・返信速度分析ツールに不足していた時間パラメーターの英語説明を追加" + }, + { + "text": "【CLI】Docker イメージに npm と同じ配布パッケージを使用し、npm への反映遅延によるイメージ公開の失敗を防止" + } + ] + } + ] + }, + { + "version": "0.37.3", + "date": "2026-09-22", + "summary": "AI 分析の期間指定、Qwen モデルとの互換性、会話再開に関する問題を修正し、多言語対応とメッセージの前後関係の取得を改善しました。", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "AI ツールの中国語・英語の説明を整備し、デスクトップ版、CLI Web、MCP の言語処理を統一", + "contributors": ["Zycheng114514"] + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "AI の単語頻度・返信速度の分析で選択した期間が反映されない問題を修正し、質問で指定した期間、画面のフィルター、既定の期間の優先順位を明確化", + "contributors": ["liuyy2"] + }, + { + "text": "一部の Qwen モデルで AI チャットや話題の要約を行う際に、メッセージのロールの互換性によりエラーが発生する問題を修正" + }, + { + "text": "過去のメッセージを追加で取り込んだ後に前後のメッセージが正しく取得されない問題を修正し、取得処理を高速化", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "言語傾向カードに不足していた日本語ラベルを追加", + "contributors": ["Zycheng114514"] + }, + { + "text": "【CLI】AI との会話を再開する際に完了済みのツール実行結果が欠落する問題を修正し、実行結果とエラー状態を後続の会話に引き継ぐように改善", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "【CLI】npm の新バージョンがまだ取得できないために Docker イメージの公開が失敗する問題を修正" + } + ] + } + ] + }, + { + "version": "0.37.2", + "date": "2026-09-18", + "summary": "AI の応答が途中で切れたり停止されたりした後の会話再開に関する問題を修正し、絵文字の認識、スキルの呼び出し方法、ダウンロード時の安全性を改善しました。", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "単語頻度と言語傾向の分析で抖音(Douyin)の絵文字コードを認識し、絵文字名が頻出語に混ざるのを防止", + "contributors": ["babybitter"] + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "一部のチャット画面やコマンドで AI の応答が途中で切れた際に、生成済みの内容やトークン使用量が失われる問題を修正し、保存後に会話を続けられるように改善" + }, + { + "text": "AI の応答を手動で停止すると生成済みの内容が保存されず、次の質問で文脈が失われることがある問題を修正", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "AI リクエストの停止後もコンテキスト圧縮が続く問題を修正し、余分なモデル呼び出しや要約の書き込みを防止", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "ダウンロード API のファイル名検証を修正し、ダウンロードフォルダー外への書き込みを防止", + "contributors": ["ttsdj"] + } + ] + }, + { + "type": "refactor", + "items": [ + { + "text": "外部 Agent のインポート・変換・分析スキルの呼び出し方法を統一し、ユーザーの言語で応答するように変更。リリース済みバージョンの旧インポートスキルのインストールコマンドも引き続きサポート" + } + ] + } + ] + }, + { + "version": "0.37.1", + "date": "2026-09-04", + "summary": "コントリビューター表記を追加し、Docker の起動を改善。AI ツールのループ、テキスト統計、関係グラフのリソース使用に関する問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "音声の文字起こし内容を言語傾向、単語頻度、口癖の分析対象に追加", + "contributors": ["babybitter"] + }, + { + "text": "【CLI】Docker イメージに既定の中国語形態素解析辞書を同梱し、初回起動時のダウンロードを不要に変更", + "contributors": ["KokerZhou"] + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "AI ツールの呼び出しが上限に達した際の最終回答の重複、ツールエラーの成功扱い、コンテキストの重複書き込みを修正", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "ユーザーの絵文字コンテンツを保持しつつ、システム通知や共有プレースホルダーが単語頻度、言語傾向、口癖の統計に含まれる問題を修正", + "contributors": ["babybitter"] + } + ] + } + ] + }, { "version": "0.37.0", "date": "2026-08-28", @@ -11,19 +245,37 @@ "好み、自分について、連絡先、グループの情報を管理できる長期メモリを追加し、質問に応じて関連する内容を自動的に参照", "長期メモリを参照元メッセージに関連付け、AI が生成した結論を元の会話で確認できるように改善", "グローバル AI チャットと各チャット内 AI の会話一覧、入力欄、@ メンション選択を統一", - "チャット履歴ビューアーをドラッグでリサイズし、選択した幅を保持できるように改善", + { + "text": "チャット履歴ビューアーをドラッグでリサイズし、選択した幅を保持できるように改善", + "contributors": ["KokerZhou"] + }, "分析ページの SQL ラボとデバッグツールを「その他」にまとめて配置", - "【CLI】Unix Domain Socket 経由で Web サービスを起動できる機能を追加" + { + "text": "【CLI】Unix Domain Socket 経由で Web サービスを起動できる機能を追加", + "contributors": ["KokerZhou"] + } ] }, { "type": "fix", "items": [ "WeFlow および安定したメッセージ ID がないチャット履歴の差分インポートや統合で重複排除が過剰に働き、有効なメッセージが失われる問題を修正", - "AI の応答に失敗した後、エラー状態、会話一覧、次の質問が正しく処理されないことがある問題を修正", - "AI 会話の保存や編集で不完全なターンが残る問題を修正し、編集対象を直近の質問のみに制限", - "長い会話の圧縮時に、メッセージ境界やメモリの参照元リンクが失われることがある問題を修正", - "同じ名前の AI サービス設定で誤った認証情報が読み込まれる問題を修正", + { + "text": "AI の応答に失敗した後、エラー状態、会話一覧、次の質問が正しく処理されないことがある問題を修正", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "AI 会話の保存や編集で不完全なターンが残る問題を修正し、編集対象を直近の質問のみに制限", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "長い会話の圧縮時に、メッセージ境界やメモリの参照元リンクが失われることがある問題を修正", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "同じ名前の AI サービス設定で誤った認証情報が読み込まれる問題を修正", + "contributors": ["Sanssssssssssssssss"] + }, "分析ページを切り替えて戻った際に重複して読み込んだり、インポート前の古いデータが表示されたりする問題を修正", "クロスチャット分析の期間指定、連絡先の解決、スキャン上限、結果省略時の案内を改善" ] @@ -50,7 +302,12 @@ }, { "type": "fix", - "items": ["フォルダーのインポートを独立したボタンに変更し、ファイル選択と操作が競合しないように改善"] + "items": [ + { + "text": "フォルダーのインポートを独立したボタンに変更し、ファイル選択と操作が競合しないように改善", + "contributors": ["kongjiyu"] + } + ] }, { "type": "perf", @@ -145,7 +402,10 @@ "items": [ "QQ の自動インポートで UID/UIN の変更、owner 情報の欠落、セッションのマージがある場合の照合とメンバー対応を修正し、セッションの重複やメンバー識別の不整合を防止", "プラットフォームメッセージ ID の競合や個人チャットの識別情報不足により、マージ時にメッセージが誤って削除される問題を修正。有効な owner とセッション識別情報も保持", - "動的キーワードランキングのリクエスト処理、高速な表示切り替え、マルチカラーモードを修正し、古い結果の表示や未知の種類によるクラッシュを防止", + { + "text": "動的キーワードランキングのリクエスト処理、高速な表示切り替え、マルチカラーモードを修正し、古い結果の表示や未知の種類によるクラッシュを防止", + "contributors": ["kelaocai"] + }, "インサイトの読み込み切り替えを統一し、年間アクティビティカレンダーの配色と関係ビューの切り替え操作を復元" ] }, @@ -168,7 +428,10 @@ "QQ Shuakami / QQ Chat Exporter のインポートに Rust ネイティブパーサーを導入し、大容量エクスポートの処理を安定化", "複数ファイルのインポートに安全な並行スケジューリングを追加し、同一セッションへの同時書き込みを防止。ファイルごとの進捗と失敗内容も表示", "許可リストに含まれる実行環境と機能利用イベントだけを送信する、プライバシーに配慮した匿名利用統計を追加", - "【CLI Web】ローカルセマンティックインデックスの実行コンポーネントを初回利用時に自動インストール。Docker イメージには引き続き必要なコンポーネントを同梱" + { + "text": "【CLI Web】ローカルセマンティックインデックスの実行コンポーネントを初回利用時に自動インストール。Docker イメージには引き続き必要なコンポーネントを同梱", + "contributors": ["kelaocai"] + } ] }, { @@ -247,19 +510,35 @@ "type": "fix", "items": [ "ブラウザ版における OPFS の同時アクセス、概要フィルターの競合、単語頻度集計、関係分析フィルターの問題を修正", - "フロントエンドのアイコンをローカルに同梱し、実行時の外部アイコンサービス依存を解消", + { + "text": "フロントエンドのアイコンをローカルに同梱し、実行時の外部アイコンサービス依存を解消", + "contributors": ["KokerZhou"] + }, "ChatLab JSONL の自動判定と形式検証ルールを統一し、解決できない owner 情報を拒否", "【デスクトップ版】移行処理による使用中のデータディレクトリ削除を防止し、新旧ディレクトリの入れ子や同一パス指定を拒否", - "【CLI】Docker デプロイでユーザーデータとシステム状態を永続化し、公開ポートをデフォルトで localhost のみにバインド" + { + "text": "【CLI】Docker デプロイでユーザーデータとシステム状態を永続化し、公開ポートをデフォルトで localhost のみにバインド", + "contributors": ["KokerZhou"] + } ] }, { "type": "docs", - "items": ["Docker デプロイガイドを追加し、AI 分析の利点とローカルプライバシーの説明を改善"] + "items": [ + { + "text": "Docker デプロイガイドを追加し、AI 分析の利点とローカルプライバシーの説明を改善", + "contributors": ["KokerZhou"] + } + ] }, { "type": "ci", - "items": ["amd64 / arm64 向けの ChatLab CLI Docker イメージを公開"] + "items": [ + { + "text": "amd64 / arm64 向けの ChatLab CLI Docker イメージを公開", + "contributors": ["KokerZhou"] + } + ] } ] }, @@ -271,7 +550,10 @@ { "type": "feat", "items": [ - "【デスクトップ版】パスワード保護、即時ロック、起動時のロック解除要求、無操作時の自動ロックに対応したアプリロックを追加" + { + "text": "【デスクトップ版】パスワード保護、即時ロック、起動時のロック解除要求、無操作時の自動ロックに対応したアプリロックを追加", + "contributors": ["xyz-225648"] + } ] }, { @@ -280,7 +562,10 @@ "データディレクトリの移行後も旧データを残し、手動での確認・削除を案内することで、移行や再試行時の移行元ファイルの誤削除を防止", "大容量の ChatLab JSONL をストリーミングで分割処理し、インポートの停止を防ぐとともに、マージインポート時のメンバー画像を保持", "デスクトップ版と CLI Web の複数ファイル一括インポート処理を統一し、順次処理の進捗表示、キャンセル、結果サマリーに対応", - "【デスクトップ版】アプリロックに失敗回数制限、クールダウン、起動時の状態保護を追加し、再読み込みや再起動による回避を防止" + { + "text": "【デスクトップ版】アプリロックに失敗回数制限、クールダウン、起動時の状態保護を追加し、再読み込みや再起動による回避を防止", + "contributors": ["xyz-225648"] + } ] } ] @@ -343,7 +628,10 @@ "items": [ "各環境の Push インポートで検証、重複排除、メンバー推定、dry-run の結果を統一し、事前確認と実際の書き込みの差異を解消", "過去メッセージの追加インポート後も、タイムスタンプとメッセージ ID に基づく安定した時系列順で表示", - "QCE / QQ Chat Exporter の個人チャットがグループと誤判定され、「0」メンバーが生成される問題を修正" + { + "text": "QCE / QQ Chat Exporter の個人チャットがグループと誤判定され、「0」メンバーが生成される問題を修正", + "contributors": ["shuakami"] + } ] } ] @@ -368,7 +656,10 @@ "items": [ "ChatLab JSON の書き出しをそのまま再インポートできるよう修正し、メンバー、owner、別名のメタデータを保持", "自動増分インポートの重複排除、混在するメッセージ ID の判定、ChatLab 再インポートの識別を強化", - "WhatsApp TXT 解析で括弧付きタイムスタンプとドット付き AM/PM 表記に対応" + { + "text": "WhatsApp TXT 解析で括弧付きタイムスタンプとドット付き AM/PM 表記に対応", + "contributors": ["hh7418695"] + } ] }, { @@ -427,7 +718,10 @@ "SQL Lab で CSV エクスポートに失敗する問題を修正", "JSONL などのストリーミングされたメンバー情報をインポートする際にアバターを保持し、増分インポートでも新しいアバターを取り込めるよう修正", "AI ログを共有ルートから開くようにし、ローカルの CLI Web とデスクトップで挙動を統一", - "概要の本人カードが選択中の期間に合わせて更新されるよう修正" + { + "text": "概要の本人カードが選択中の期間に合わせて更新されるよう修正", + "contributors": ["qikairo7"] + } ] }, { @@ -596,8 +890,14 @@ "type": "fix", "items": [ "複数キーワードによるメッセージ検索を修正", - "ワードクラウドの辞書読み込みにおける UX 回帰を修正", - "ランキングラベルと絵文字クリーニングの問題を修正", + { + "text": "ワードクラウドの辞書読み込みにおける UX 回帰を修正", + "contributors": ["qikairo7"] + }, + { + "text": "ランキングラベルと絵文字クリーニングの問題を修正", + "contributors": ["qikairo7"] + }, "v8 データベース移行時の i18n テキスト欠落を修正", "【CLI】ツールアダプターでのセッションコンテキスト転送と前処理の欠落を修正" ] @@ -624,8 +924,14 @@ { "type": "fix", "items": [ - "頻出ワード統計にプラットフォームの返信プレースホルダーが含まれる問題を修正", - "ワードクラウドのテキストにメディアプレースホルダーが含まれる問題を修正", + { + "text": "頻出ワード統計にプラットフォームの返信プレースホルダーが含まれる問題を修正", + "contributors": ["qikairo7"] + }, + { + "text": "ワードクラウドのテキストにメディアプレースホルダーが含まれる問題を修正", + "contributors": ["qikairo7"] + }, "Jieba カスタム辞書変更後に言語設定キャッシュと単語頻度キャッシュが無効化されない問題を修正", "時間依存の分析キャッシュが1日ごとに期限切れにならない問題を修正", "タイムラインパネルのデフォルトスクロール位置とセッションジャンプが機能しない問題を修正", @@ -734,7 +1040,10 @@ "AI アシスタントのメッセージがチャット領域の幅全体を埋めない問題を修正しました", "分析プラン生成ブロックの表示スタイルを改善しました", "再起動後に思考レベルの選択が保持されない問題を修正しました", - "JSONL のタイムスタンプ正規化により差分インポートが失敗する問題を修正しました", + { + "text": "JSONL のタイムスタンプ正規化により差分インポートが失敗する問題を修正しました", + "contributors": ["cnYui"] + }, "【デスクトップ】execute_sql ツールがデスクトップのツールレジストリに登録されない問題を修正しました" ] }, @@ -793,8 +1102,14 @@ { "type": "feat", "items": [ - "スラッシュ操作で起動できる AI グラフ実行環境を追加し、会話内で ECharts グラフを生成・表示できるようにしました", - "AI グラフ結果の表示体験を改善し、レンダリング状態と複数のグラフ種別への対応を追加しました" + { + "text": "スラッシュ操作で起動できる AI グラフ実行環境を追加し、会話内で ECharts グラフを生成・表示できるようにしました", + "contributors": ["CEQ151"] + }, + { + "text": "AI グラフ結果の表示体験を改善し、レンダリング状態と複数のグラフ種別への対応を追加しました", + "contributors": ["CEQ151"] + } ] }, { @@ -804,7 +1119,10 @@ "プリセット質問チップとサイドバー要素の重なり順が崩れる問題を修正しました", "【CLI Web】開発バックエンドのライフサイクル cleanup が不完全になる問題を修正しました", "【デスクトップ】自動スキルでグラフツールが登録されず、グラフスキルを正しく実行できない問題を修正しました", - "【デスクトップ】Windows のタイトルバー overlay がテーマ切り替え後にキャッシュを更新せず、表示が不安定になる問題を修正しました" + { + "text": "【デスクトップ】Windows のタイトルバー overlay がテーマ切り替え後にキャッシュを更新せず、表示が不安定になる問題を修正しました", + "contributors": ["CEQ151"] + } ] }, { @@ -879,7 +1197,10 @@ "items": [ "データディレクトリ移行とデータベース移行の複数の境界ケースを修正し、旧データベースの列不足やパス切り替え後の読み取り失敗を防止", "旧データが存在しない場合やディレクトリ変更後にも移行案内が誤って表示される問題を修正", - "増分インポート後にサイドバーのメッセージ数が更新されない問題を修正", + { + "text": "増分インポート後にサイドバーのメッセージ数が更新されない問題を修正", + "contributors": ["CEQ151"] + }, "サイドバーの折りたたみ状態が sessionStorage のみに保存され、再読み込み後に失われる問題を修正", "保存済みキーで AI 設定を編集する際、モデル取得と検証ボタンが正しく有効化されない問題を修正", "共有 AI SSE ストリーミングの安定性に関する問題を修正", @@ -946,7 +1267,10 @@ { "type": "feat", "items": [ - "AI返答の任意の箇所から会話を分岐できるFork機能を追加", + { + "text": "AI返答の任意の箇所から会話を分岐できるFork機能を追加", + "contributors": ["CEQ151"] + }, "ステータスバーに推論強度セレクターを追加し、モデルごとに思考レベルを記憶・切替可能に", "推論レベル制御にdefault/autoオプションを追加し、Kimi・Doubao・Geminiなど多数のモデルファミリーに対応", "メッセージ分析ビューを「タイプ分析」と「時間分析」の2タブに分割し、統計インサイトカードを強化", @@ -1174,7 +1498,10 @@ "items": [ "Web モードのセッションインデックス、CORS プロキシ、Demo 保護、実行時エラーに関する問題を修正", "Web モードでアプリのバージョンが空表示になる問題を修正", - "AI Agent の根拠取得、Web 側イベントストリーム、エラー整形に関する問題を修正", + { + "text": "AI Agent の根拠取得、Web 側イベントストリーム、エラー整形に関する問題を修正", + "contributors": ["CEQ151"] + }, "同期表示とインポート処理の不整合を修正", "CLI 開発モードでの ESM モジュール解決問題を修正", "CLI インストール後に既存のデスクトップ版データを見つけられない場合がある問題を修正", @@ -1292,7 +1619,10 @@ { "type": "feat", "items": [ - "会話を購読する際に、種類で絞り込んで選択できるようにしました。", + { + "text": "会話を購読する際に、種類で絞り込んで選択できるようにしました。", + "contributors": ["xunchahaha"] + }, "リモート会話のページ分割取得と、必要に応じた追加読み込みに対応しました。", "データソースごとに、1 回の取得で読み込むメッセージ件数を設定できるようにしました。" ] @@ -1494,7 +1824,10 @@ "type": "fix", "items": [ "WhatsApp の時刻解析正規表現と行マッチ正規表現の厳密さが一致しない問題を修正", - "WhatsApp の12時間表記時刻と NNBSP 文字の解析互換性問題を修正" + { + "text": "WhatsApp の12時間表記時刻と NNBSP 文字の解析互換性問題を修正", + "contributors": ["kongjiyu"] + } ] }, { @@ -1573,7 +1906,10 @@ "AI 会話のスタイルと全体の操作体験を改善", "FTS5 全文検索に対応し、クイック検索ツールを追加", "一部ツールの検索パラメータを整理し、token 消費を削減", - "Electron アプリ向けに、ポート管理とインスタンス分離に対応した E2E テスト基盤を追加" + { + "text": "Electron アプリ向けに、ポート管理とインスタンス分離に対応した E2E テスト基盤を追加", + "contributors": ["l17728"] + } ] }, { @@ -1589,7 +1925,12 @@ }, { "type": "test", - "items": ["再利用可能な E2E ランチャーのスモークテストを追加"] + "items": [ + { + "text": "再利用可能な E2E ランチャーのスモークテストを追加", + "contributors": ["l17728"] + } + ] }, { "type": "docs", @@ -1651,7 +1992,10 @@ { "type": "fix", "items": [ - "メッセージ重複判定の誤検知を修正し、空文字列の重複排除ルールを統一", + { + "text": "メッセージ重複判定の誤検知を修正し、空文字列の重複排除ルールを統一", + "contributors": ["lnexin"] + }, "AI会話フローの不具合とフロントエンドのtype-checkエラーを修正", "例外時のための既定assistantフォールバックを追加", "日次メッセージ推移が表示されない問題を修正" @@ -1718,7 +2062,10 @@ "items": [ "チャット履歴の前処理パイプラインを追加", "前処理設定画面と設定管理機能を追加", - "Agent でセッション単位のコンテキストタイムラインと実行状態を表示", + { + "text": "Agent でセッション単位のコンテキストタイムラインと実行状態を表示", + "contributors": ["n-WN"] + }, "AI デバッグモードを追加し、ログの可観測性を向上" ] }, @@ -1821,7 +2168,10 @@ "type": "fix", "items": [ "増分更新後にセッション索引の生成範囲が不正確になる問題を修正(fix #79)", - "移行やセッション走査時に非チャット用 SQLite ファイルを誤処理する問題を修正" + { + "text": "移行やセッション走査時に非チャット用 SQLite ファイルを誤処理する問題を修正", + "contributors": ["n-WN"] + } ] }, { @@ -1851,7 +2201,12 @@ }, { "type": "fix", - "items": ["LINE チャット履歴の解析問題を修正"] + "items": [ + { + "text": "LINE チャット履歴の解析問題を修正", + "contributors": ["haotien91"] + } + ] }, { "type": "docs", @@ -1867,9 +2222,15 @@ { "type": "feat", "items": [ - "設定画面にデータディレクトリ位置の設定項目を追加", + { + "text": "設定画面にデータディレクトリ位置の設定項目を追加", + "contributors": ["xunchahaha"] + }, "データ保存先の移行ロジックを改善", - "ディレクトリ切り替え時の確認ダイアログを追加", + { + "text": "ディレクトリ切り替え時の確認ダイアログを追加", + "contributors": ["xunchahaha"] + }, "パーサーロジックを改善(WeFlow / Echotrace)" ] }, @@ -1879,12 +2240,21 @@ "Windows で大量メッセージを絞り込むとクラッシュする問題を修正", "外部中継 API が tool_call を返した際に会話が異常終了する問題を修正", "一部の WhatsApp 履歴を正しく認識できない問題を修正", - "管理画面のヘッダー階層表示の問題を修正" + { + "text": "管理画面のヘッダー階層表示の問題を修正", + "contributors": ["xunchahaha"] + } ] }, { "type": "refactor", - "items": ["session 検索モジュールを再構成", "移行ログ出力を改善"] + "items": [ + "session 検索モジュールを再構成", + { + "text": "移行ログ出力を改善", + "contributors": ["xunchahaha"] + } + ] } ] }, @@ -2067,7 +2437,10 @@ "増分インポートに対応", "一括インポートに対応", "スタイルを改善", - "Windows でネイティブウィンドウ操作とテーマ同期に対応(#31)" + { + "text": "Windows でネイティブウィンドウ操作とテーマ同期に対応(#31)", + "contributors": ["JiQingzhe2004"] + } ] }, { @@ -2185,7 +2558,10 @@ "プロキシ設定に対応(resolve #7)", "AI モデル画面の操作性を改善", "ユーザー設定 API のチュートリアルを追加", - "無料の GLM モデル 2 種を追加し、Doubao プロバイダーと最新モデルを追加", + { + "text": "無料の GLM モデル 2 種を追加し、Doubao プロバイダーと最新モデルを追加", + "contributors": ["JiQingzhe2004"] + }, "AI 応答で think 内容を出力しないよう調整" ] } diff --git a/changelogs/ja.md b/changelogs/ja.md index 3d97d262b..cd1fc9f40 100644 --- a/changelogs/ja.md +++ b/changelogs/ja.md @@ -1,5 +1,104 @@ # 変更履歴 +## v0.37.7 (2026-10-30) + +> Discord の DM 判定と再インポート時の会話照合を修正し、MCP の読み取り専用クエリと安全性チェックを改善しました。 + +### 🐛 バグ修正 + +- Discord の DM がグループチャットとして認識される問題を修正し、同じチャンネルの再インポートや差分インポートで既存の会話と正しく照合できるようにしました。 +- 【MCP】クエリツールに読み取り専用の注釈を追加し、AI クライアントがツールの権限を正しく認識できるようにしました。 (by [@moesnow](https://github.com/moesnow)) +- 【MCP】クエリ時にデータベースの移行やデータディレクトリの互換性マーカーの更新を行わないようにしました。古いデータベースは、先にデスクトップ版または CLI で更新する必要があります。 (by [@moesnow](https://github.com/moesnow)) +- 【MCP】会話のファイルパスと SQL の検証を強化し、データディレクトリ外のファイルへのアクセスや、クエリ以外の操作の実行を防ぐようにしました。 (by [@moesnow](https://github.com/moesnow)) + +## v0.37.6 (2026-10-22) + +> 認識できない形式のインポート時の案内を改善し、メモリーを会話の初期表示ページに設定できるようにしました。MCP の配列パラメーターの型も修正しました。 + +### 🐛 バグ修正 + +- チャット記録の形式を認識できない場合に、説明と AI 変換ガイドを表示するようにしました。単一ファイル・一括インポートと多言語表示に対応しています。 +- 会話を開いたときに最初に表示するページとして、メモリーを選択できるようにしました。 (by [@KokerZhou](https://github.com/KokerZhou)) +- 【MCP】文字列・数値・真偽値の配列パラメーターが文字列として扱われ、ツールの呼び出しに失敗する問題を修正しました。 (by [@moesnow](https://github.com/moesnow)) + +## v0.37.5 (2026-09-28) + +> 右側パネルの操作を統一し、デスクトップ版に表示倍率の設定を追加。会話の読み込み、データソースの設定、プロキシ経由のモデルダウンロードの問題を修正しました。 + +### ✨ 新機能 + +- チャット履歴・連絡先の詳細・チャットの話題を表示する右側パネルを統一。ヘッダーからの開閉とドラッグによる幅調整に対応し、閉じる前の閲覧位置を保持 +- 【デスクトップ版】表示倍率の設定を追加 + +### 🐛 バグ修正 + +- 一部の Windows 環境で、アップグレード後にデータディレクトリの互換性マーカーを不要に書き直すことで会話一覧を読み込めなくなる問題を修正 +- リモートデータソースの設定の読み込み・保存に失敗した際、既存の設定を上書きしたり成功と誤って表示したりする問題を修正。元のファイルを保持し、明確なエラーを表示 +- プロキシ経由でダウンロードしたローカル埋め込みモデルがキャッシュに保存されず、読み込みに失敗する問題を修正 (by [@huaqian0226](https://github.com/huaqian0226)) + +## v0.37.4 (2026-09-23) + +> AI ツール実行結果のコンテキスト管理を改善し、Qwen での話題の要約と会話一覧の読み込みエラー表示を修正しました。 + +### 🐛 バグ修正 + +- AI ツールの複数回・並列実行で結果が蓄積し、モデルのコンテキスト上限を超える問題を修正。会話履歴は保持したまま、リクエストごとにツールの実行結果を短縮 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 一部の Qwen モデルで思考モードが既定で有効になり、話題の要約を生成できない問題を修正 +- 話題の要約がモデルの出力上限に達した際に明確なメッセージを表示し、無駄な再試行による追加消費を防止 +- 会話一覧の読み込み失敗時にエラーの詳細を省略せず表示し、アプリのログにも完全なスタックトレースを記録。アップグレード後の権限エラーなどを調査しやすく改善 +- AI の単語頻度・返信速度分析ツールに不足していた時間パラメーターの英語説明を追加 +- 【CLI】Docker イメージに npm と同じ配布パッケージを使用し、npm への反映遅延によるイメージ公開の失敗を防止 + +## v0.37.3 (2026-09-22) + +> AI 分析の期間指定、Qwen モデルとの互換性、会話再開に関する問題を修正し、多言語対応とメッセージの前後関係の取得を改善しました。 + +### ✨ 新機能 + +- AI ツールの中国語・英語の説明を整備し、デスクトップ版、CLI Web、MCP の言語処理を統一 (by [@Zycheng114514](https://github.com/Zycheng114514)) + +### 🐛 バグ修正 + +- AI の単語頻度・返信速度の分析で選択した期間が反映されない問題を修正し、質問で指定した期間、画面のフィルター、既定の期間の優先順位を明確化 (by [@liuyy2](https://github.com/liuyy2)) +- 一部の Qwen モデルで AI チャットや話題の要約を行う際に、メッセージのロールの互換性によりエラーが発生する問題を修正 +- 過去のメッセージを追加で取り込んだ後に前後のメッセージが正しく取得されない問題を修正し、取得処理を高速化 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 言語傾向カードに不足していた日本語ラベルを追加 (by [@Zycheng114514](https://github.com/Zycheng114514)) +- 【CLI】AI との会話を再開する際に完了済みのツール実行結果が欠落する問題を修正し、実行結果とエラー状態を後続の会話に引き継ぐように改善 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 【CLI】npm の新バージョンがまだ取得できないために Docker イメージの公開が失敗する問題を修正 + +## v0.37.2 (2026-09-18) + +> AI の応答が途中で切れたり停止されたりした後の会話再開に関する問題を修正し、絵文字の認識、スキルの呼び出し方法、ダウンロード時の安全性を改善しました。 + +### ✨ 新機能 + +- 単語頻度と言語傾向の分析で抖音(Douyin)の絵文字コードを認識し、絵文字名が頻出語に混ざるのを防止 (by [@babybitter](https://github.com/babybitter)) + +### 🐛 バグ修正 + +- 一部のチャット画面やコマンドで AI の応答が途中で切れた際に、生成済みの内容やトークン使用量が失われる問題を修正し、保存後に会話を続けられるように改善 +- AI の応答を手動で停止すると生成済みの内容が保存されず、次の質問で文脈が失われることがある問題を修正 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- AI リクエストの停止後もコンテキスト圧縮が続く問題を修正し、余分なモデル呼び出しや要約の書き込みを防止 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- ダウンロード API のファイル名検証を修正し、ダウンロードフォルダー外への書き込みを防止 (by [@ttsdj](https://github.com/ttsdj)) + +### ♻️ リファクタリング + +- 外部 Agent のインポート・変換・分析スキルの呼び出し方法を統一し、ユーザーの言語で応答するように変更。リリース済みバージョンの旧インポートスキルのインストールコマンドも引き続きサポート + +## v0.37.1 (2026-09-04) + +> コントリビューター表記を追加し、Docker の起動を改善。AI ツールのループ、テキスト統計、関係グラフのリソース使用に関する問題を修正しました。 + +### ✨ 新機能 + +- 音声の文字起こし内容を言語傾向、単語頻度、口癖の分析対象に追加 (by [@babybitter](https://github.com/babybitter)) +- 【CLI】Docker イメージに既定の中国語形態素解析辞書を同梱し、初回起動時のダウンロードを不要に変更 (by [@KokerZhou](https://github.com/KokerZhou)) + +### 🐛 バグ修正 + +- AI ツールの呼び出しが上限に達した際の最終回答の重複、ツールエラーの成功扱い、コンテキストの重複書き込みを修正 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- ユーザーの絵文字コンテンツを保持しつつ、システム通知や共有プレースホルダーが単語頻度、言語傾向、口癖の統計に含まれる問題を修正 (by [@babybitter](https://github.com/babybitter)) + ## v0.37.0 (2026-08-28) > クロスチャット AI 分析と長期メモリを追加し、インポートの重複排除や AI 応答失敗を含む多数の問題を修正。インサイトのパフォーマンスとチャット履歴ビューアーも改善しました。
クロスチャット分析はまだ十分にテストされていない実験的な機能です。今後のバージョンでも継続して改善します。 @@ -10,17 +109,17 @@ - 好み、自分について、連絡先、グループの情報を管理できる長期メモリを追加し、質問に応じて関連する内容を自動的に参照 - 長期メモリを参照元メッセージに関連付け、AI が生成した結論を元の会話で確認できるように改善 - グローバル AI チャットと各チャット内 AI の会話一覧、入力欄、@ メンション選択を統一 -- チャット履歴ビューアーをドラッグでリサイズし、選択した幅を保持できるように改善 +- チャット履歴ビューアーをドラッグでリサイズし、選択した幅を保持できるように改善 (by [@KokerZhou](https://github.com/KokerZhou)) - 分析ページの SQL ラボとデバッグツールを「その他」にまとめて配置 -- 【CLI】Unix Domain Socket 経由で Web サービスを起動できる機能を追加 +- 【CLI】Unix Domain Socket 経由で Web サービスを起動できる機能を追加 (by [@KokerZhou](https://github.com/KokerZhou)) ### 🐛 バグ修正 - WeFlow および安定したメッセージ ID がないチャット履歴の差分インポートや統合で重複排除が過剰に働き、有効なメッセージが失われる問題を修正 -- AI の応答に失敗した後、エラー状態、会話一覧、次の質問が正しく処理されないことがある問題を修正 -- AI 会話の保存や編集で不完全なターンが残る問題を修正し、編集対象を直近の質問のみに制限 -- 長い会話の圧縮時に、メッセージ境界やメモリの参照元リンクが失われることがある問題を修正 -- 同じ名前の AI サービス設定で誤った認証情報が読み込まれる問題を修正 +- AI の応答に失敗した後、エラー状態、会話一覧、次の質問が正しく処理されないことがある問題を修正 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- AI 会話の保存や編集で不完全なターンが残る問題を修正し、編集対象を直近の質問のみに制限 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 長い会話の圧縮時に、メッセージ境界やメモリの参照元リンクが失われることがある問題を修正 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 同じ名前の AI サービス設定で誤った認証情報が読み込まれる問題を修正 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) - 分析ページを切り替えて戻った際に重複して読み込んだり、インポート前の古いデータが表示されたりする問題を修正 - クロスチャット分析の期間指定、連絡先の解決、スキャン上限、結果省略時の案内を改善 @@ -41,7 +140,7 @@ ### 🐛 バグ修正 -- フォルダーのインポートを独立したボタンに変更し、ファイル選択と操作が競合しないように改善 +- フォルダーのインポートを独立したボタンに変更し、ファイル選択と操作が競合しないように改善 (by [@kongjiyu](https://github.com/kongjiyu)) ### ⚡ パフォーマンス @@ -109,7 +208,7 @@ - QQ の自動インポートで UID/UIN の変更、owner 情報の欠落、セッションのマージがある場合の照合とメンバー対応を修正し、セッションの重複やメンバー識別の不整合を防止 - プラットフォームメッセージ ID の競合や個人チャットの識別情報不足により、マージ時にメッセージが誤って削除される問題を修正。有効な owner とセッション識別情報も保持 -- 動的キーワードランキングのリクエスト処理、高速な表示切り替え、マルチカラーモードを修正し、古い結果の表示や未知の種類によるクラッシュを防止 +- 動的キーワードランキングのリクエスト処理、高速な表示切り替え、マルチカラーモードを修正し、古い結果の表示や未知の種類によるクラッシュを防止 (by [@kelaocai](https://github.com/kelaocai)) - インサイトの読み込み切り替えを統一し、年間アクティビティカレンダーの配色と関係ビューの切り替え操作を復元 ### ⚡ パフォーマンス @@ -125,7 +224,7 @@ - QQ Shuakami / QQ Chat Exporter のインポートに Rust ネイティブパーサーを導入し、大容量エクスポートの処理を安定化 - 複数ファイルのインポートに安全な並行スケジューリングを追加し、同一セッションへの同時書き込みを防止。ファイルごとの進捗と失敗内容も表示 - 許可リストに含まれる実行環境と機能利用イベントだけを送信する、プライバシーに配慮した匿名利用統計を追加 -- 【CLI Web】ローカルセマンティックインデックスの実行コンポーネントを初回利用時に自動インストール。Docker イメージには引き続き必要なコンポーネントを同梱 +- 【CLI Web】ローカルセマンティックインデックスの実行コンポーネントを初回利用時に自動インストール。Docker イメージには引き続き必要なコンポーネントを同梱 (by [@kelaocai](https://github.com/kelaocai)) ### 🐛 バグ修正 @@ -183,18 +282,18 @@ ### 🐛 バグ修正 - ブラウザ版における OPFS の同時アクセス、概要フィルターの競合、単語頻度集計、関係分析フィルターの問題を修正 -- フロントエンドのアイコンをローカルに同梱し、実行時の外部アイコンサービス依存を解消 +- フロントエンドのアイコンをローカルに同梱し、実行時の外部アイコンサービス依存を解消 (by [@KokerZhou](https://github.com/KokerZhou)) - ChatLab JSONL の自動判定と形式検証ルールを統一し、解決できない owner 情報を拒否 - 【デスクトップ版】移行処理による使用中のデータディレクトリ削除を防止し、新旧ディレクトリの入れ子や同一パス指定を拒否 -- 【CLI】Docker デプロイでユーザーデータとシステム状態を永続化し、公開ポートをデフォルトで localhost のみにバインド +- 【CLI】Docker デプロイでユーザーデータとシステム状態を永続化し、公開ポートをデフォルトで localhost のみにバインド (by [@KokerZhou](https://github.com/KokerZhou)) ### 📝 ドキュメント -- Docker デプロイガイドを追加し、AI 分析の利点とローカルプライバシーの説明を改善 +- Docker デプロイガイドを追加し、AI 分析の利点とローカルプライバシーの説明を改善 (by [@KokerZhou](https://github.com/KokerZhou)) ### 👷 CI -- amd64 / arm64 向けの ChatLab CLI Docker イメージを公開 +- amd64 / arm64 向けの ChatLab CLI Docker イメージを公開 (by [@KokerZhou](https://github.com/KokerZhou)) ## v0.32.0 (2026-07-18) @@ -202,14 +301,14 @@ ### ✨ 新機能 -- 【デスクトップ版】パスワード保護、即時ロック、起動時のロック解除要求、無操作時の自動ロックに対応したアプリロックを追加 +- 【デスクトップ版】パスワード保護、即時ロック、起動時のロック解除要求、無操作時の自動ロックに対応したアプリロックを追加 (by [@xyz-225648](https://github.com/xyz-225648)) ### 🐛 バグ修正 - データディレクトリの移行後も旧データを残し、手動での確認・削除を案内することで、移行や再試行時の移行元ファイルの誤削除を防止 - 大容量の ChatLab JSONL をストリーミングで分割処理し、インポートの停止を防ぐとともに、マージインポート時のメンバー画像を保持 - デスクトップ版と CLI Web の複数ファイル一括インポート処理を統一し、順次処理の進捗表示、キャンセル、結果サマリーに対応 -- 【デスクトップ版】アプリロックに失敗回数制限、クールダウン、起動時の状態保護を追加し、再読み込みや再起動による回避を防止 +- 【デスクトップ版】アプリロックに失敗回数制限、クールダウン、起動時の状態保護を追加し、再読み込みや再起動による回避を防止 (by [@xyz-225648](https://github.com/xyz-225648)) ## v0.31.3 (2026-07-17) @@ -253,7 +352,7 @@ - 各環境の Push インポートで検証、重複排除、メンバー推定、dry-run の結果を統一し、事前確認と実際の書き込みの差異を解消 - 過去メッセージの追加インポート後も、タイムスタンプとメッセージ ID に基づく安定した時系列順で表示 -- QCE / QQ Chat Exporter の個人チャットがグループと誤判定され、「0」メンバーが生成される問題を修正 +- QCE / QQ Chat Exporter の個人チャットがグループと誤判定され、「0」メンバーが生成される問題を修正 (by [@shuakami](https://github.com/shuakami)) ## v0.31.0 (2026-07-13) @@ -271,7 +370,7 @@ - ChatLab JSON の書き出しをそのまま再インポートできるよう修正し、メンバー、owner、別名のメタデータを保持 - 自動増分インポートの重複排除、混在するメッセージ ID の判定、ChatLab 再インポートの識別を強化 -- WhatsApp TXT 解析で括弧付きタイムスタンプとドット付き AM/PM 表記に対応 +- WhatsApp TXT 解析で括弧付きタイムスタンプとドット付き AM/PM 表記に対応 (by [@hh7418695](https://github.com/hh7418695)) ### ♻️ リファクタリング @@ -314,7 +413,7 @@ - SQL Lab で CSV エクスポートに失敗する問題を修正 - JSONL などのストリーミングされたメンバー情報をインポートする際にアバターを保持し、増分インポートでも新しいアバターを取り込めるよう修正 - AI ログを共有ルートから開くようにし、ローカルの CLI Web とデスクトップで挙動を統一 -- 概要の本人カードが選択中の期間に合わせて更新されるよう修正 +- 概要の本人カードが選択中の期間に合わせて更新されるよう修正 (by [@qikairo7](https://github.com/qikairo7)) ### 📝 ドキュメント @@ -445,8 +544,8 @@ ### 🐛 バグ修正 - 複数キーワードによるメッセージ検索を修正 -- ワードクラウドの辞書読み込みにおける UX 回帰を修正 -- ランキングラベルと絵文字クリーニングの問題を修正 +- ワードクラウドの辞書読み込みにおける UX 回帰を修正 (by [@qikairo7](https://github.com/qikairo7)) +- ランキングラベルと絵文字クリーニングの問題を修正 (by [@qikairo7](https://github.com/qikairo7)) - v8 データベース移行時の i18n テキスト欠落を修正 - 【CLI】ツールアダプターでのセッションコンテキスト転送と前処理の欠落を修正 @@ -466,8 +565,8 @@ ### 🐛 バグ修正 -- 頻出ワード統計にプラットフォームの返信プレースホルダーが含まれる問題を修正 -- ワードクラウドのテキストにメディアプレースホルダーが含まれる問題を修正 +- 頻出ワード統計にプラットフォームの返信プレースホルダーが含まれる問題を修正 (by [@qikairo7](https://github.com/qikairo7)) +- ワードクラウドのテキストにメディアプレースホルダーが含まれる問題を修正 (by [@qikairo7](https://github.com/qikairo7)) - Jieba カスタム辞書変更後に言語設定キャッシュと単語頻度キャッシュが無効化されない問題を修正 - 時間依存の分析キャッシュが1日ごとに期限切れにならない問題を修正 - タイムラインパネルのデフォルトスクロール位置とセッションジャンプが機能しない問題を修正 @@ -555,7 +654,7 @@ - AI アシスタントのメッセージがチャット領域の幅全体を埋めない問題を修正しました - 分析プラン生成ブロックの表示スタイルを改善しました - 再起動後に思考レベルの選択が保持されない問題を修正しました -- JSONL のタイムスタンプ正規化により差分インポートが失敗する問題を修正しました +- JSONL のタイムスタンプ正規化により差分インポートが失敗する問題を修正しました (by [@cnYui](https://github.com/cnYui)) - 【デスクトップ】execute_sql ツールがデスクトップのツールレジストリに登録されない問題を修正しました ### test @@ -600,8 +699,8 @@ ### ✨ 新機能 -- スラッシュ操作で起動できる AI グラフ実行環境を追加し、会話内で ECharts グラフを生成・表示できるようにしました -- AI グラフ結果の表示体験を改善し、レンダリング状態と複数のグラフ種別への対応を追加しました +- スラッシュ操作で起動できる AI グラフ実行環境を追加し、会話内で ECharts グラフを生成・表示できるようにしました (by [@CEQ151](https://github.com/CEQ151)) +- AI グラフ結果の表示体験を改善し、レンダリング状態と複数のグラフ種別への対応を追加しました (by [@CEQ151](https://github.com/CEQ151)) ### 🐛 バグ修正 @@ -609,7 +708,7 @@ - プリセット質問チップとサイドバー要素の重なり順が崩れる問題を修正しました - 【CLI Web】開発バックエンドのライフサイクル cleanup が不完全になる問題を修正しました - 【デスクトップ】自動スキルでグラフツールが登録されず、グラフスキルを正しく実行できない問題を修正しました -- 【デスクトップ】Windows のタイトルバー overlay がテーマ切り替え後にキャッシュを更新せず、表示が不安定になる問題を修正しました +- 【デスクトップ】Windows のタイトルバー overlay がテーマ切り替え後にキャッシュを更新せず、表示が不安定になる問題を修正しました (by [@CEQ151](https://github.com/CEQ151)) ### ♻️ リファクタリング @@ -666,7 +765,7 @@ - データディレクトリ移行とデータベース移行の複数の境界ケースを修正し、旧データベースの列不足やパス切り替え後の読み取り失敗を防止 - 旧データが存在しない場合やディレクトリ変更後にも移行案内が誤って表示される問題を修正 -- 増分インポート後にサイドバーのメッセージ数が更新されない問題を修正 +- 増分インポート後にサイドバーのメッセージ数が更新されない問題を修正 (by [@CEQ151](https://github.com/CEQ151)) - サイドバーの折りたたみ状態が sessionStorage のみに保存され、再読み込み後に失われる問題を修正 - 保存済みキーで AI 設定を編集する際、モデル取得と検証ボタンが正しく有効化されない問題を修正 - 共有 AI SSE ストリーミングの安定性に関する問題を修正 @@ -717,7 +816,7 @@ ### ✨ 新機能 -- AI返答の任意の箇所から会話を分岐できるFork機能を追加 +- AI返答の任意の箇所から会話を分岐できるFork機能を追加 (by [@CEQ151](https://github.com/CEQ151)) - ステータスバーに推論強度セレクターを追加し、モデルごとに思考レベルを記憶・切替可能に - 推論レベル制御にdefault/autoオプションを追加し、Kimi・Doubao・Geminiなど多数のモデルファミリーに対応 - メッセージ分析ビューを「タイプ分析」と「時間分析」の2タブに分割し、統計インサイトカードを強化 @@ -900,7 +999,7 @@ - Web モードのセッションインデックス、CORS プロキシ、Demo 保護、実行時エラーに関する問題を修正 - Web モードでアプリのバージョンが空表示になる問題を修正 -- AI Agent の根拠取得、Web 側イベントストリーム、エラー整形に関する問題を修正 +- AI Agent の根拠取得、Web 側イベントストリーム、エラー整形に関する問題を修正 (by [@CEQ151](https://github.com/CEQ151)) - 同期表示とインポート処理の不整合を修正 - CLI 開発モードでの ESM モジュール解決問題を修正 - CLI インストール後に既存のデスクトップ版データを見つけられない場合がある問題を修正 @@ -990,7 +1089,7 @@ ### ✨ 新機能 -- 会話を購読する際に、種類で絞り込んで選択できるようにしました。 +- 会話を購読する際に、種類で絞り込んで選択できるようにしました。 (by [@xunchahaha](https://github.com/xunchahaha)) - リモート会話のページ分割取得と、必要に応じた追加読み込みに対応しました。 - データソースごとに、1 回の取得で読み込むメッセージ件数を設定できるようにしました。 @@ -1147,7 +1246,7 @@ ### 🐛 バグ修正 - WhatsApp の時刻解析正規表現と行マッチ正規表現の厳密さが一致しない問題を修正 -- WhatsApp の12時間表記時刻と NNBSP 文字の解析互換性問題を修正 +- WhatsApp の12時間表記時刻と NNBSP 文字の解析互換性問題を修正 (by [@kongjiyu](https://github.com/kongjiyu)) ### 🔧 雑務 @@ -1210,7 +1309,7 @@ - AI 会話のスタイルと全体の操作体験を改善 - FTS5 全文検索に対応し、クイック検索ツールを追加 - 一部ツールの検索パラメータを整理し、token 消費を削減 -- Electron アプリ向けに、ポート管理とインスタンス分離に対応した E2E テスト基盤を追加 +- Electron アプリ向けに、ポート管理とインスタンス分離に対応した E2E テスト基盤を追加 (by [@l17728](https://github.com/l17728)) ### 🐛 バグ修正 @@ -1223,7 +1322,7 @@ ### test -- 再利用可能な E2E ランチャーのスモークテストを追加 +- 再利用可能な E2E ランチャーのスモークテストを追加 (by [@l17728](https://github.com/l17728)) ### 📝 ドキュメント @@ -1274,7 +1373,7 @@ ### 🐛 バグ修正 -- メッセージ重複判定の誤検知を修正し、空文字列の重複排除ルールを統一 +- メッセージ重複判定の誤検知を修正し、空文字列の重複排除ルールを統一 (by [@lnexin](https://github.com/lnexin)) - AI会話フローの不具合とフロントエンドのtype-checkエラーを修正 - 例外時のための既定assistantフォールバックを追加 - 日次メッセージ推移が表示されない問題を修正 @@ -1333,7 +1432,7 @@ - チャット履歴の前処理パイプラインを追加 - 前処理設定画面と設定管理機能を追加 -- Agent でセッション単位のコンテキストタイムラインと実行状態を表示 +- Agent でセッション単位のコンテキストタイムラインと実行状態を表示 (by [@n-WN](https://github.com/n-WN)) - AI デバッグモードを追加し、ログの可観測性を向上 ### 🐛 バグ修正 @@ -1413,7 +1512,7 @@ ### 🐛 バグ修正 - 増分更新後にセッション索引の生成範囲が不正確になる問題を修正(fix #79) -- 移行やセッション走査時に非チャット用 SQLite ファイルを誤処理する問題を修正 +- 移行やセッション走査時に非チャット用 SQLite ファイルを誤処理する問題を修正 (by [@n-WN](https://github.com/n-WN)) ### ♻️ リファクタリング @@ -1437,7 +1536,7 @@ ### 🐛 バグ修正 -- LINE チャット履歴の解析問題を修正 +- LINE チャット履歴の解析問題を修正 (by [@haotien91](https://github.com/haotien91)) ### 📝 ドキュメント @@ -1449,9 +1548,9 @@ ### ✨ 新機能 -- 設定画面にデータディレクトリ位置の設定項目を追加 +- 設定画面にデータディレクトリ位置の設定項目を追加 (by [@xunchahaha](https://github.com/xunchahaha)) - データ保存先の移行ロジックを改善 -- ディレクトリ切り替え時の確認ダイアログを追加 +- ディレクトリ切り替え時の確認ダイアログを追加 (by [@xunchahaha](https://github.com/xunchahaha)) - パーサーロジックを改善(WeFlow / Echotrace) ### 🐛 バグ修正 @@ -1459,12 +1558,12 @@ - Windows で大量メッセージを絞り込むとクラッシュする問題を修正 - 外部中継 API が tool_call を返した際に会話が異常終了する問題を修正 - 一部の WhatsApp 履歴を正しく認識できない問題を修正 -- 管理画面のヘッダー階層表示の問題を修正 +- 管理画面のヘッダー階層表示の問題を修正 (by [@xunchahaha](https://github.com/xunchahaha)) ### ♻️ リファクタリング - session 検索モジュールを再構成 -- 移行ログ出力を改善 +- 移行ログ出力を改善 (by [@xunchahaha](https://github.com/xunchahaha)) ## v0.9.2 (2026-02-02) @@ -1603,7 +1702,7 @@ - 増分インポートに対応 - 一括インポートに対応 - スタイルを改善 -- Windows でネイティブウィンドウ操作とテーマ同期に対応(#31) +- Windows でネイティブウィンドウ操作とテーマ同期に対応(#31) (by [@JiQingzhe2004](https://github.com/JiQingzhe2004)) ### 🔧 雑務 @@ -1697,7 +1796,7 @@ - プロキシ設定に対応(resolve #7) - AI モデル画面の操作性を改善 - ユーザー設定 API のチュートリアルを追加 -- 無料の GLM モデル 2 種を追加し、Doubao プロバイダーと最新モデルを追加 +- 無料の GLM モデル 2 種を追加し、Doubao プロバイダーと最新モデルを追加 (by [@JiQingzhe2004](https://github.com/JiQingzhe2004)) - AI 応答で think 内容を出力しないよう調整 ## v0.1.3 (2025-12-25) diff --git a/changelogs/tw.json b/changelogs/tw.json index 6bb37c7db..a4a16def1 100644 --- a/changelogs/tw.json +++ b/changelogs/tw.json @@ -1,4 +1,238 @@ [ + { + "version": "0.37.7", + "date": "2026-10-30", + "summary": "修正 Discord 私訊辨識與重複匯入的對話比對,並完善 MCP 唯讀查詢與安全檢查。", + "changes": [ + { + "type": "fix", + "items": [ + { + "text": "修正 Discord 私訊被辨識為群聊的問題,讓同一頻道的重複匯入與增量匯入能正確對應既有對話" + }, + { + "text": "【MCP】為查詢工具加入唯讀標記,方便 AI 用戶端正確辨識工具權限", + "contributors": ["moesnow"] + }, + { + "text": "【MCP】查詢不再自動遷移資料庫或更新資料目錄的相容性標記;舊資料庫須先透過桌面版或 CLI 升級", + "contributors": ["moesnow"] + }, + { + "text": "【MCP】加強對話路徑與 SQL 檢查,防止讀取資料目錄以外的檔案或執行查詢以外的操作", + "contributors": ["moesnow"] + } + ] + } + ] + }, + { + "version": "0.37.6", + "date": "2026-10-22", + "summary": "完善無法辨識匯入格式時的引導,支援將記憶設為對話預設頁面,並修正 MCP 陣列參數類型問題。", + "changes": [ + { + "type": "fix", + "items": [ + { + "text": "無法辨識聊天記錄格式時,提供說明與 AI 轉換指南,支援單一檔案、批次匯入及多語言介面" + }, + { + "text": "支援將記憶設為開啟聊天對話時的預設頁面", + "contributors": ["KokerZhou"] + }, + { + "text": "【MCP】修正字串、數字與布林值陣列參數被誤判為字串,導致工具呼叫失敗的問題", + "contributors": ["moesnow"] + } + ] + } + ] + }, + { + "version": "0.37.5", + "date": "2026-09-28", + "summary": "統一右側面板操作,新增桌面版介面縮放,並修正對話載入、資料來源設定與透過代理下載模型的問題。", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "統一聊天紀錄、聯絡人詳細資訊與聊天話題的右側面板,可從頁首展開或收合、拖曳調整寬度,並保留收合前的瀏覽位置" + }, + { + "text": "【桌面版】新增介面縮放設定" + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "修正部分 Windows 使用者升級後,因重複寫入資料目錄相容性標記而無法載入對話清單的問題" + }, + { + "text": "修正遠端資料來源設定讀取或儲存失敗時,可能覆寫原設定或誤報成功的問題;保留原始檔案並顯示明確錯誤提示" + }, + { + "text": "修正透過代理伺服器下載本機語意模型時,檔案未寫入快取而導致模型載入失敗的問題", + "contributors": ["huaqian0226"] + } + ] + } + ] + }, + { + "version": "0.37.4", + "date": "2026-09-23", + "summary": "改善 AI 工具結果的上下文控制,修正 Qwen 話題摘要問題,並完善對話清單載入失敗的提示。", + "changes": [ + { + "type": "fix", + "items": [ + { + "text": "修正 AI 多輪或平行工具呼叫的結果累積後,可能超出模型上下文限制的問題;依每次請求裁剪工具結果,並保留完整對話紀錄", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "修正部分 Qwen 模型因預設開啟思考,導致無法產生話題摘要的問題" + }, + { + "text": "話題摘要達到模型輸出上限時顯示明確提示,避免無效重試造成額外用量" + }, + { + "text": "對話清單載入失敗時顯示完整錯誤資訊,並在應用程式日誌中保留完整錯誤堆疊,方便排查升級後的權限等問題" + }, + { + "text": "補齊 AI 詞頻與回覆速度分析工具的英文時間參數說明" + }, + { + "text": "【CLI】Docker 映像檔改用與 npm 發布相同的安裝套件,避免 npm 同步延遲導致映像檔發布失敗" + } + ] + } + ] + }, + { + "version": "0.37.3", + "date": "2026-09-22", + "summary": "修正 AI 時間篩選、Qwen 模型相容性與接續對話的問題,改善多語言支援及訊息上下文查詢。", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "完善 AI 工具的中英文說明,統一桌面版、CLI Web 與 MCP 的語言處理", + "contributors": ["Zycheng114514"] + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "修正 AI 詞頻與回覆速度分析忽略所選時間範圍的問題,釐清提問指定時間、頁面篩選與預設範圍的優先順序", + "contributors": ["liuyy2"] + }, + { + "text": "修正部分 Qwen 模型在 AI 對話與話題摘要中,因訊息角色不相容而發生錯誤的問題" + }, + { + "text": "修正補匯入較早訊息後上下文可能錯置的問題,並提升訊息上下文的查詢效能", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "補齊語言偏好卡片缺少的日文標籤", + "contributors": ["Zycheng114514"] + }, + { + "text": "【CLI】修正接續 AI 對話時遺漏已完成工具呼叫結果的問題,保留工具結果與錯誤狀態供後續對話使用", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "【CLI】修正 npm 新版本尚未同步時,Docker 映像檔可能發布失敗的問題" + } + ] + } + ] + }, + { + "version": "0.37.2", + "date": "2026-09-18", + "summary": "修正 AI 回覆遭截斷或停止後無法順利接續對話的問題,改善表情符號辨識、技能入口與下載安全檢查。", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "詞頻與語言偏好分析可辨識抖音表情代碼,避免將表情名稱列入高頻詞", + "contributors": ["babybitter"] + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "修正部分介面在 AI 回覆遭截斷後遺失已產生內容或用量資訊的問題,儲存後可接續對話" + }, + { + "text": "修正手動停止 AI 回覆後未儲存已產生內容,導致後續提問可能遺失對話脈絡的問題", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "停止 AI 請求時一併取消上下文壓縮,避免額外的模型呼叫與摘要寫入", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "修正下載介面的檔名檢查,防止檔案寫入下載資料夾以外的位置", + "contributors": ["ttsdj"] + } + ] + }, + { + "type": "refactor", + "items": [ + { + "text": "統一外部 Agent 的匯入、轉換與分析技能入口,依使用者的語言回覆,並保留已發布版本中舊匯入技能安裝指令的相容性" + } + ] + } + ] + }, + { + "version": "0.37.1", + "date": "2026-09-04", + "summary": "新增貢獻者署名,改善 Docker 啟動,並修正 AI 工具迴圈、文字統計與關係圖資源占用問題。", + "changes": [ + { + "type": "feat", + "items": [ + { + "text": "語言偏好、詞頻與口頭禪分析支援讀取語音轉寫內容", + "contributors": ["babybitter"] + }, + { + "text": "【CLI】Docker 映像檔內建預設中文斷詞詞典,首次啟動不再需要下載", + "contributors": ["KokerZhou"] + } + ] + }, + { + "type": "fix", + "items": [ + { + "text": "修正 AI 工具呼叫達到回合上限後可能重複產生最終回覆、工具錯誤被記為成功,以及上下文內容重複寫入的問題", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "修正系統通知和分享預留文字被納入詞頻、語言偏好與口頭禪統計的問題,並保留使用者的表情符號內容", + "contributors": ["babybitter"] + } + ] + } + ] + }, { "version": "0.37.0", "date": "2026-08-28", @@ -11,19 +245,37 @@ "新增加長期記憶,可管理偏好、關於我、聯絡人與群組記憶,並依問題自動載入相關內容", "長期記憶可連結來源訊息,方便回到原始對話核對 AI 產生的結論", "統一全域與單一對話 AI 的對話清單、輸入框和 @ 選取操作", - "聊天記錄檢視器可拖曳調整寬度,並記住版面設定", + { + "text": "聊天記錄檢視器可拖曳調整寬度,並記住版面設定", + "contributors": ["KokerZhou"] + }, "分析頁將 SQL 實驗室與除錯工具統一收納至「更多」", - "【CLI】支援透過 Unix Domain Socket 啟動 Web 服務" + { + "text": "【CLI】支援透過 Unix Domain Socket 啟動 Web 服務", + "contributors": ["KokerZhou"] + } ] }, { "type": "fix", "items": [ "修正 WeFlow 與缺少穩定訊息 ID 的聊天記錄在增量匯入、合併時可能過度去重的問題,避免真實訊息遺失", - "修正 AI 回覆失敗後,錯誤狀態、對話清單與後續提問可能異常的問題", - "修正 AI 對話在儲存或編輯時可能留下不完整輪次的問題,並僅允許編輯最新一輪提問", - "修正長對話壓縮可能遺失訊息邊界或記憶來源連結的問題", - "修正同名 AI 服務設定可能讀取錯誤憑證的問題", + { + "text": "修正 AI 回覆失敗後,錯誤狀態、對話清單與後續提問可能異常的問題", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "修正 AI 對話在儲存或編輯時可能留下不完整輪次的問題,並僅允許編輯最新一輪提問", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "修正長對話壓縮可能遺失訊息邊界或記憶來源連結的問題", + "contributors": ["Sanssssssssssssssss"] + }, + { + "text": "修正同名 AI 服務設定可能讀取錯誤憑證的問題", + "contributors": ["Sanssssssssssssssss"] + }, "修正切換或返回分析頁面時可能重複載入,或繼續顯示匯入前舊資料的問題", "完善跨對話分析的時間範圍、聯絡人解析、掃描預算與截斷提示" ] @@ -50,7 +302,12 @@ }, { "type": "fix", - "items": ["匯入資料夾改為獨立按鈕,避免與選取檔案互相搶入口"] + "items": [ + { + "text": "匯入資料夾改為獨立按鈕,避免與選取檔案互相搶入口", + "contributors": ["kongjiyu"] + } + ] }, { "type": "perf", @@ -140,7 +397,10 @@ "items": [ "修正 QQ 自動匯入在 UID/UIN 變更、缺少 owner 資訊或工作階段已合併時的比對與成員對應問題,避免產生重複工作階段或錯誤的成員身分", "修正合併匯入因平台訊息 ID 衝突或私人聊天身分資訊不足而誤刪訊息的問題,並保留有效的 owner 與工作階段身分資訊", - "修正動態關鍵字排行的請求、快速切換與多色模式,避免顯示過期結果或因未知類型導致頁面當機", + { + "text": "修正動態關鍵字排行的請求、快速切換與多色模式,避免顯示過期結果或因未知類型導致頁面當機", + "contributors": ["kelaocai"] + }, "統一洞察頁面的載入轉場,並恢復年度活動日曆配色與關係檢視切換控制項" ] }, @@ -161,7 +421,10 @@ "QQ Shuakami 與 QQ Chat Exporter 匯入改用 Rust 原生解析,提升大型匯出檔案的處理穩定性", "多檔案匯入加入安全的並行排程,避免同一工作階段同時寫入,並顯示各檔案的進度與失敗資訊", "新增兼顧隱私的匿名產品統計,只回報白名單內的執行環境與功能使用事件", - "【CLI Web】本機語意索引執行元件改為首次使用時按需安裝,Docker 映像仍預先內建所需元件" + { + "text": "【CLI Web】本機語意索引執行元件改為首次使用時按需安裝,Docker 映像仍預先內建所需元件", + "contributors": ["kelaocai"] + } ] }, { @@ -240,19 +503,35 @@ "type": "fix", "items": [ "修正純 Web 版同時存取 OPFS、概覽篩選競態、詞頻統計與關係分析篩選問題", - "前端圖示改為隨應用程式一併封裝,避免執行時依賴外部圖示服務", + { + "text": "前端圖示改為隨應用程式一併封裝,避免執行時依賴外部圖示服務", + "contributors": ["KokerZhou"] + }, "統一 ChatLab JSONL 自動辨識與格式驗證規則,並拒絕無法解析的 owner 資訊", "【桌面版】防止移轉程序刪除目前使用中的資料目錄,並拒絕新舊資料目錄互相巢狀或指向同一路徑", - "【CLI】Docker 部署會保存使用者資料與系統狀態,並預設只將連接埠綁定至 localhost" + { + "text": "【CLI】Docker 部署會保存使用者資料與系統狀態,並預設只將連接埠綁定至 localhost", + "contributors": ["KokerZhou"] + } ] }, { "type": "docs", - "items": ["新增 Docker 部署指南,並補充 AI 分析效益與本機隱私說明"] + "items": [ + { + "text": "新增 Docker 部署指南,並補充 AI 分析效益與本機隱私說明", + "contributors": ["KokerZhou"] + } + ] }, { "type": "ci", - "items": ["發布適用於 amd64 與 arm64 架構的 ChatLab CLI Docker 映像檔"] + "items": [ + { + "text": "發布適用於 amd64 與 arm64 架構的 ChatLab CLI Docker 映像檔", + "contributors": ["KokerZhou"] + } + ] } ] }, @@ -263,7 +542,12 @@ "changes": [ { "type": "feat", - "items": ["【桌面版】新增應用程式鎖定,支援密碼、立即鎖定、啟動時解鎖,以及閒置逾時自動鎖定"] + "items": [ + { + "text": "【桌面版】新增應用程式鎖定,支援密碼、立即鎖定、啟動時解鎖,以及閒置逾時自動鎖定", + "contributors": ["xyz-225648"] + } + ] }, { "type": "fix", @@ -271,7 +555,10 @@ "資料目錄移轉後保留舊資料並提示使用者手動確認清理,避免移轉或重試時誤刪來源檔案", "大型 ChatLab JSONL 改為串流分批處理,避免匯入卡住,並保留合併匯入中的成員頭像", "統一桌面版與 CLI Web 的多檔批次匯入流程,提供循序處理進度、取消與結果摘要", - "【桌面版】應用程式鎖定新增失敗次數限制、冷卻時間與啟動狀態保護,避免重新整理或重新啟動後繞過鎖定" + { + "text": "【桌面版】應用程式鎖定新增失敗次數限制、冷卻時間與啟動狀態保護,避免重新整理或重新啟動後繞過鎖定", + "contributors": ["xyz-225648"] + } ] } ] @@ -334,7 +621,10 @@ "items": [ "統一各端 Push 匯入的驗證、去重、成員推斷與 dry-run 結果,避免預覽與實際寫入不一致", "補匯入歷史訊息後,聊天記錄會依時間與訊息 ID 穩定排序,避免舊訊息出現在最後", - "修正 QCE / QQ Chat Exporter 私人聊天被誤判為群組並產生「0」成員的問題" + { + "text": "修正 QCE / QQ Chat Exporter 私人聊天被誤判為群組並產生「0」成員的問題", + "contributors": ["shuakami"] + } ] } ] @@ -359,7 +649,10 @@ "items": [ "修正 ChatLab JSON 匯出無法直接重新匯入的問題,並保留成員、owner 與別名元資料", "強化自動增量匯入的去重、混合訊息身份識別與 ChatLab 重複匯入判斷", - "WhatsApp TXT 解析支援括號時間戳與點號 AM/PM 標記" + { + "text": "WhatsApp TXT 解析支援括號時間戳與點號 AM/PM 標記", + "contributors": ["hh7418695"] + } ] }, { @@ -415,7 +708,10 @@ "修正 SQL 實驗室匯出 CSV 失敗的問題", "匯入 JSONL 等串流成員資料時保留頭像,並支援增量匯入新頭像", "開啟 AI 日誌改由共享路由處理,讓本機 CLI Web 與桌面端行為一致", - "概覽身份卡會跟隨目前選取的時間範圍更新" + { + "text": "概覽身份卡會跟隨目前選取的時間範圍更新", + "contributors": ["qikairo7"] + } ] }, { @@ -584,8 +880,14 @@ "type": "fix", "items": [ "修正多關鍵字訊息搜尋功能", - "修正詞雲詞典載入 UX 回歸問題", - "修正排行榜標籤與 emoji 清理", + { + "text": "修正詞雲詞典載入 UX 回歸問題", + "contributors": ["qikairo7"] + }, + { + "text": "修正排行榜標籤與 emoji 清理", + "contributors": ["qikairo7"] + }, "修正 v8 資料庫遷移缺少國際化文字的問題", "【CLI】修正工具轉接器中會話上下文傳遞與前處理缺失的問題" ] @@ -612,8 +914,14 @@ { "type": "fix", "items": [ - "修正高頻詞統計中含有平台回覆訊息占位符的問題", - "修正詞雲文字中包含媒體占位符的問題", + { + "text": "修正高頻詞統計中含有平台回覆訊息占位符的問題", + "contributors": ["qikairo7"] + }, + { + "text": "修正詞雲文字中包含媒體占位符的問題", + "contributors": ["qikairo7"] + }, "修正 Jieba 自訂詞典變更後語言偏好快取與詞頻快取未失效的問題", "修正時效性分析快取未按日失效的問題", "修正時間軸面板預設捲動位置及會話跳轉功能失效的問題", @@ -719,7 +1027,10 @@ "修正 AI 助理訊息未填滿聊天區域寬度", "改善分析計畫產生區塊的顯示樣式", "修正思考深度選擇跨重新啟動後不持久的問題", - "修正 JSONL 時間戳記規範化導致增量匯入異常", + { + "text": "修正 JSONL 時間戳記規範化導致增量匯入異常", + "contributors": ["cnYui"] + }, "【桌面版】修正 execute_sql 工具未註冊至桌面版工具清單" ] }, @@ -776,8 +1087,14 @@ { "type": "feat", "items": [ - "新增以斜線指令觸發的 AI 圖表執行環境,可在對話中產生並渲染 ECharts 圖表", - "改善 AI 圖表結果呈現,補上更清楚的渲染狀態與多種圖表類型支援" + { + "text": "新增以斜線指令觸發的 AI 圖表執行環境,可在對話中產生並渲染 ECharts 圖表", + "contributors": ["CEQ151"] + }, + { + "text": "改善 AI 圖表結果呈現,補上更清楚的渲染狀態與多種圖表類型支援", + "contributors": ["CEQ151"] + } ] }, { @@ -787,7 +1104,10 @@ "修正預設問題標籤與側邊欄元素的層疊順序異常", "【CLI Web】修正開發後端生命週期清理不完整的問題", "【桌面版】修正自動技能未註冊圖表工具,導致圖表技能無法完整執行的問題", - "【桌面版】修正 Windows 標題列覆蓋層在切換主題後快取未更新與顯示不順的問題" + { + "text": "【桌面版】修正 Windows 標題列覆蓋層在切換主題後快取未更新與顯示不順的問題", + "contributors": ["CEQ151"] + } ] }, { @@ -860,7 +1180,10 @@ "items": [ "修正資料目錄遷移與資料庫遷移流程中的多個邊界情境,避免舊資料庫欄位缺失或路徑切換後讀取失敗", "修正舊資料不存在或目錄變更後,資料遷移提示仍錯誤顯示的問題", - "修正增量匯入後側邊欄訊息數量未更新的問題", + { + "text": "修正增量匯入後側邊欄訊息數量未更新的問題", + "contributors": ["CEQ151"] + }, "修正側邊欄收合狀態只存於 sessionStorage,導致重新整理後遺失的問題", "修正編輯 AI 設定且已儲存金鑰時,拉取模型與驗證按鈕未正確啟用的問題", "修正 AI 共享 SSE 串流回應的穩定性問題", @@ -925,7 +1248,10 @@ { "type": "feat", "items": [ - "新增訊息分叉(Fork)功能,可從任意 AI 回覆處建立獨立分支對話", + { + "text": "新增訊息分叉(Fork)功能,可從任意 AI 回覆處建立獨立分支對話", + "contributors": ["CEQ151"] + }, "狀態列新增推理強度選擇器,支援為每個推理模型個別記憶並切換思維等級", "推理等級控制新增 default/auto 選項,擴展對 Kimi、Doubao、Gemini 等更多模型系列的支援", "訊息分析檢視拆分為「類型分析」與「時間分析」兩個分頁,提供豐富統計洞察卡片", @@ -1144,7 +1470,10 @@ "items": [ "修復 Web 模式下會話索引、跨來源代理、Demo 保護與執行階段錯誤等問題", "修復 Web 模式下應用程式版本顯示為空的問題", - "修復 AI Agent 證據檢索、Web 端事件串流與錯誤格式化等問題", + { + "text": "修復 AI Agent 證據檢索、Web 端事件串流與錯誤格式化等問題", + "contributors": ["CEQ151"] + }, "修復同步顯示與匯入邏輯不一致的問題", "修復 CLI 開發模式下的 ESM 模組解析問題", "修復 CLI 安裝後可能找不到既有桌面版資料的問題", @@ -1255,7 +1584,10 @@ { "type": "feat", "items": [ - "訂閱會話時支援依類型篩選與選取", + { + "text": "訂閱會話時支援依類型篩選與選取", + "contributors": ["xunchahaha"] + }, "支援遠端會話分頁發現與按需載入更多", "支援為資料來源設定每次拉取的訊息數量" ] @@ -1457,7 +1789,10 @@ "type": "fix", "items": [ "修正 WhatsApp 時間解析正則與行匹配正則寬嚴不一致的問題", - "修正 WhatsApp 12 小時制時間與 NNBSP 字元的解析相容性問題" + { + "text": "修正 WhatsApp 12 小時制時間與 NNBSP 字元的解析相容性問題", + "contributors": ["kongjiyu"] + } ] }, { @@ -1532,7 +1867,10 @@ "優化 AI 對話樣式與整體互動體驗", "支援 FTS5 全文搜尋,並新增快速搜尋工具", "精簡部分工具的搜尋參數,降低 token 消耗", - "新增 Electron 應用 E2E 測試框架,支援連接埠管理與實例隔離" + { + "text": "新增 Electron 應用 E2E 測試框架,支援連接埠管理與實例隔離", + "contributors": ["l17728"] + } ] }, { @@ -1545,7 +1883,12 @@ }, { "type": "test", - "items": ["補充可重複使用的 E2E 啟動器冒煙測試能力"] + "items": [ + { + "text": "補充可重複使用的 E2E 啟動器冒煙測試能力", + "contributors": ["l17728"] + } + ] }, { "type": "docs", @@ -1607,7 +1950,10 @@ { "type": "fix", "items": [ - "修復訊息去重誤判,並統一空字串的去重行為", + { + "text": "修復訊息去重誤判,並統一空字串的去重行為", + "contributors": ["lnexin"] + }, "修復 AI 對話流程與前端 type-check 錯誤", "補上預設 assistant 的兜底邏輯,修復例外情境", "修復每日訊息趨勢未顯示的問題" @@ -1674,7 +2020,10 @@ "items": [ "新增聊天紀錄前處理流程", "新增前處理設定介面與設定管理能力", - "Agent 支援依會話顯示上下文時間軸與執行狀態", + { + "text": "Agent 支援依會話顯示上下文時間軸與執行狀態", + "contributors": ["n-WN"] + }, "新增 AI 除錯模式並提升日誌可觀測性" ] }, @@ -1771,7 +2120,10 @@ "type": "fix", "items": [ "修復增量更新後會話索引產生範圍不準確的問題(fix #79)", - "修復遷移與會話掃描時誤處理非聊天 SQLite 檔案的問題" + { + "text": "修復遷移與會話掃描時誤處理非聊天 SQLite 檔案的問題", + "contributors": ["n-WN"] + } ] }, { @@ -1801,7 +2153,12 @@ }, { "type": "fix", - "items": ["修復 LINE 聊天紀錄解析問題"] + "items": [ + { + "text": "修復 LINE 聊天紀錄解析問題", + "contributors": ["haotien91"] + } + ] }, { "type": "docs", @@ -1817,9 +2174,15 @@ { "type": "feat", "items": [ - "設定中新增資料目錄位置選項", + { + "text": "設定中新增資料目錄位置選項", + "contributors": ["xunchahaha"] + }, "優化資料儲存目錄遷移邏輯", - "切換目錄時新增確認彈窗", + { + "text": "切換目錄時新增確認彈窗", + "contributors": ["xunchahaha"] + }, "優化解析邏輯(WeFlow / Echotrace)" ] }, @@ -1829,12 +2192,21 @@ "修復 Windows 自訂篩選時訊息量過大導致崩潰的問題", "修復第三方中轉 API 呼叫 tool_call 導致對話異常結束的問題", "修復部分 WhatsApp 聊天紀錄無法正確辨識的問題", - "修復管理頁表頭層級顯示問題" + { + "text": "修復管理頁表頭層級顯示問題", + "contributors": ["xunchahaha"] + } ] }, { "type": "refactor", - "items": ["重構 session 查詢模組", "補強遷移日誌輸出"] + "items": [ + "重構 session 查詢模組", + { + "text": "補強遷移日誌輸出", + "contributors": ["xunchahaha"] + } + ] } ] }, @@ -2010,7 +2382,10 @@ "支援增量匯入", "支援批次匯入", "優化樣式", - "Windows 端支援原生視窗控制並實作主題同步(#31)" + { + "text": "Windows 端支援原生視窗控制並實作主題同步(#31)", + "contributors": ["JiQingzhe2004"] + } ] }, { @@ -2128,7 +2503,10 @@ "支援設定代理(resolve #7)", "優化 AI 模型介面互動", "新增使用者設定 API 教學", - "新增 2 個免費 GLM 模型,並加入豆包服務商與最新模型", + { + "text": "新增 2 個免費 GLM 模型,並加入豆包服務商與最新模型", + "contributors": ["JiQingzhe2004"] + }, "AI 回覆不再輸出 think 內容" ] } diff --git a/changelogs/tw.md b/changelogs/tw.md index 311836f6a..7cb713665 100644 --- a/changelogs/tw.md +++ b/changelogs/tw.md @@ -1,5 +1,104 @@ # 更新日誌 +## v0.37.7 (2026-10-30) + +> 修正 Discord 私訊辨識與重複匯入的對話比對,並完善 MCP 唯讀查詢與安全檢查。 + +### 🐛 修復 + +- 修正 Discord 私訊被辨識為群聊的問題,讓同一頻道的重複匯入與增量匯入能正確對應既有對話 +- 【MCP】為查詢工具加入唯讀標記,方便 AI 用戶端正確辨識工具權限 (by [@moesnow](https://github.com/moesnow)) +- 【MCP】查詢不再自動遷移資料庫或更新資料目錄的相容性標記;舊資料庫須先透過桌面版或 CLI 升級 (by [@moesnow](https://github.com/moesnow)) +- 【MCP】加強對話路徑與 SQL 檢查,防止讀取資料目錄以外的檔案或執行查詢以外的操作 (by [@moesnow](https://github.com/moesnow)) + +## v0.37.6 (2026-10-22) + +> 完善無法辨識匯入格式時的引導,支援將記憶設為對話預設頁面,並修正 MCP 陣列參數類型問題。 + +### 🐛 修復 + +- 無法辨識聊天記錄格式時,提供說明與 AI 轉換指南,支援單一檔案、批次匯入及多語言介面 +- 支援將記憶設為開啟聊天對話時的預設頁面 (by [@KokerZhou](https://github.com/KokerZhou)) +- 【MCP】修正字串、數字與布林值陣列參數被誤判為字串,導致工具呼叫失敗的問題 (by [@moesnow](https://github.com/moesnow)) + +## v0.37.5 (2026-09-28) + +> 統一右側面板操作,新增桌面版介面縮放,並修正對話載入、資料來源設定與透過代理下載模型的問題。 + +### ✨ 新功能 + +- 統一聊天紀錄、聯絡人詳細資訊與聊天話題的右側面板,可從頁首展開或收合、拖曳調整寬度,並保留收合前的瀏覽位置 +- 【桌面版】新增介面縮放設定 + +### 🐛 修復 + +- 修正部分 Windows 使用者升級後,因重複寫入資料目錄相容性標記而無法載入對話清單的問題 +- 修正遠端資料來源設定讀取或儲存失敗時,可能覆寫原設定或誤報成功的問題;保留原始檔案並顯示明確錯誤提示 +- 修正透過代理伺服器下載本機語意模型時,檔案未寫入快取而導致模型載入失敗的問題 (by [@huaqian0226](https://github.com/huaqian0226)) + +## v0.37.4 (2026-09-23) + +> 改善 AI 工具結果的上下文控制,修正 Qwen 話題摘要問題,並完善對話清單載入失敗的提示。 + +### 🐛 修復 + +- 修正 AI 多輪或平行工具呼叫的結果累積後,可能超出模型上下文限制的問題;依每次請求裁剪工具結果,並保留完整對話紀錄 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 修正部分 Qwen 模型因預設開啟思考,導致無法產生話題摘要的問題 +- 話題摘要達到模型輸出上限時顯示明確提示,避免無效重試造成額外用量 +- 對話清單載入失敗時顯示完整錯誤資訊,並在應用程式日誌中保留完整錯誤堆疊,方便排查升級後的權限等問題 +- 補齊 AI 詞頻與回覆速度分析工具的英文時間參數說明 +- 【CLI】Docker 映像檔改用與 npm 發布相同的安裝套件,避免 npm 同步延遲導致映像檔發布失敗 + +## v0.37.3 (2026-09-22) + +> 修正 AI 時間篩選、Qwen 模型相容性與接續對話的問題,改善多語言支援及訊息上下文查詢。 + +### ✨ 新功能 + +- 完善 AI 工具的中英文說明,統一桌面版、CLI Web 與 MCP 的語言處理 (by [@Zycheng114514](https://github.com/Zycheng114514)) + +### 🐛 修復 + +- 修正 AI 詞頻與回覆速度分析忽略所選時間範圍的問題,釐清提問指定時間、頁面篩選與預設範圍的優先順序 (by [@liuyy2](https://github.com/liuyy2)) +- 修正部分 Qwen 模型在 AI 對話與話題摘要中,因訊息角色不相容而發生錯誤的問題 +- 修正補匯入較早訊息後上下文可能錯置的問題,並提升訊息上下文的查詢效能 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 補齊語言偏好卡片缺少的日文標籤 (by [@Zycheng114514](https://github.com/Zycheng114514)) +- 【CLI】修正接續 AI 對話時遺漏已完成工具呼叫結果的問題,保留工具結果與錯誤狀態供後續對話使用 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 【CLI】修正 npm 新版本尚未同步時,Docker 映像檔可能發布失敗的問題 + +## v0.37.2 (2026-09-18) + +> 修正 AI 回覆遭截斷或停止後無法順利接續對話的問題,改善表情符號辨識、技能入口與下載安全檢查。 + +### ✨ 新功能 + +- 詞頻與語言偏好分析可辨識抖音表情代碼,避免將表情名稱列入高頻詞 (by [@babybitter](https://github.com/babybitter)) + +### 🐛 修復 + +- 修正部分介面在 AI 回覆遭截斷後遺失已產生內容或用量資訊的問題,儲存後可接續對話 +- 修正手動停止 AI 回覆後未儲存已產生內容,導致後續提問可能遺失對話脈絡的問題 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 停止 AI 請求時一併取消上下文壓縮,避免額外的模型呼叫與摘要寫入 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 修正下載介面的檔名檢查,防止檔案寫入下載資料夾以外的位置 (by [@ttsdj](https://github.com/ttsdj)) + +### ♻️ 重構 + +- 統一外部 Agent 的匯入、轉換與分析技能入口,依使用者的語言回覆,並保留已發布版本中舊匯入技能安裝指令的相容性 + +## v0.37.1 (2026-09-04) + +> 新增貢獻者署名,改善 Docker 啟動,並修正 AI 工具迴圈、文字統計與關係圖資源占用問題。 + +### ✨ 新功能 + +- 語言偏好、詞頻與口頭禪分析支援讀取語音轉寫內容 (by [@babybitter](https://github.com/babybitter)) +- 【CLI】Docker 映像檔內建預設中文斷詞詞典,首次啟動不再需要下載 (by [@KokerZhou](https://github.com/KokerZhou)) + +### 🐛 修復 + +- 修正 AI 工具呼叫達到回合上限後可能重複產生最終回覆、工具錯誤被記為成功,以及上下文內容重複寫入的問題 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 修正系統通知和分享預留文字被納入詞頻、語言偏好與口頭禪統計的問題,並保留使用者的表情符號內容 (by [@babybitter](https://github.com/babybitter)) + ## v0.37.0 (2026-08-28) > 新增跨對話 AI 分析與長期記憶,修正匯入去重、AI 回覆失敗等多項問題,並改善洞察效能與聊天記錄檢視體驗。
目前跨對話分析的測試仍不完整,屬於實驗性功能,後續版本將持續改善。 @@ -10,17 +109,17 @@ - 新增加長期記憶,可管理偏好、關於我、聯絡人與群組記憶,並依問題自動載入相關內容 - 長期記憶可連結來源訊息,方便回到原始對話核對 AI 產生的結論 - 統一全域與單一對話 AI 的對話清單、輸入框和 @ 選取操作 -- 聊天記錄檢視器可拖曳調整寬度,並記住版面設定 +- 聊天記錄檢視器可拖曳調整寬度,並記住版面設定 (by [@KokerZhou](https://github.com/KokerZhou)) - 分析頁將 SQL 實驗室與除錯工具統一收納至「更多」 -- 【CLI】支援透過 Unix Domain Socket 啟動 Web 服務 +- 【CLI】支援透過 Unix Domain Socket 啟動 Web 服務 (by [@KokerZhou](https://github.com/KokerZhou)) ### 🐛 修復 - 修正 WeFlow 與缺少穩定訊息 ID 的聊天記錄在增量匯入、合併時可能過度去重的問題,避免真實訊息遺失 -- 修正 AI 回覆失敗後,錯誤狀態、對話清單與後續提問可能異常的問題 -- 修正 AI 對話在儲存或編輯時可能留下不完整輪次的問題,並僅允許編輯最新一輪提問 -- 修正長對話壓縮可能遺失訊息邊界或記憶來源連結的問題 -- 修正同名 AI 服務設定可能讀取錯誤憑證的問題 +- 修正 AI 回覆失敗後,錯誤狀態、對話清單與後續提問可能異常的問題 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 修正 AI 對話在儲存或編輯時可能留下不完整輪次的問題,並僅允許編輯最新一輪提問 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 修正長對話壓縮可能遺失訊息邊界或記憶來源連結的問題 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) +- 修正同名 AI 服務設定可能讀取錯誤憑證的問題 (by [@Sanssssssssssssssss](https://github.com/Sanssssssssssssssss)) - 修正切換或返回分析頁面時可能重複載入,或繼續顯示匯入前舊資料的問題 - 完善跨對話分析的時間範圍、聯絡人解析、掃描預算與截斷提示 @@ -41,7 +140,7 @@ ### 🐛 修復 -- 匯入資料夾改為獨立按鈕,避免與選取檔案互相搶入口 +- 匯入資料夾改為獨立按鈕,避免與選取檔案互相搶入口 (by [@kongjiyu](https://github.com/kongjiyu)) ### ⚡ 效能 @@ -109,7 +208,7 @@ - 修正 QQ 自動匯入在 UID/UIN 變更、缺少 owner 資訊或工作階段已合併時的比對與成員對應問題,避免產生重複工作階段或錯誤的成員身分 - 修正合併匯入因平台訊息 ID 衝突或私人聊天身分資訊不足而誤刪訊息的問題,並保留有效的 owner 與工作階段身分資訊 -- 修正動態關鍵字排行的請求、快速切換與多色模式,避免顯示過期結果或因未知類型導致頁面當機 +- 修正動態關鍵字排行的請求、快速切換與多色模式,避免顯示過期結果或因未知類型導致頁面當機 (by [@kelaocai](https://github.com/kelaocai)) - 統一洞察頁面的載入轉場,並恢復年度活動日曆配色與關係檢視切換控制項 ### ⚡ 效能 @@ -125,7 +224,7 @@ - QQ Shuakami 與 QQ Chat Exporter 匯入改用 Rust 原生解析,提升大型匯出檔案的處理穩定性 - 多檔案匯入加入安全的並行排程,避免同一工作階段同時寫入,並顯示各檔案的進度與失敗資訊 - 新增兼顧隱私的匿名產品統計,只回報白名單內的執行環境與功能使用事件 -- 【CLI Web】本機語意索引執行元件改為首次使用時按需安裝,Docker 映像仍預先內建所需元件 +- 【CLI Web】本機語意索引執行元件改為首次使用時按需安裝,Docker 映像仍預先內建所需元件 (by [@kelaocai](https://github.com/kelaocai)) ### 🐛 修復 @@ -183,18 +282,18 @@ ### 🐛 修復 - 修正純 Web 版同時存取 OPFS、概覽篩選競態、詞頻統計與關係分析篩選問題 -- 前端圖示改為隨應用程式一併封裝,避免執行時依賴外部圖示服務 +- 前端圖示改為隨應用程式一併封裝,避免執行時依賴外部圖示服務 (by [@KokerZhou](https://github.com/KokerZhou)) - 統一 ChatLab JSONL 自動辨識與格式驗證規則,並拒絕無法解析的 owner 資訊 - 【桌面版】防止移轉程序刪除目前使用中的資料目錄,並拒絕新舊資料目錄互相巢狀或指向同一路徑 -- 【CLI】Docker 部署會保存使用者資料與系統狀態,並預設只將連接埠綁定至 localhost +- 【CLI】Docker 部署會保存使用者資料與系統狀態,並預設只將連接埠綁定至 localhost (by [@KokerZhou](https://github.com/KokerZhou)) ### 📝 文件 -- 新增 Docker 部署指南,並補充 AI 分析效益與本機隱私說明 +- 新增 Docker 部署指南,並補充 AI 分析效益與本機隱私說明 (by [@KokerZhou](https://github.com/KokerZhou)) ### 👷 CI -- 發布適用於 amd64 與 arm64 架構的 ChatLab CLI Docker 映像檔 +- 發布適用於 amd64 與 arm64 架構的 ChatLab CLI Docker 映像檔 (by [@KokerZhou](https://github.com/KokerZhou)) ## v0.32.0 (2026-07-18) @@ -202,14 +301,14 @@ ### ✨ 新功能 -- 【桌面版】新增應用程式鎖定,支援密碼、立即鎖定、啟動時解鎖,以及閒置逾時自動鎖定 +- 【桌面版】新增應用程式鎖定,支援密碼、立即鎖定、啟動時解鎖,以及閒置逾時自動鎖定 (by [@xyz-225648](https://github.com/xyz-225648)) ### 🐛 修復 - 資料目錄移轉後保留舊資料並提示使用者手動確認清理,避免移轉或重試時誤刪來源檔案 - 大型 ChatLab JSONL 改為串流分批處理,避免匯入卡住,並保留合併匯入中的成員頭像 - 統一桌面版與 CLI Web 的多檔批次匯入流程,提供循序處理進度、取消與結果摘要 -- 【桌面版】應用程式鎖定新增失敗次數限制、冷卻時間與啟動狀態保護,避免重新整理或重新啟動後繞過鎖定 +- 【桌面版】應用程式鎖定新增失敗次數限制、冷卻時間與啟動狀態保護,避免重新整理或重新啟動後繞過鎖定 (by [@xyz-225648](https://github.com/xyz-225648)) ## v0.31.3 (2026-07-17) @@ -253,7 +352,7 @@ - 統一各端 Push 匯入的驗證、去重、成員推斷與 dry-run 結果,避免預覽與實際寫入不一致 - 補匯入歷史訊息後,聊天記錄會依時間與訊息 ID 穩定排序,避免舊訊息出現在最後 -- 修正 QCE / QQ Chat Exporter 私人聊天被誤判為群組並產生「0」成員的問題 +- 修正 QCE / QQ Chat Exporter 私人聊天被誤判為群組並產生「0」成員的問題 (by [@shuakami](https://github.com/shuakami)) ## v0.31.0 (2026-07-13) @@ -271,7 +370,7 @@ - 修正 ChatLab JSON 匯出無法直接重新匯入的問題,並保留成員、owner 與別名元資料 - 強化自動增量匯入的去重、混合訊息身份識別與 ChatLab 重複匯入判斷 -- WhatsApp TXT 解析支援括號時間戳與點號 AM/PM 標記 +- WhatsApp TXT 解析支援括號時間戳與點號 AM/PM 標記 (by [@hh7418695](https://github.com/hh7418695)) ### ♻️ 重構 @@ -314,7 +413,7 @@ - 修正 SQL 實驗室匯出 CSV 失敗的問題 - 匯入 JSONL 等串流成員資料時保留頭像,並支援增量匯入新頭像 - 開啟 AI 日誌改由共享路由處理,讓本機 CLI Web 與桌面端行為一致 -- 概覽身份卡會跟隨目前選取的時間範圍更新 +- 概覽身份卡會跟隨目前選取的時間範圍更新 (by [@qikairo7](https://github.com/qikairo7)) ### 📝 文件 @@ -445,8 +544,8 @@ ### 🐛 修復 - 修正多關鍵字訊息搜尋功能 -- 修正詞雲詞典載入 UX 回歸問題 -- 修正排行榜標籤與 emoji 清理 +- 修正詞雲詞典載入 UX 回歸問題 (by [@qikairo7](https://github.com/qikairo7)) +- 修正排行榜標籤與 emoji 清理 (by [@qikairo7](https://github.com/qikairo7)) - 修正 v8 資料庫遷移缺少國際化文字的問題 - 【CLI】修正工具轉接器中會話上下文傳遞與前處理缺失的問題 @@ -466,8 +565,8 @@ ### 🐛 修復 -- 修正高頻詞統計中含有平台回覆訊息占位符的問題 -- 修正詞雲文字中包含媒體占位符的問題 +- 修正高頻詞統計中含有平台回覆訊息占位符的問題 (by [@qikairo7](https://github.com/qikairo7)) +- 修正詞雲文字中包含媒體占位符的問題 (by [@qikairo7](https://github.com/qikairo7)) - 修正 Jieba 自訂詞典變更後語言偏好快取與詞頻快取未失效的問題 - 修正時效性分析快取未按日失效的問題 - 修正時間軸面板預設捲動位置及會話跳轉功能失效的問題 @@ -555,7 +654,7 @@ - 修正 AI 助理訊息未填滿聊天區域寬度 - 改善分析計畫產生區塊的顯示樣式 - 修正思考深度選擇跨重新啟動後不持久的問題 -- 修正 JSONL 時間戳記規範化導致增量匯入異常 +- 修正 JSONL 時間戳記規範化導致增量匯入異常 (by [@cnYui](https://github.com/cnYui)) - 【桌面版】修正 execute_sql 工具未註冊至桌面版工具清單 ### test @@ -600,8 +699,8 @@ ### ✨ 新功能 -- 新增以斜線指令觸發的 AI 圖表執行環境,可在對話中產生並渲染 ECharts 圖表 -- 改善 AI 圖表結果呈現,補上更清楚的渲染狀態與多種圖表類型支援 +- 新增以斜線指令觸發的 AI 圖表執行環境,可在對話中產生並渲染 ECharts 圖表 (by [@CEQ151](https://github.com/CEQ151)) +- 改善 AI 圖表結果呈現,補上更清楚的渲染狀態與多種圖表類型支援 (by [@CEQ151](https://github.com/CEQ151)) ### 🐛 修復 @@ -609,7 +708,7 @@ - 修正預設問題標籤與側邊欄元素的層疊順序異常 - 【CLI Web】修正開發後端生命週期清理不完整的問題 - 【桌面版】修正自動技能未註冊圖表工具,導致圖表技能無法完整執行的問題 -- 【桌面版】修正 Windows 標題列覆蓋層在切換主題後快取未更新與顯示不順的問題 +- 【桌面版】修正 Windows 標題列覆蓋層在切換主題後快取未更新與顯示不順的問題 (by [@CEQ151](https://github.com/CEQ151)) ### ♻️ 重構 @@ -666,7 +765,7 @@ - 修正資料目錄遷移與資料庫遷移流程中的多個邊界情境,避免舊資料庫欄位缺失或路徑切換後讀取失敗 - 修正舊資料不存在或目錄變更後,資料遷移提示仍錯誤顯示的問題 -- 修正增量匯入後側邊欄訊息數量未更新的問題 +- 修正增量匯入後側邊欄訊息數量未更新的問題 (by [@CEQ151](https://github.com/CEQ151)) - 修正側邊欄收合狀態只存於 sessionStorage,導致重新整理後遺失的問題 - 修正編輯 AI 設定且已儲存金鑰時,拉取模型與驗證按鈕未正確啟用的問題 - 修正 AI 共享 SSE 串流回應的穩定性問題 @@ -717,7 +816,7 @@ ### ✨ 新功能 -- 新增訊息分叉(Fork)功能,可從任意 AI 回覆處建立獨立分支對話 +- 新增訊息分叉(Fork)功能,可從任意 AI 回覆處建立獨立分支對話 (by [@CEQ151](https://github.com/CEQ151)) - 狀態列新增推理強度選擇器,支援為每個推理模型個別記憶並切換思維等級 - 推理等級控制新增 default/auto 選項,擴展對 Kimi、Doubao、Gemini 等更多模型系列的支援 - 訊息分析檢視拆分為「類型分析」與「時間分析」兩個分頁,提供豐富統計洞察卡片 @@ -900,7 +999,7 @@ - 修復 Web 模式下會話索引、跨來源代理、Demo 保護與執行階段錯誤等問題 - 修復 Web 模式下應用程式版本顯示為空的問題 -- 修復 AI Agent 證據檢索、Web 端事件串流與錯誤格式化等問題 +- 修復 AI Agent 證據檢索、Web 端事件串流與錯誤格式化等問題 (by [@CEQ151](https://github.com/CEQ151)) - 修復同步顯示與匯入邏輯不一致的問題 - 修復 CLI 開發模式下的 ESM 模組解析問題 - 修復 CLI 安裝後可能找不到既有桌面版資料的問題 @@ -990,7 +1089,7 @@ ### ✨ 新功能 -- 訂閱會話時支援依類型篩選與選取 +- 訂閱會話時支援依類型篩選與選取 (by [@xunchahaha](https://github.com/xunchahaha)) - 支援遠端會話分頁發現與按需載入更多 - 支援為資料來源設定每次拉取的訊息數量 @@ -1147,7 +1246,7 @@ ### 🐛 修復 - 修正 WhatsApp 時間解析正則與行匹配正則寬嚴不一致的問題 -- 修正 WhatsApp 12 小時制時間與 NNBSP 字元的解析相容性問題 +- 修正 WhatsApp 12 小時制時間與 NNBSP 字元的解析相容性問題 (by [@kongjiyu](https://github.com/kongjiyu)) ### 🔧 雜項 @@ -1210,7 +1309,7 @@ - 優化 AI 對話樣式與整體互動體驗 - 支援 FTS5 全文搜尋,並新增快速搜尋工具 - 精簡部分工具的搜尋參數,降低 token 消耗 -- 新增 Electron 應用 E2E 測試框架,支援連接埠管理與實例隔離 +- 新增 Electron 應用 E2E 測試框架,支援連接埠管理與實例隔離 (by [@l17728](https://github.com/l17728)) ### 🐛 修復 @@ -1223,7 +1322,7 @@ ### test -- 補充可重複使用的 E2E 啟動器冒煙測試能力 +- 補充可重複使用的 E2E 啟動器冒煙測試能力 (by [@l17728](https://github.com/l17728)) ### 📝 文件 @@ -1274,7 +1373,7 @@ ### 🐛 修復 -- 修復訊息去重誤判,並統一空字串的去重行為 +- 修復訊息去重誤判,並統一空字串的去重行為 (by [@lnexin](https://github.com/lnexin)) - 修復 AI 對話流程與前端 type-check 錯誤 - 補上預設 assistant 的兜底邏輯,修復例外情境 - 修復每日訊息趨勢未顯示的問題 @@ -1333,7 +1432,7 @@ - 新增聊天紀錄前處理流程 - 新增前處理設定介面與設定管理能力 -- Agent 支援依會話顯示上下文時間軸與執行狀態 +- Agent 支援依會話顯示上下文時間軸與執行狀態 (by [@n-WN](https://github.com/n-WN)) - 新增 AI 除錯模式並提升日誌可觀測性 ### 🐛 修復 @@ -1413,7 +1512,7 @@ ### 🐛 修復 - 修復增量更新後會話索引產生範圍不準確的問題(fix #79) -- 修復遷移與會話掃描時誤處理非聊天 SQLite 檔案的問題 +- 修復遷移與會話掃描時誤處理非聊天 SQLite 檔案的問題 (by [@n-WN](https://github.com/n-WN)) ### ♻️ 重構 @@ -1437,7 +1536,7 @@ ### 🐛 修復 -- 修復 LINE 聊天紀錄解析問題 +- 修復 LINE 聊天紀錄解析問題 (by [@haotien91](https://github.com/haotien91)) ### 📝 文件 @@ -1449,9 +1548,9 @@ ### ✨ 新功能 -- 設定中新增資料目錄位置選項 +- 設定中新增資料目錄位置選項 (by [@xunchahaha](https://github.com/xunchahaha)) - 優化資料儲存目錄遷移邏輯 -- 切換目錄時新增確認彈窗 +- 切換目錄時新增確認彈窗 (by [@xunchahaha](https://github.com/xunchahaha)) - 優化解析邏輯(WeFlow / Echotrace) ### 🐛 修復 @@ -1459,12 +1558,12 @@ - 修復 Windows 自訂篩選時訊息量過大導致崩潰的問題 - 修復第三方中轉 API 呼叫 tool_call 導致對話異常結束的問題 - 修復部分 WhatsApp 聊天紀錄無法正確辨識的問題 -- 修復管理頁表頭層級顯示問題 +- 修復管理頁表頭層級顯示問題 (by [@xunchahaha](https://github.com/xunchahaha)) ### ♻️ 重構 - 重構 session 查詢模組 -- 補強遷移日誌輸出 +- 補強遷移日誌輸出 (by [@xunchahaha](https://github.com/xunchahaha)) ## v0.9.2 (2026-02-02) @@ -1603,7 +1702,7 @@ - 支援增量匯入 - 支援批次匯入 - 優化樣式 -- Windows 端支援原生視窗控制並實作主題同步(#31) +- Windows 端支援原生視窗控制並實作主題同步(#31) (by [@JiQingzhe2004](https://github.com/JiQingzhe2004)) ### 🔧 雜項 @@ -1697,7 +1796,7 @@ - 支援設定代理(resolve #7) - 優化 AI 模型介面互動 - 新增使用者設定 API 教學 -- 新增 2 個免費 GLM 模型,並加入豆包服務商與最新模型 +- 新增 2 個免費 GLM 模型,並加入豆包服務商與最新模型 (by [@JiQingzhe2004](https://github.com/JiQingzhe2004)) - AI 回覆不再輸出 think 內容 ## v0.1.3 (2025-12-25) diff --git a/docs/cn/ai/external-agent.md b/docs/cn/ai/external-agent.md index 628351632..10fc20b93 100644 --- a/docs/cn/ai/external-agent.md +++ b/docs/cn/ai/external-agent.md @@ -8,22 +8,24 @@ ChatLab 可以让 Codex、Claude Code、HermesAgent 等 AI Agent 在本地查询 ## 开始使用 -需要 Node.js 22.19 或更高版本,并已在 ChatLab 中导入聊天记录。安装 ChatLab CLI 和中文 Skill: +需要 Node.js 22.19 或更高版本,并已在 ChatLab 中导入聊天记录。安装 ChatLab CLI 和分析 Skill: ```bash npm install -g chatlab-cli -npx skills add ChatLab/ChatLab --skill chatlab-analyze-cn -g +npx skills add ChatLab/ChatLab --skill chatlab-analyze -g ``` +各语言共用同一个技能,可以直接用中文提出需求。曾安装 `chatlab-analyze-cn` 时,请先保留自定义改动,再安装上面的统一版本,并通过原安装工具移除旧入口,避免重复显示。 + 安装后,可以在 Codex、Claude Code、Cursor 等外部 Agent 中直接说: ```text -chatlab-analyze-cn 帮我分析我和小红的聊天记录 +chatlab-analyze 帮我分析我和小红的聊天记录 ``` 这个技能会引导 Agent 先执行 `clb manifest` 获取机读命令清单,再用安全的 `--format agent/json` 查询聊天记录。 -`chatlab-analyze-cn` 始终只读。如果你希望 Agent 导入新的聊天导出文件,请改用独立的 `chatlab-import-cn` Skill;它会先预览导入计划,再自动新建或增量导入,详见[导入聊天记录指南](/cn/usage/how-to-import)。 +`chatlab-analyze` 始终只读。如果你希望 Agent 导入新的聊天导出文件,请改用独立的 `chatlab-import` Skill;它会先预览导入计划,再自动新建或增量导入,详见[导入聊天记录指南](/cn/usage/how-to-import)。 ## 命令总览 @@ -65,7 +67,13 @@ agent/json 模式下 stdout 只包含一个 JSON 响应信封,日志一律走 "ok": true, "command": "messages.search", "data": { "text": "returned: 2\n\n--- 2026/6/1 ---\n[#1*] 09:00 老王: 今年五一去旅游吧..." }, - "meta": { "totalHits": 2, "returnedHits": 2, "hasMore": false, "preprocess": { "desensitized": true }, "apiVersion": 1 } + "meta": { + "totalHits": 2, + "returnedHits": 2, + "hasMore": false, + "preprocess": { "desensitized": true }, + "apiVersion": 1 + } } ``` diff --git a/docs/cn/contributing/ai-workflow.md b/docs/cn/contributing/ai-workflow.md new file mode 100644 index 000000000..8434c0ce8 --- /dev/null +++ b/docs/cn/contributing/ai-workflow.md @@ -0,0 +1,73 @@ +--- +layout: doc +title: AI 协作工作流 +--- + +# AI 协作工作流 + +本文说明 ChatLab 如何维护开发代理指令与对外技能。日常入口是根目录 `AGENTS.md`;只有维护技能、调整工作流或处理复杂审查时才需要阅读本文。 + +## 指令放在哪里 + +| 位置 | 用途 | 维护原则 | +| --- | --- | --- | +| `AGENTS.md` | 跨任务稳定约定、项目入口、验证命令 | 保持精简,只保留会影响决策的信息 | +| `docs/cn/contributing/` | 公开开发细则 | 按任务引用,公开 PR 可独立理解 | +| `skills/*/SKILL.md` | 供外部 Agent 使用的分析、导入、转换流程 | 每个技能一个统一名称,使用简短双语描述与英文正文;回答遵循用户语言 | +| `skills/*/references/`、`scripts/` | 格式契约与严格验证器 | 按需读取;确定性校验交给脚本 | +| `.github/workflows/` | CI、构建、发布及产物清理 | 保留真实质量门禁,不承载模型提示词 | +| 可选的本地规则和私有技能 | 个人协作偏好与维护任务 | 不覆盖用户当前意图,不作为公开贡献前提 | + +`skills/` 中的文件是产品分发资源,不会因存在于仓库就自动成为 Codex 开发技能。需要本地自动发现时,按宿主支持的技能安装方式配置;不要为了开发本项目而自动加载聊天分析技能。Codex 的仓库技能发现目录是 `.agents/skills`,正文按需加载。[官方技能文档](https://learn.chatgpt.com/docs/build-skills) + +## 执行与授权 + +从用户目标、输入和完成条件出发,直接完成已授权的工作。复杂任务可以先列简短计划;计划不构成新的审批门槛。只有会影响正确性、数据归属或授权范围的缺失信息才需要澄清。 + +已明确要求“转换并导入”时,验证成功后继续导入;仅要求预览时只返回计划。安装依赖、提交、推送和发布依各自授权执行。工具缺失时完成可做的工作,并准确说明未完成的验证或操作。 + +一次性审批要求不能散落在技能描述、默认提示词和正文中互相冲突。需要停下时说明具体规则及缺少的授权;不要求每次回复都进入交互弹窗,也不以固定口令作为任务完成条件。 + +## 技能维护 + +- `description` 只写任务和触发边界;避免平台长列表、安装说明和重复的适用场景。 +- 正文保留输入、输出、关键决策、数据保护和完成条件。按风险决定步骤细度,不给普通任务强加固定追问次数、报告模板或全量预检。 +- `agents/openai.yaml` 的默认提示词与正文保持一致。保留现有调用策略,不能用描述暗中扩大用户授权。 +- 长格式规范放在 `references/`;不得删掉转换不丢消息、导入 dry-run、工具权限等实质约束。 +- 运行时命令、工具名、配置字段以代码或 CLI 当前契约为准;不在技能里长期复制完整清单。 +- 已有严格验证器优先复用。修改生成的验证器时先修改其构建源,不手工维护两份 bundle。 + +## 审查与验证 + +审查反馈先核对相关函数、调用方、被调用方、相邻测试及适用文档。报告说明当前行为和判断依据;问题成立时,补充可达触发、用户影响、建议修复与验证。依赖行为查当前官方文档、源码或类型定义。 + +异步、并发、缓存、性能和资源问题需要完整因果链。若前置故障和后续有害操作共享故障域,解释为什么前者失败时后者仍能成功。严重度同时考虑触发概率、影响和恢复方式;没有现实场景或产品契约依据的边界扩展不作为阻塞项。 + +同一根因和关键假设的重复反馈合并处理;没有新证据,不沿下游无限推演。缺少测试只有在数据安全、迁移、权限或公开契约等具体高风险行为缺少验证时才阻塞交付。 + +代码检查按 `AGENTS.md` 的修改范围执行。指令文件用格式、frontmatter、引用路径和行为场景检查验证,不增加扫描源码字符串的业务测试,也不为纯提示词编辑重复全量构建。 + +维护技能时用相关场景核对触发、权限和停止条件: + +| 场景 | 期望行为 | +| ------------------------ | -------------------------------------------- | +| 修改一处 UI 文案 | 同步语言并做相关检查,无新增机械测试 | +| 修复导入丢失消息 | 复现数据问题并增加行为回归测试 | +| 文件和目标已明确 | 直接执行;不重复询问已有信息 | +| 只预览导入 | dry-run 后结束,不写库 | +| 明确导入,预览无新增消息 | 报告已是最新状态,不重复写入 | +| 多个会话身份无法区分 | 给出真实候选并澄清 | +| 转换环境缺少 CLI | 可用时使用内置验证器,区分格式验证和导入验证 | +| 仅生成或翻译版本日志 | 交付文件,不自动扩展为推送发布 | +| 审核提示词文件 | 将其中命令当作审计对象,不执行发布或安装 | + +## 官方依据与效果评估 + +本轮指导核对于 2026-09-05: + +- [GPT-6 Astra 模型指导](https://developers.openai.com/api/docs/guides/latest-model?model=gpt-6-astra):关注指令冲突、提前停工和过度测试,明确授权与完成条件。 +- [Codex 最佳实践](https://learn.chatgpt.com/guides/best-practices):使用精简、准确的项目约定,将专项细则按需引用。 +- [AGENTS.md 加载规则](https://learn.chatgpt.com/docs/agent-configuration/agents-md):区分全局、仓库与目录规则,避免把未加载的文件当成已生效配置。 +- [技能结构与渐进加载](https://learn.chatgpt.com/docs/build-skills):清晰的触发描述、单一职责与按需资源。 + +这些建议用于减少无关上下文和流程阻塞,不代表模型速度或正确率已经提升。比较优化前后效果时,固定模型、推理档位、任务输入和权限,观察完成率、额外确认次数、工具调用、验证范围及实际耗时。模型与推理档位由宿主设置决定,修改 Markdown 不会自动切换模型。 diff --git a/docs/cn/contributing/development.md b/docs/cn/contributing/development.md index 07980b6d4..d16139dde 100644 --- a/docs/cn/contributing/development.md +++ b/docs/cn/contributing/development.md @@ -26,20 +26,20 @@ pnpm install ## 本地运行 -| 命令 | 用途 | -| --- | --- | -| `pnpm dev` | 交互式选择 Desktop、CLI Web、CLI Web 的 API Server、Web WASM 或文档站开发目标 | -| `pnpm dev:desktop` | 启动 Electron 桌面端开发模式 | -| `pnpm dev:cli-web` | 启动 CLI Web(Node 后端 + Web UI)开发模式,默认访问 `http://127.0.0.1:3100/` | -| `pnpm dev:web-wasm` | 启动 Web WASM(纯浏览器运行),默认访问 `http://127.0.0.1:3130/` | -| `pnpm docs:dev` | 启动公开文档站开发模式 | -| `pnpm build:desktop` | 构建桌面端 | -| `pnpm build:cli-web` | 构建 CLI Web UI | -| `pnpm build:web-wasm` | 构建 Web WASM | -| `pnpm docs:build` | 构建公开文档站 | -| `pnpm run type-check:all` | 运行前端和 Node 侧类型检查 | -| `pnpm lint` | 运行 ESLint 并自动修复 | -| `pnpm format` | 运行 Prettier 格式化 | +| 命令 | 用途 | +| ------------------------- | ----------------------------------------------------------------------------- | +| `pnpm dev` | 交互式选择 Desktop、CLI Web、CLI Web 的 API Server、Web WASM 或文档站开发目标 | +| `pnpm dev:desktop` | 启动 Electron 桌面端开发模式 | +| `pnpm dev:cli-web` | 启动 CLI Web(Node 后端 + Web UI)开发模式,默认访问 `http://127.0.0.1:3100/` | +| `pnpm dev:web-wasm` | 启动 Web WASM(纯浏览器运行),默认访问 `http://127.0.0.1:3130/` | +| `pnpm docs:dev` | 启动公开文档站开发模式 | +| `pnpm build:desktop` | 构建桌面端 | +| `pnpm build:cli-web` | 构建 CLI Web UI | +| `pnpm build:web-wasm` | 构建 Web WASM | +| `pnpm docs:build` | 构建公开文档站 | +| `pnpm run type-check:all` | 运行前端和 Node 侧类型检查 | +| `pnpm lint` | 运行 ESLint 并自动修复 | +| `pnpm format` | 运行 Prettier 格式化 | 小范围改动优先对修改文件或相关子项目做定向检查;跨模块、发布或架构类改动再跑全量检查。 @@ -53,18 +53,18 @@ pnpm install ## 目录职责 -| 路径 | 职责 | -| --- | --- | -| `src/` | 共享前端应用代码,包含页面、组件、服务封装、状态和 i18n | -| `src/services/` | 前端访问 Electron、CLI Web API 和平台能力的服务层 | -| `apps/desktop/` | Electron 主进程、preload 和桌面端构建配置 | -| `apps/cli/` | CLI、HTTP API、CLI Web 运行时和导入命令 | -| `packages/core/` | 平台无关的核心数据模型、查询、导入和成员操作 | -| `packages/node-runtime/` | Node.js 运行时服务、数据库、AI、导出、缓存和迁移 | -| `packages/tools/` | 统一 AI 工具定义和数据访问适配 | -| `docs/` | 公开文档站源码 | -| `changelogs/` | 应用内和发布使用的多语言更新日志 | -| `.docs/` | 可选的个人或团队私有开发上下文,不是公开贡献的必需依赖 | +| 路径 | 职责 | +| ------------------------ | ------------------------------------------------------- | +| `src/` | 共享前端应用代码,包含页面、组件、服务封装、状态和 i18n | +| `src/services/` | 前端访问 Electron、CLI Web API 和平台能力的服务层 | +| `apps/desktop/` | Electron 主进程、preload 和桌面端构建配置 | +| `apps/cli/` | CLI、HTTP API、CLI Web 运行时和导入命令 | +| `packages/core/` | 平台无关的核心数据模型、查询、导入和成员操作 | +| `packages/node-runtime/` | Node.js 运行时服务、数据库、AI、导出、缓存和迁移 | +| `packages/tools/` | 统一 AI 工具定义和数据访问适配 | +| `docs/` | 公开文档站源码 | +| `changelogs/` | 应用内和发布使用的多语言更新日志 | +| `.docs/` | 可选的个人或团队私有开发上下文,不是公开贡献的必需依赖 | ## 架构边界 @@ -129,23 +129,23 @@ CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR=1 ## 常见改动入口 -| 想改什么 | 先看哪里 | -| --- | --- | -| 前端页面和组件 | `src/pages/`、`src/components/` | -| 图表分析 | `src/components/analysis/`、`src/components/charts/` | -| 数据、消息、会话 API 调用 | `src/services/` | -| Electron 主进程 | `apps/desktop/main/`、`apps/desktop/preload/` | -| CLI 和 Web API | `apps/cli/` | -| 共享业务逻辑 | `packages/node-runtime/src/services/`、`packages/core/` | -| AI 工具和 Agent | `packages/tools/`、`packages/node-runtime/src/ai/`、`src/services/ai*` | -| 导入解析 | `packages/core/`、`apps/cli/src/import/`、`src/services/import/` | -| 文档站 | `docs/`、`docs/.vitepress/config.mts` | -| 更新日志 | `changelogs/` | +| 想改什么 | 先看哪里 | +| ------------------------- | ---------------------------------------------------------------------- | +| 前端页面和组件 | `src/pages/`、`src/components/` | +| 图表分析 | `src/components/analysis/`、`src/components/charts/` | +| 数据、消息、会话 API 调用 | `src/services/` | +| Electron 主进程 | `apps/desktop/main/`、`apps/desktop/preload/` | +| CLI 和 Web API | `apps/cli/` | +| 共享业务逻辑 | `packages/node-runtime/src/services/`、`packages/core/` | +| AI 工具和 Agent | `packages/tools/`、`packages/node-runtime/src/ai/`、`src/services/ai*` | +| 导入解析 | `packages/core/`、`apps/cli/src/import/`、`src/services/import/` | +| 文档站 | `docs/`、`docs/.vitepress/config.mts` | +| 更新日志 | `changelogs/` | ## 测试与检查 - 修改 TypeScript 或 Vue 代码后,至少运行相关类型检查。 -- 修改公开文档后,运行 `pnpm docs:build` 或对修改的 Markdown/配置文件运行格式化检查。 +- 修改公开文档或 VitePress 配置后,运行 `pnpm docs:build`,并格式化修改文件;`docs/**/*.md` 被 Prettier 默认忽略,定向格式化时使用 `pnpm exec prettier --write --ignore-path .gitignore `。 - 修改跨平台共享逻辑后,确认 Electron 和 CLI Web 两端入口没有产生行为分歧。 - 修复会影响用户数据、业务逻辑、异步任务、缓存状态、跨端共享 service、公开 API 契约、导入解析、去重逻辑、权限认证、AI 工具 allowlist、配置/API key 迁移或数据库 schema/迁移的行为 bug 时,必须优先补能失败的回归测试。 - 只改 UI 文案、i18n key/翻译、样式、类型声明、日志、注释、无行为变化的小重构,或修复低风险展示细节时,可以不新增测试,但仍需运行相关类型检查、lint 和 format;不要为了低价值页面文案或源码字符串扫描新增脆弱测试。 @@ -163,6 +163,8 @@ CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR=1 AI 可以帮助阅读代码、生成补丁和补测试,但公开 PR 必须让 reviewer 能在公开上下文中理解。建议让 AI 先读 `AGENTS.md` 和本页;如果你维护了自己的 `.docs/`,可以把它作为额外上下文,但不要让变更说明、测试理由或设计依据只存在于私有 `.docs/` 中。 +维护代理指令、技能或审查流程时,参见 [AI 协作工作流](./ai-workflow.md)。日常任务按修改范围完成验证,不需要加载所有技能或反复执行全量检查。 + ## PR 与提交规范 - 明显的 Bug 修复可以直接提交 PR。 diff --git a/docs/cn/standard/ai-converter.md b/docs/cn/standard/ai-converter.md index faf956931..fd876e150 100644 --- a/docs/cn/standard/ai-converter.md +++ b/docs/cn/standard/ai-converter.md @@ -16,24 +16,26 @@ outline: deep npm install -g chatlab-cli ``` -再在支持 Skills 的 Agent 环境中安装中文版转换 Skill: +再在支持 Skills 的 Agent 环境中安装转换 Skill: ```bash -npx skills add ChatLab/ChatLab --skill chatlab-convert-cn -g +npx skills add ChatLab/ChatLab --skill chatlab-convert -g ``` +各语言共用同一个技能,可以直接用中文提出需求。曾安装 `chatlab-convert-cn` 时,请先保留自定义改动,再安装上面的统一版本,并通过原安装工具移除旧入口,避免重复显示。 + ## 开始转换 把聊天导出的准确路径告诉 Agent: ```text -使用 $chatlab-convert-cn,把 "/你的/聊天记录路径" 转换并导入 ChatLab。 +使用 $chatlab-convert,把 "/你的/聊天记录路径" 转换并导入 ChatLab。 ``` 如果只希望生成转换结果,不立即导入: ```text -使用 $chatlab-convert-cn,把 "/你的/聊天记录路径" 转换为 ChatLab 格式,只转换,不导入。 +使用 $chatlab-convert,把 "/你的/聊天记录路径" 转换为 ChatLab 格式,只转换,不导入。 ``` Skill 会自动选择电脑上已有的 Node.js、Python 或 Shell 工具,完成以下流程: @@ -60,5 +62,7 @@ clb import "/转换后的文件.jsonl" --dry-run --json 只有严格验证和导入预览都成功,并且源消息数与输出消息数核对一致,才会报告转换成功。转换脚本和输出文件都会保留,方便以后重新转换更新后的聊天记录。 ::: warning 隐私提醒 + 请使用能够访问本机文件和终端的 Agent 执行转换,不要把完整聊天文件上传到在线对话框。即使需要排查特殊文本格式,也应只检查最小范围并隐藏消息正文。 + ::: diff --git a/docs/cn/usage/how-to-import.md b/docs/cn/usage/how-to-import.md index 90d7699f5..f69daad50 100644 --- a/docs/cn/usage/how-to-import.md +++ b/docs/cn/usage/how-to-import.md @@ -19,17 +19,19 @@ ChatLab 支持多种方式导入聊天记录。 适合已经使用 Codex、Claude Code 等各类 AI Agent,希望让 Agent 代为执行导入的用户。 -需要 Node.js 22.19 或更高版本。安装 ChatLab CLI 和中文 Skill: +需要 Node.js 22.19 或更高版本。安装 ChatLab CLI 和导入 Skill: ```bash npm install -g chatlab-cli -npx skills add ChatLab/ChatLab --skill chatlab-import-cn -g +npx skills add ChatLab/ChatLab --skill chatlab-import -g ``` +各语言共用同一个技能,可以直接用中文提出需求。`chatlab-import-cn` 作为已弃用的兼容入口保留,确保 ChatLab v0.31.1 至 v0.37.1 首页中的旧安装命令继续有效;其正文也使用英文,回答遵循用户语言。曾安装旧入口时,请先保留自定义改动,再安装上面的统一版本,并通过原安装工具移除旧入口,避免重复显示。 + 然后直接告诉 Agent: ```text -chatlab-import-cn 帮我把 /absolute/path/to/chat-export.json 导入 ChatLab +chatlab-import 帮我把 /absolute/path/to/chat-export.json 导入 ChatLab ``` Agent 会先在后台预览,预览成功后自动新建或增量导入,无需再次确认。 diff --git a/docs/en/ai/external-agent.md b/docs/en/ai/external-agent.md index 84323cb79..5e94a6095 100644 --- a/docs/en/ai/external-agent.md +++ b/docs/en/ai/external-agent.md @@ -8,13 +8,15 @@ ChatLab lets AI agents such as Codex, Claude Code, and HermesAgent query and ana ## Get started -Node.js 22.19 or newer is required, and chat records must already be imported into ChatLab. Install ChatLab CLI and the English Skill: +Node.js 22.19 or newer is required, and chat records must already be imported into ChatLab. Install ChatLab CLI and the analysis skill: ```bash npm install -g chatlab-cli npx skills add ChatLab/ChatLab --skill chatlab-analyze -g ``` +The same skill works across languages; ask in your preferred language. + After installation, ask Codex, Claude Code, Cursor, or another external agent: ```text @@ -65,7 +67,13 @@ In agent/json mode stdout contains exactly one JSON envelope; logs go to stderr: "ok": true, "command": "messages.search", "data": { "text": "returned: 2\n\n--- 2026/6/1 ---\n[#1*] 09:00 Wang: how about a trip on May Day..." }, - "meta": { "totalHits": 2, "returnedHits": 2, "hasMore": false, "preprocess": { "desensitized": true }, "apiVersion": 1 } + "meta": { + "totalHits": 2, + "returnedHits": 2, + "hasMore": false, + "preprocess": { "desensitized": true }, + "apiVersion": 1 + } } ``` diff --git a/docs/en/standard/ai-converter.md b/docs/en/standard/ai-converter.md index ff294dd9c..3511e3d8e 100644 --- a/docs/en/standard/ai-converter.md +++ b/docs/en/standard/ai-converter.md @@ -22,6 +22,8 @@ Install the conversion skill in a skill-compatible agent environment: npx skills add ChatLab/ChatLab --skill chatlab-convert -g ``` +The same skill works across languages; ask in your preferred language. + ## Convert an export Give the agent the exact export path: @@ -60,5 +62,7 @@ clb import "/absolute/path/to/converted.jsonl" --dry-run --json Success requires strict validation, a successful import preview, and matching source/output message counts. The converter and outputs remain available so a newer export can be converted again. ::: warning Privacy + Use an agent that can work with local files and a local terminal. Do not upload a complete chat export to an online chat box. When an unusual text format requires sampling, inspect the smallest possible range and mask message bodies. + ::: diff --git a/docs/en/usage/how-to-import.md b/docs/en/usage/how-to-import.md index 3bd707c8e..a41369f7e 100644 --- a/docs/en/usage/how-to-import.md +++ b/docs/en/usage/how-to-import.md @@ -19,13 +19,15 @@ The homepage also supports incremental imports. When a new file matches an impor Use this when you already work with Codex, Claude Code, or another AI agent and want the agent to handle the import for you. -Node.js 22.19 or newer is required. Install ChatLab CLI and the English skill: +Node.js 22.19 or newer is required. Install ChatLab CLI and the import skill: ```bash npm install -g chatlab-cli npx skills add ChatLab/ChatLab --skill chatlab-import -g ``` +The same skill works across languages; ask in your preferred language. The deprecated `chatlab-import-cn` entry remains available for installation commands shown in ChatLab v0.31.1 through v0.37.1. Its instructions are also in English, and responses follow your language. If you already installed it, preserve any custom changes, install the unified skill above, then remove the old entry with your original installation tool to avoid duplicates. + Then ask the agent: ```text diff --git a/docs/tw/standard/ai-converter.md b/docs/tw/standard/ai-converter.md index 45a37f502..5d4f3df03 100644 --- a/docs/tw/standard/ai-converter.md +++ b/docs/tw/standard/ai-converter.md @@ -16,24 +16,26 @@ outline: deep npm install -g chatlab-cli ``` -再於支援 Skills 的 Agent 環境安裝中文版轉換 Skill: +再於支援 Skills 的 Agent 環境安裝轉換 Skill: ```bash -npx skills add ChatLab/ChatLab --skill chatlab-convert-cn -g +npx skills add ChatLab/ChatLab --skill chatlab-convert -g ``` +各語言共用同一個技能,可以直接用繁體中文提出需求。曾安裝 `chatlab-convert-cn` 時,請先保留自訂修改,再安裝上面的統一版本,並透過原安裝工具移除舊入口,避免重複顯示。 + ## 開始轉換 把聊天匯出的準確路徑告訴 Agent: ```text -使用 $chatlab-convert-cn,把「/你的/聊天記錄路徑」轉換並匯入 ChatLab。 +使用 $chatlab-convert,把「/你的/聊天記錄路徑」轉換並匯入 ChatLab。 ``` 如果只希望產生轉換結果,不立即匯入: ```text -使用 $chatlab-convert-cn,把「/你的/聊天記錄路徑」轉換為 ChatLab 格式,只轉換,不匯入。 +使用 $chatlab-convert,把「/你的/聊天記錄路徑」轉換為 ChatLab 格式,只轉換,不匯入。 ``` Skill 會自動選擇電腦上已有的 Node.js、Python 或 Shell 工具,完成以下流程: @@ -60,5 +62,7 @@ clb import "/轉換後的檔案.jsonl" --dry-run --json 只有嚴格驗證和匯入預覽都成功,而且來源訊息數與輸出訊息數核對一致,才會回報轉換成功。轉換腳本和輸出檔案都會保留,方便之後重新轉換更新後的聊天記錄。 ::: warning 隱私提醒 + 請使用能存取本機檔案和終端的 Agent 執行轉換,不要把完整聊天檔案上傳到線上對話框。即使需要排查特殊文字格式,也應只檢查最小範圍並隱藏訊息正文。 + ::: diff --git a/docs/tw/usage/how-to-import.md b/docs/tw/usage/how-to-import.md index 3c5f27200..c247320cd 100644 --- a/docs/tw/usage/how-to-import.md +++ b/docs/tw/usage/how-to-import.md @@ -19,17 +19,19 @@ ChatLab 支援多種方式匯入聊天記錄。 適合已經使用 Codex、Claude Code 等各類 AI Agent,希望讓 Agent 代為執行匯入的使用者。 -需要 Node.js 22.19 或更高版本。安裝 ChatLab CLI 和中文 Skill: +需要 Node.js 22.19 或更高版本。安裝 ChatLab CLI 和匯入 Skill: ```bash npm install -g chatlab-cli -npx skills add ChatLab/ChatLab --skill chatlab-import-cn -g +npx skills add ChatLab/ChatLab --skill chatlab-import -g ``` +各語言共用同一個技能,可以直接用繁體中文提出需求。`chatlab-import-cn` 作為已棄用的相容入口保留,確保 ChatLab v0.31.1 至 v0.37.1 首頁中的舊安裝命令繼續有效;其正文也使用英文,回覆遵循使用者語言。曾安裝舊入口時,請先保留自訂修改,再安裝上面的統一版本,並透過原安裝工具移除舊入口,避免重複顯示。 + 然後直接告訴 Agent: ```text -chatlab-import-cn 幫我把 /absolute/path/to/chat-export.json 匯入 ChatLab +chatlab-import 幫我把 /absolute/path/to/chat-export.json 匯入 ChatLab ``` Agent 會先在背景預覽,預覽成功後自動建立或增量匯入,無需再次確認。 diff --git a/package.json b/package.json index 221b11d20..6d42b6834 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ChatLab", - "version": "0.37.0", + "version": "0.37.7", "private": true, "description": "本地化的聊天记录分析工具,通过 SQL 和 AI Agent 回顾你的社交记忆", "repository": { diff --git a/packages/config/src/loader.ts b/packages/config/src/loader.ts index b70c912cb..5f75c58fb 100644 --- a/packages/config/src/loader.ts +++ b/packages/config/src/loader.ts @@ -138,7 +138,17 @@ export function writeConfigField(section: string, key: string, value: string | n if (!existing[section] || typeof existing[section] !== 'object') { existing[section] = {} } - existing[section][key] = value + if (section === 'ui' && key === 'default_session_tab') { + if (value === 'memory') { + existing.ui.default_session_tab = 'overview' + existing.ui.default_session_tab_override = 'memory' + } else { + existing.ui.default_session_tab = value + delete existing.ui.default_session_tab_override + } + } else { + existing[section][key] = value + } const lines: string[] = [] for (const [sec, entries] of Object.entries(existing)) { diff --git a/packages/config/src/schema.test.ts b/packages/config/src/schema.test.ts index a8afafc72..06e7313c1 100644 --- a/packages/config/src/schema.test.ts +++ b/packages/config/src/schema.test.ts @@ -14,8 +14,44 @@ test('normalizes the released overview preference to insights', () => { assert.equal(config.ui.default_session_tab, 'insights') }) +test('accepts memory as a session landing tab', () => { + const config = configSchema.parse({ + ui: { + default_session_tab: 'overview', + default_session_tab_override: 'memory', + }, + }) + + assert.equal(config.ui.default_session_tab, 'memory') +}) + +test('accepts canonical memory input and its parsed output', () => { + const config = configSchema.parse({ ui: { default_session_tab: 'memory' } }) + + assert.equal(config.ui.default_session_tab, 'memory') + assert.equal(configSchema.parse(config).ui.default_session_tab, 'memory') +}) + +test('prefers a later legacy session tab change over the memory override', () => { + const config = configSchema.parse({ + ui: { + default_session_tab: 'ai-chat', + default_session_tab_override: 'memory', + }, + }) + + assert.equal(config.ui.default_session_tab, 'ai-chat') +}) + +test('does not reinterpret a historical overview preference as memory', () => { + const config = configSchema.parse({ ui: { default_session_tab: 'overview' } }) + + assert.equal(config.ui.default_session_tab, 'insights') +}) + test('rejects unknown session landing tabs', () => { assert.throws(() => configSchema.parse({ ui: { default_session_tab: 'unknown' } })) + assert.throws(() => configSchema.parse({ ui: { default_session_tab_override: 'unknown' } })) }) test('defaults Windows close behavior to running in the background', () => { diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index f43f44344..96b44850b 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -30,14 +30,25 @@ export const localeConfigSchema = z.object({ lang: z.string().default(''), }) -export const uiConfigSchema = z.object({ - default_session_tab: z.preprocess( - (value) => (value === 'overview' ? 'insights' : value), - z.enum(['insights', 'ai-chat']).default('insights') - ), - session_gap_threshold: z.number().int().min(60).max(86400).default(1800), - summary_strategy: z.enum(['brief', 'standard']).default('standard'), -}) +const defaultSessionTabSchema = z.preprocess( + (value) => (value === 'overview' ? 'insights' : value), + z.enum(['insights', 'ai-chat', 'memory']).default('insights') +) + +export const uiConfigSchema = z + .object({ + default_session_tab: z.unknown().optional(), + default_session_tab_override: z.literal('memory').optional(), + session_gap_threshold: z.number().int().min(60).max(86400).default(1800), + summary_strategy: z.enum(['brief', 'standard']).default('standard'), + }) + .transform(({ default_session_tab_override, ...config }) => ({ + ...config, + default_session_tab: + default_session_tab_override && config.default_session_tab === 'overview' + ? default_session_tab_override + : defaultSessionTabSchema.parse(config.default_session_tab), + })) export const cliConfigSchema = z.object({ /** Allow --raw output that bypasses privacy preprocessing (user-side opt-in). */ @@ -48,6 +59,7 @@ export const cliConfigSchema = z.object({ export const desktopConfigSchema = z.object({ close_behavior: z.enum(['background', 'quit']).default('background'), + ui_scale: z.number().min(0.9).max(1.5).default(1), }) export const configSchema = z.object({ diff --git a/packages/config/src/set-config-field.test.ts b/packages/config/src/set-config-field.test.ts index a7d3bd977..3b42bb779 100644 --- a/packages/config/src/set-config-field.test.ts +++ b/packages/config/src/set-config-field.test.ts @@ -3,9 +3,12 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync import { join } from 'node:path' import { tmpdir } from 'node:os' import { after, beforeEach, describe, it } from 'node:test' +import { parse as parseToml } from 'smol-toml' +import { z } from 'zod' const tempHome = mkdtempSync(join(tmpdir(), 'chatlab-config-set-')) process.env.HOME = tempHome +process.env.USERPROFILE = tempHome const [{ setConfigField, ConfigSetError }, { loadConfig }] = await Promise.all([ import('./set-config-field'), @@ -45,6 +48,23 @@ describe('cli config section', () => { }) describe('setConfigField', () => { + it('defaults old desktop configs to 100% and persists UI scale without changing other preferences', () => { + mkdirSync(join(tempHome, '.chatlab'), { recursive: true }) + writeFileSync(configToml, '[desktop]\nclose_behavior = "quit"\n[locale]\nlang = "en-US"\n', 'utf-8') + + assert.equal(loadConfig().desktop.ui_scale, 1) + setConfigField('desktop.ui_scale', '1.25') + + const config = loadConfig() + assert.equal(config.desktop.ui_scale, 1.25) + assert.equal(config.desktop.close_behavior, 'quit') + assert.equal(config.locale.lang, 'en-US') + + const saved = readFileSync(configToml, 'utf-8') + assert.throws(() => setConfigField('desktop.ui_scale', '0')) + assert.equal(readFileSync(configToml, 'utf-8'), saved) + }) + it('persists the Windows close behavior', () => { const result = setConfigField('desktop.close_behavior', 'background') @@ -52,6 +72,38 @@ describe('setConfigField', () => { assert.equal(loadConfig().desktop.close_behavior, 'background') }) + it('persists memory without breaking the v0.37.5 UI config schema', () => { + setConfigField('ui.default_session_tab', 'memory') + + assert.equal(loadConfig().ui.default_session_tab, 'memory') + + const saved = parseToml(readFileSync(configToml, 'utf-8')) as { ui: Record } + const releasedUiConfigSchema = z.object({ + default_session_tab: z.preprocess( + (value) => (value === 'overview' ? 'insights' : value), + z.enum(['insights', 'ai-chat']).default('insights') + ), + session_gap_threshold: z.number().int().min(60).max(86400).default(1800), + summary_strategy: z.enum(['brief', 'standard']).default('standard'), + }) + assert.equal(releasedUiConfigSchema.parse(saved.ui).default_session_tab, 'insights') + }) + + it('honors a later session tab write from a v0.37.5 client', () => { + setConfigField('ui.default_session_tab', 'memory') + + let content = readFileSync(configToml, 'utf-8').replace( + 'default_session_tab = "overview"', + 'default_session_tab = "ai-chat"' + ) + writeFileSync(configToml, content, 'utf-8') + assert.equal(loadConfig().ui.default_session_tab, 'ai-chat') + + content = content.replace('default_session_tab = "ai-chat"', 'default_session_tab = "insights"') + writeFileSync(configToml, content, 'utf-8') + assert.equal(loadConfig().ui.default_session_tab, 'insights') + }) + it('writes boolean value and round-trips via loadConfig', () => { const result = setConfigField('cli.allow_raw', 'true') assert.deepEqual(result, { section: 'cli', key: 'allow_raw', value: true }) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aaa63073d..185d913bf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -298,6 +298,7 @@ export { getStopwords, isStopword, cleanText, + isSystemMessageContent, isValidWord, } from './nlp' export type { diff --git a/packages/core/src/nlp/bracket-emoji.ts b/packages/core/src/nlp/bracket-emoji.ts new file mode 100644 index 000000000..b9f4917d9 --- /dev/null +++ b/packages/core/src/nlp/bracket-emoji.ts @@ -0,0 +1,290 @@ +/** + * Platform bracket emoji code tables (pure data, platform-agnostic). + * + * Chat exports render platform-specific emoji as bracketed code names such as + * `[微笑]`. Text analysis must tell these emoji codes apart from user-authored + * text, so each table below lists the code names observed in real exports of + * one platform. The WeChat table keeps its classic semantic mapping to Unicode + * emoji; other platforms only need name recognition for text cleaning. + */ + +/** + * WeChat classic bracket emoji codes mapped to their Unicode equivalents. + * Kept as a mapping because the converted emoji may carry meaning beyond + * plain recognition. + */ +export const WECHAT_BRACKET_EMOJI_MAP: Record = { + 破涕为笑: '😂', + 微笑: '🙂', + 呲牙: '😁', + 大笑: '😄', + 笑哭: '😂', + 流泪: '😢', + 捂脸: '🤦', + 发呆: '😳', + 害羞: '😊', + 调皮: '😜', + 色: '😍', + 惊讶: '😮', + 撇嘴: '😒', + 难过: '😞', + 酷: '😎', + 抓狂: '😫', + 吐: '🤮', + 偷笑: '🤭', + 可爱: '😊', + 白眼: '🙄', + 傲慢: '😤', + 饥饿: '😋', + 困: '😴', + 惊恐: '😱', + 流汗: '😅', + 憨笑: '😄', + 悠闲: '😌', + 奋斗: '💪', + 咒骂: '😡', + 疑问: '❓', + 嘘: '🤫', + 晕: '😵', + 衰: '😞', + 敲打: '👊', + 再见: '👋', + 擦汗: '😅', + 抠鼻: '👃', + 鼓掌: '👏', + 坏笑: '😏', + 左哼哼: '😤', + 右哼哼: '😤', + 哈欠: '🥱', + 鄙视: '😒', + 委屈: '🥺', + 快哭了: '😢', + 阴险: '😏', + 亲亲: '😘', + 吓: '😱', + 可怜: '🥺', + 菜刀: '🔪', + 西瓜: '🍉', + 啤酒: '🍺', + 篮球: '🏀', + 乒乓: '🏓', + 咖啡: '☕', + 饭: '🍚', + 猪头: '🐷', + 玫瑰: '🌹', + 凋谢: '🥀', + 爱心: '❤️', + 心碎: '💔', + 蛋糕: '🎂', + 闪电: '⚡', + 炸弹: '💣', + 刀: '🔪', + 足球: '⚽', + 便便: '💩', + 月亮: '🌙', + 太阳: '☀️', + 礼物: '🎁', + 拥抱: '🤗', + 强: '👍', + 弱: '👎', + 握手: '🤝', + 胜利: '✌️', + 抱拳: '🙏', + 勾引: '☝️', + 拳头: '✊', + 差劲: '👎', + 爱你: '🤟', + NO: '🙅', + OK: '👌', +} + +/** + * Bracket emoji code names collected from Douyin chat exports, including the + * `[躺平]` and `[强壮]` codes covered by earlier system-message separation + * work. + */ +const DOUYIN_BRACKET_EMOJI_NAMES: readonly string[] = [ + '微笑', + '色', + '发呆', + '撇嘴', + '流泪', + '捂脸', + '发怒', + '竖牙', + '哈哈', + '害羞', + '调皮', + '呲牙', + '亲亲', + '比心', + '鼓掌', + '惊讶', + '赞', + '憨笑', + '拥抱', + '抱抱', + '玫瑰', + '尴尬', + '流汗', + '握手', + '星星眼', + '杀马特', + '黄脸干杯', + '裸奔', + '举手', + '挥手', + '绿火花', + '笑脸', + '哭', + '吐舌', + '小狗', + '送花', + '爱心', + '手', + '贴贴', + '灵机一动', + '大笑', + '机智', + '送心', + '666', + '闭嘴', + '看我来', + '我一起加油', + '寒心', + '震惊', + '落', + '囧', + '疑问', + '问号', + '泣不成声', + '小鼓掌', + '大金牙', + '偷笑', + '右看', + '思考', + '吐血', + '可怜', + '得意', + '委屈', + '笑哭', + '抓狂', + '恐惧', + '快哭了', + '翻白眼', + '互粉', + '我想静静', + '鄙视', + '飞吻', + '再见', + '听歌', + '求抱抱', + '绝望的凝视', + '不失礼貌的微笑', + '不看', + '揉开', + '干饭人', + '庆祝', + '呆无辜', + '白眼', + '猪头', + '冷漠', + '暗中观察', + '二哈', + '黑脸', + '展开说说', + '蜜蜂狗', + '呆头', + '摸头', + '腼腆', + '做鬼脸', + '强', + '如花', + '吓', + '惊喜', + '打', + '奋斗', + '吐彩虹', + '大英', + '吓唬', + '惊恐', + '困', + '斜眼', + '阴险', + '咒骂', + '吃瓜', + '群众', + '绿帽子', + '求求了', + '眼含热泪', + '宕', + '气', + '好开心', + '不是吧', + '驾', + '躺平', + '不你不想', + '一头乱麻', + 'kisskiss', + '你不大行', + '嗅', + '苦笑', + '苦涩', + '坚强', + '落泪', + '求机位', + '黄脸', + '点赞', + '精选', + '硬拳', + 'OOK', + '左上', + '抱拳', + '勾引', + '拳头', + '附', + '胜利', + '右边', + '右', + '躲', + '垃圾', + '啤酒', + '蛋糕', + '礼物', + '撒花', + '加一', + '呸', + 'ok', + 'V5', + '发', + '18', + '禁', + '西瓜', + '加鸡腿', + '太阳', + '月亮', + '给跪了', + '打心', + '打call', + '栓Q', + '雪花', + '气球', + '福', + 'candy', + '糖葫芦', + '鞭炮', + '元宝', + '灯笼', + '巧克力', + '威哥', + '棒棒糖', + '纸飞机', + '粽子', + '强壮', +] + +/** + * Every known platform bracket emoji name. Text cleaning recognizes these + * names as emoji placeholders instead of user-authored words. + */ +export const KNOWN_BRACKET_EMOJI_NAMES: ReadonlySet = new Set([...DOUYIN_BRACKET_EMOJI_NAMES]) diff --git a/packages/core/src/nlp/index.ts b/packages/core/src/nlp/index.ts index 6fd1c64e3..ff55c424f 100644 --- a/packages/core/src/nlp/index.ts +++ b/packages/core/src/nlp/index.ts @@ -24,4 +24,11 @@ export { POS_TAG_DEFINITIONS, MEANINGFUL_POS_TAGS } from './pos-tags' export { CHINESE_STOPWORDS, ENGLISH_STOPWORDS, JAPANESE_STOPWORDS, getStopwords, isStopword } from './stopwords' -export { cleanText, isValidWord } from './text-utils' +export { + cleanText, + isMediaPlaceholderContent, + isSystemMessageContent, + isValidWord, + stripMediaPlaceholders, + stripVoiceTranscriptionPrefix, +} from './text-utils' diff --git a/packages/core/src/nlp/text-utils.test.ts b/packages/core/src/nlp/text-utils.test.ts index 87aedd223..a8d10aaac 100644 --- a/packages/core/src/nlp/text-utils.test.ts +++ b/packages/core/src/nlp/text-utils.test.ts @@ -1,6 +1,11 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { cleanText } from './text-utils' +import { + cleanText, + isMediaPlaceholderContent, + isSystemMessageContent, + stripVoiceTranscriptionPrefix, +} from './text-utils' describe('cleanText', () => { const cases = [ @@ -13,6 +18,10 @@ describe('cleanText', () => { ['removes bracketed chat emoji placeholders before tokenization', '今天[破涕为笑][微笑][呲牙]很好', '今天 很好'], ['removes unknown short bracketed emoji placeholders', '收到[旺柴]马上来', '收到 马上来'], ['removes mapped emoji placeholders with variation selectors', '送你[爱心][太阳]', '送你'], + ['removes exact bracketed Douyin emoji codes', '太累了[躺平],一起[强壮]', '太累了 一起'], + ['removes full-width bracketed emoji codes', '【微笑】收到', '收到'], + ['removes non-CJK Douyin emoji codes as emoji', '真的[V5]呀[666]', '真的 呀'], + ['removes collected Douyin emoji codes in mixed text', '发一个[kisskiss]给你', '发一个 给你'], ['keeps ordinary non-bracketed words', '破涕为笑 微笑 呲牙', '破涕为笑 微笑 呲牙'], ['keeps non-CJK bracketed words as regular text', 'please check [report]', 'please check report'], ] as const @@ -22,4 +31,32 @@ describe('cleanText', () => { assert.equal(cleanText(input), expected) }) } + + it('removes duration-bearing voice labels while retaining the transcription', () => { + assert.equal(cleanText('[语音 3秒] 这是测试语音内容。'), '这是测试语音内容') + assert.equal(stripVoiceTranscriptionPrefix('[语音 3秒] 这是测试语音内容。'), '这是测试语音内容。') + }) + + it('recognizes duration-bearing media-only content as a placeholder', () => { + assert.equal(isMediaPlaceholderContent('[语音 3秒]'), true) + assert.equal(isMediaPlaceholderContent('[语音 3秒] 有转写内容'), false) + }) + + it('recognizes explicit system markers without treating bracketed emoji labels as system text', () => { + const cases = [ + ['[系统] 对方赞了你分享的 图文', true], + ['【系统消息】你赞了对方分享的视频', true], + ['[System Message] shared a post', true], + ['[分享内容]', true], + ['【分享内容】', true], + ['[强壮]', false], + ['[躺平]', false], + ['[分享]', false], + ['我说“[系统]”这个词', false], + ] as const + + for (const [input, expected] of cases) { + assert.equal(isSystemMessageContent(input), expected, input) + } + }) }) diff --git a/packages/core/src/nlp/text-utils.ts b/packages/core/src/nlp/text-utils.ts index c7d4ae1a6..b5b5826b6 100644 --- a/packages/core/src/nlp/text-utils.ts +++ b/packages/core/src/nlp/text-utils.ts @@ -2,114 +2,47 @@ * 文本处理工具(纯函数,平台无关) */ +import { KNOWN_BRACKET_EMOJI_NAMES, WECHAT_BRACKET_EMOJI_MAP } from './bracket-emoji' + const EMOJI_REGEX = /[\u{1F300}-\u{1FAFF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu const EMOJI_VARIATION_SELECTOR_REGEX = /\u{FE0F}/gu const PUNCTUATION_REGEX = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~,。!?、;:""''()【】《》…—~·\s]/g const URL_REGEX = /https?:\/\/[^\s]+/g const MENTION_REGEX = /@[^\s@]+/g const PURE_NUMBER_REGEX = /^\d+$/ -const SYSTEM_PLACEHOLDER_REGEX = - /\[(?:图片|视频|语音|文件|动画表情|表情|链接|位置|名片|红包|转账|音乐|Image|Video|Voice|File|Sticker|Link)\]/gi +const MEDIA_PLACEHOLDER_NAME_PATTERN = + '(?:图片|视频|语音|文件|动画表情|表情|链接|位置|地理位置|名片|红包|转账|音乐|Image|Photo|Video|Voice|Audio|File|Sticker|Link|Location)' +const MEDIA_PLACEHOLDER_DURATION_PATTERN = '(?:\\s+\\d+(?:\\.\\d+)?\\s*(?:秒|s))?' +const SYSTEM_PLACEHOLDER_REGEX = new RegExp( + `\\[${MEDIA_PLACEHOLDER_NAME_PATTERN}${MEDIA_PLACEHOLDER_DURATION_PATTERN}\\]`, + 'gi' +) +const MEDIA_PLACEHOLDER_ONLY_REGEX = new RegExp( + `^\\[${MEDIA_PLACEHOLDER_NAME_PATTERN}${MEDIA_PLACEHOLDER_DURATION_PATTERN}\\]$`, + 'i' +) +const VOICE_TRANSCRIPTION_PREFIX_REGEX = new RegExp( + `^\\s*\\[(?:语音|Voice|Audio)${MEDIA_PLACEHOLDER_DURATION_PATTERN}\\]\\s*`, + 'i' +) +const SYSTEM_MESSAGE_PREFIX_REGEX = + /^\s*(?:\[(?:系统(?:消息)?|分享内容|system(?:\s+message)?)\]|【(?:系统(?:消息)?|分享内容|system(?:\s+message)?)】)\s*/iu const BRACKET_EMOJI_PLACEHOLDER_REGEX = /(?:\[|【)([\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Letter}\p{Number}_-]{1,16})(?:\]|】)/gu const CJK_TEXT_REGEX = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u -const BRACKET_EMOJI_MAP: Record = { - 破涕为笑: '😂', - 微笑: '🙂', - 呲牙: '😁', - 大笑: '😄', - 笑哭: '😂', - 流泪: '😢', - 捂脸: '🤦', - 发呆: '😳', - 害羞: '😊', - 调皮: '😜', - 色: '😍', - 惊讶: '😮', - 撇嘴: '😒', - 难过: '😞', - 酷: '😎', - 抓狂: '😫', - 吐: '🤮', - 偷笑: '🤭', - 可爱: '😊', - 白眼: '🙄', - 傲慢: '😤', - 饥饿: '😋', - 困: '😴', - 惊恐: '😱', - 流汗: '😅', - 憨笑: '😄', - 悠闲: '😌', - 奋斗: '💪', - 咒骂: '😡', - 疑问: '❓', - 嘘: '🤫', - 晕: '😵', - 衰: '😞', - 敲打: '👊', - 再见: '👋', - 擦汗: '😅', - 抠鼻: '👃', - 鼓掌: '👏', - 坏笑: '😏', - 左哼哼: '😤', - 右哼哼: '😤', - 哈欠: '🥱', - 鄙视: '😒', - 委屈: '🥺', - 快哭了: '😢', - 阴险: '😏', - 亲亲: '😘', - 吓: '😱', - 可怜: '🥺', - 菜刀: '🔪', - 西瓜: '🍉', - 啤酒: '🍺', - 篮球: '🏀', - 乒乓: '🏓', - 咖啡: '☕', - 饭: '🍚', - 猪头: '🐷', - 玫瑰: '🌹', - 凋谢: '🥀', - 爱心: '❤️', - 心碎: '💔', - 蛋糕: '🎂', - 闪电: '⚡', - 炸弹: '💣', - 刀: '🔪', - 足球: '⚽', - 便便: '💩', - 月亮: '🌙', - 太阳: '☀️', - 礼物: '🎁', - 拥抱: '🤗', - 强: '👍', - 弱: '👎', - 握手: '🤝', - 胜利: '✌️', - 抱拳: '🙏', - 勾引: '☝️', - 拳头: '✊', - 差劲: '👎', - 爱你: '🤟', - NO: '🙅', - OK: '👌', -} - /** * 清理文本:移除 URL、@提及、表情、标点等 */ -export function cleanText(text: string): string { +export function cleanText(text: string, options: { preserveUnknownBracketedText?: boolean } = {}): string { return text .replace(URL_REGEX, ' ') .replace(MENTION_REGEX, ' ') .replace(SYSTEM_PLACEHOLDER_REGEX, ' ') .replace(BRACKET_EMOJI_PLACEHOLDER_REGEX, (match, name: string) => { - if (BRACKET_EMOJI_MAP[name]) return BRACKET_EMOJI_MAP[name] - return CJK_TEXT_REGEX.test(name) ? ' ' : match + if (WECHAT_BRACKET_EMOJI_MAP[name]) return WECHAT_BRACKET_EMOJI_MAP[name] + if (KNOWN_BRACKET_EMOJI_NAMES.has(name)) return ' ' + return !options.preserveUnknownBracketedText && CJK_TEXT_REGEX.test(name) ? ' ' : match }) .replace(EMOJI_REGEX, ' ') .replace(EMOJI_VARIATION_SELECTOR_REGEX, ' ') @@ -118,6 +51,26 @@ export function cleanText(text: string): string { .trim() } +/** Remove media placeholders, including duration-bearing labels such as `[语音 3秒]`. */ +export function stripMediaPlaceholders(text: string): string { + return text.replace(SYSTEM_PLACEHOLDER_REGEX, ' ') +} + +/** Return the transcribed text after a leading voice-message label. */ +export function stripVoiceTranscriptionPrefix(text: string): string { + return text.replace(VOICE_TRANSCRIPTION_PREFIX_REGEX, '').trim() +} + +/** Identify an explicit system/share marker without classifying bracketed emoji labels as system text. */ +export function isSystemMessageContent(text: string): boolean { + return SYSTEM_MESSAGE_PREFIX_REGEX.test(text) +} + +/** Whether the complete value is a media placeholder rather than message text. */ +export function isMediaPlaceholderContent(text: string): boolean { + return MEDIA_PLACEHOLDER_ONLY_REGEX.test(text.trim()) +} + /** * 判断是否为有效词语 */ diff --git a/packages/core/src/query/__tests__/browser-word-frequency.test.ts b/packages/core/src/query/__tests__/browser-word-frequency.test.ts index f5da2dd9c..8cb4ea0db 100644 --- a/packages/core/src/query/__tests__/browser-word-frequency.test.ts +++ b/packages/core/src/query/__tests__/browser-word-frequency.test.ts @@ -119,6 +119,68 @@ describe('getBrowserWordFrequency', () => { assert.equal(result.uniqueWords, 2) }) + it('keeps voice transcription words but removes the duration label', () => { + const insert = raw.prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, ?, ?)') + insert.run(6, 1, 600, 0, '[Voice 3s] sample transcript') + insert.run(7, 1, 700, 0, '[Voice 3s] sample transcript') + + const result = getBrowserWordFrequency(database, { + sessionId: 'session-one', + locale: 'en-US', + topN: 20, + minCount: 2, + posFilterMode: 'all', + enableStopwords: false, + }) + + const words = result.words.map((word) => word.word) + assert.ok(words.includes('sample')) + assert.ok(!words.includes('voice')) + }) + + it('excludes explicit system notifications from word frequency', () => { + const insert = raw.prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, ?, ?)') + insert.run(8, 1, 800, 0, '[系统] 对方赞了你分享的 systemnoise') + insert.run(9, 1, 900, 0, '[系统] 对方赞了你分享的 systemnoise') + insert.run(10, 1, 1000, 5, '[强壮]') + insert.run(11, 1, 1100, 0, '[分享内容] share_noise') + insert.run(12, 1, 1200, 0, '[分享内容] share_noise') + + const result = getBrowserWordFrequency(database, { + sessionId: 'session-one', + locale: 'en-US', + topN: 20, + minCount: 1, + posFilterMode: 'all', + enableStopwords: false, + }) + + const words = result.words.map((word) => word.word) + assert.ok(!words.includes('systemnoise')) + assert.ok(!words.includes('share_noise')) + assert.equal(result.totalMessages, 4) + }) + + it('treats Douyin emoji codes as emoji instead of words', () => { + const insert = raw.prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, ?, ?)') + insert.run(13, 1, 1300, 0, '[kisskiss]') + insert.run(14, 1, 1400, 5, '[V5]') + + const result = getBrowserWordFrequency(database, { + sessionId: 'session-one', + locale: 'en-US', + topN: 20, + minCount: 1, + posFilterMode: 'all', + enableStopwords: false, + }) + + const words = result.words.map((word) => word.word) + assert.ok(!words.includes('kisskiss')) + assert.ok(!words.includes('v5')) + assert.equal(result.totalMessages, 5) + }) + it('applies member, time, excluded-word, and excluded-message filters', () => { const result = getBrowserWordFrequency(database, { sessionId: 'session-one', diff --git a/packages/core/src/query/__tests__/message-query-functions.test.ts b/packages/core/src/query/__tests__/message-query-functions.test.ts index 918b98046..2314303aa 100644 --- a/packages/core/src/query/__tests__/message-query-functions.test.ts +++ b/packages/core/src/query/__tests__/message-query-functions.test.ts @@ -7,6 +7,9 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import Database from 'better-sqlite3' +import { getMessageContext, getSearchMessageContext } from '../message-queries' +import { SqliteTestAdapter } from './sqlite-test-adapter' +import { CHAT_DB_TABLES, CHAT_DB_INDEXES } from '../../schema/tables' import { fetchAllRecentMessages, @@ -124,6 +127,39 @@ describe('message pagination', () => { }) describe('message search and context', () => { + // Prevent common-keyword searches in busy segments from blocking the database for seconds. + for (const indexed of [true, false]) { + it(`expands many hits without repeated scans (indexed=${indexed})`, async () => { + const db = new Database(':memory:') + try { + db.exec(CHAT_DB_TABLES + CHAT_DB_INDEXES) + db.exec(`INSERT INTO member (platform_id) VALUES ('synthetic'); + WITH RECURSIVE seq(id) AS (SELECT 1 UNION ALL SELECT id + 1 FROM seq WHERE id < 200000) + INSERT INTO message (id, sender_id, ts, type, content) + SELECT id, 1, 1700000000 + (200001 - id) * 10, 0, 'synthetic' FROM seq;`) + if (indexed) db.exec('INSERT INTO message_context (message_id, segment_id) SELECT id, 1 FROM message') + const ids = Array.from({ length: 1000 }, (_, i) => 10001 + i) + const expected = Array.from({ length: 1004 }, (_, i) => 11002 - i) + for (const query of [ + () => getSearchMessageContext(new SqliteTestAdapter(db), ids, 2, 2), + () => fetchSearchMessageContext(createSqliteExecutor(db), ids, 2, 2), + ]) { + const started = performance.now() + const messages = await query() + assert.deepEqual( + messages.map((message) => message.id), + expected + ) + assert.ok( + performance.now() - started < 2000, + 'Context expansion must not repeatedly scan the message history' + ) + } + } finally { + db.close() + } + }) + } it('searches the database and returns the matching total', async () => { const db = createMessageDb() try { @@ -142,17 +178,28 @@ describe('message search and context', () => { it('loads chronological context instead of insertion order', async () => { const db = createBackfilledMessageDb() try { - const messages = await fetchMessageContext(createSqliteExecutor(db), 1, 2) - assert.deepEqual( - messages.map((message) => message.id), - [3, 4, 1, 2] - ) + const adapter = new SqliteTestAdapter(db) + const executor = createSqliteExecutor(db) + for (const messages of [getMessageContext(adapter, [1], 2), await fetchMessageContext(executor, 1, 2)]) { + assert.deepEqual( + messages.map((message) => message.id), + [3, 4, 1, 2] + ) + } + db.exec(`INSERT INTO message (id, sender_id, ts, type, content) VALUES + (5, 1, 300, 0, 'same-second backfill'), (6, 1, 300, 0, 'same-second later');`) + for (const messages of [getMessageContext(adapter, [5], 1), await fetchMessageContext(executor, 5, 1)]) { + assert.deepEqual( + messages.map((message) => message.id), + [1, 5, 6] + ) + } } finally { db.close() } }) - it('falls back to neighboring message ids without a message_context table', async () => { + it('falls back to chronological neighbors without a message_context table', async () => { const db = createMessageDb() try { const result = await fetchSearchMessageContext(createSqliteExecutor(db), [3], 1, 1) @@ -164,6 +211,43 @@ describe('message search and context', () => { db.close() } }) + + for (const indexed of [false, true]) { + it(`loads backfilled search context in time order and respects segments (indexed=${indexed})`, async () => { + const db = createBackfilledMessageDb() + try { + db.exec(`INSERT INTO message (id, sender_id, ts, type, content) VALUES + (5, 1, 300, 0, 'same-second backfill'), (6, 1, 300, 0, 'same-second later'), + (7, 1, 5000, 0, 'another segment');`) + if (indexed) { + db.exec(`CREATE TABLE message_context (message_id INTEGER PRIMARY KEY, segment_id INTEGER); + INSERT INTO message_context SELECT id, CASE WHEN id = 7 THEN 2 ELSE 1 END FROM message;`) + } + const adapter = new SqliteTestAdapter(db) + const executor = createSqliteExecutor(db) + for (const { ids, before, after, expected } of [ + { ids: [1], before: 1, after: 1, expected: [4, 1, 5] }, + { ids: [5], before: 1, after: 1, expected: [1, 5, 6] }, + { ids: [6], before: 1, after: 1, expected: [5, 6, 2] }, + { ids: [2], before: 1, after: 1, expected: indexed ? [6, 2] : [6, 2, 7] }, + { ids: [1, 5], before: 1, after: 1, expected: [4, 1, 5, 6] }, + { ids: [1], before: 0, after: 1, expected: [1, 5] }, + ]) { + for (const messages of [ + getSearchMessageContext(adapter, ids, before, after), + await fetchSearchMessageContext(executor, ids, before, after), + ]) { + assert.deepEqual( + messages.map((message) => message.id), + expected + ) + } + } + } finally { + db.close() + } + }) + } }) describe('recent messages', () => { diff --git a/packages/core/src/query/advanced/languagePreference.ts b/packages/core/src/query/advanced/languagePreference.ts index 735a49a76..43e916aee 100644 --- a/packages/core/src/query/advanced/languagePreference.ts +++ b/packages/core/src/query/advanced/languagePreference.ts @@ -5,8 +5,9 @@ * 调用方负责提供对应平台的实现(Electron / Server 用 jieba,浏览器可降级)。 */ -import type { TimeFilter } from '@openchatlab/shared-types' +import { MessageType, type TimeFilter } from '@openchatlab/shared-types' import type { DatabaseAdapter } from '../../interfaces' +import { cleanText, isSystemMessageContent, stripVoiceTranscriptionPrefix } from '../../nlp' import { buildTimeFilter } from '../filters' import { isSystemPlaceholderContent } from './text-filters' @@ -38,22 +39,8 @@ const ADJ_TAGS = new Set(['a', 'an', 'ad', 'ag']) const ADV_TAGS = new Set(['d']) const MODAL_TAGS = new Set(['y', 'e']) -const RE_URL = /https?:\/\/[^\s]+/g -const RE_MENTION = /@[^\s@]+/g -const RE_EMOJI = - /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu -const RE_PUNCTUATION = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~,。!?、;:""''()【】《》…—~·\s]/g const RE_PURE_NUMBER = /^\d+$/ - -function cleanTextForNlp(text: string): string { - return text - .replace(RE_URL, ' ') - .replace(RE_MENTION, ' ') - .replace(RE_EMOJI, ' ') - .replace(RE_PUNCTUATION, ' ') - .replace(/\s+/g, ' ') - .trim() -} +const LANGUAGE_ANALYSIS_MESSAGE_TYPES = [MessageType.TEXT, MessageType.EMOJI].join(', ') function countMatches(text: string, regex: RegExp): number { regex.lastIndex = 0 @@ -87,8 +74,7 @@ export function getLanguagePreferenceAnalysis(db: DatabaseAdapter, params: Langu const { clause, params: filterParams } = buildTimeFilter(timeFilter) let whereClause = clause - const textFilter = - " COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2" + const textFilter = ` COALESCE(m.account_name, '') != '系统消息' AND msg.type IN (${LANGUAGE_ANALYSIS_MESSAGE_TYPES}) AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2` if (whereClause.includes('WHERE')) { whereClause += ' AND ' + textFilter } else { @@ -106,14 +92,15 @@ export function getLanguagePreferenceAnalysis(db: DatabaseAdapter, params: Langu const memberMessages = new Map() for (const row of rows) { - if (isSystemPlaceholderContent(row.content)) continue + const content = stripVoiceTranscriptionPrefix(row.content) + if (!content || isSystemMessageContent(content) || isSystemPlaceholderContent(content)) continue let entry = memberMessages.get(row.memberId) if (!entry) { entry = { name: row.name, messages: [] } memberMessages.set(row.memberId, entry) } - entry.messages.push(row.content) + entry.messages.push(content) } const isChinese = locale.startsWith('zh') @@ -142,7 +129,8 @@ export function getLanguagePreferenceAnalysis(db: DatabaseAdapter, params: Langu if (trimmed.length >= 2) phraseFreq.set(trimmed, (phraseFreq.get(trimmed) || 0) + 1) - const cleaned = cleanTextForNlp(content) + // Preserve ordinary annotations while excluding known emoji from vocabulary. + const cleaned = cleanText(content, { preserveUnknownBracketedText: true }) if (!cleaned) continue if (isChinese && nlpProvider) { diff --git a/packages/core/src/query/advanced/repeat.test.ts b/packages/core/src/query/advanced/repeat.test.ts index 708cd6f5d..e4a85faa2 100644 --- a/packages/core/src/query/advanced/repeat.test.ts +++ b/packages/core/src/query/advanced/repeat.test.ts @@ -1,6 +1,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import Database from 'better-sqlite3' +import { MessageType } from '@openchatlab/shared-types' import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces' import { getLanguagePreferenceAnalysis } from './languagePreference' import { getCatchphraseAnalysis } from './repeat' @@ -91,6 +92,7 @@ interface CatchphraseFixture { memberId: number content: string count: number + type?: number } function createCatchphraseDb(fixtures: CatchphraseFixture[]): DatabaseAdapter { @@ -114,11 +116,17 @@ function createCatchphraseDb(fixtures: CatchphraseFixture[]): DatabaseAdapter { (2, 'bob', 'Bob', NULL); `) - const insert = database.prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, 0, ?)') + const insert = database.prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, ?, ?)') let messageId = 1 for (const fixture of fixtures) { for (let index = 0; index < fixture.count; index += 1) { - insert.run(messageId, fixture.memberId, 1_700_000_000 + messageId, fixture.content) + insert.run( + messageId, + fixture.memberId, + 1_700_000_000 + messageId, + fixture.type ?? MessageType.TEXT, + fixture.content + ) messageId += 1 } } @@ -170,6 +178,62 @@ describe('getCatchphraseAnalysis', () => { ) db.close() }) + + it('shows voice transcriptions without the duration label', () => { + const db = createCatchphraseDb([ + { memberId: 1, content: '[语音 3秒] 测试转写内容', count: 3 }, + { memberId: 1, content: '[语音 2秒]', count: 5 }, + ]) + + const result = getCatchphraseAnalysis(db) + + assert.deepEqual(result.members[0]?.catchphrases, [{ content: '测试转写内容', count: 3 }]) + db.close() + }) + + it('aggregates the same transcription when voice durations differ', () => { + const db = createCatchphraseDb([ + { memberId: 1, content: '[语音 2秒] 同一句测试转写', count: 1 }, + { memberId: 1, content: '[语音 3秒] 同一句测试转写', count: 1 }, + ]) + + const result = getCatchphraseAnalysis(db) + + assert.deepEqual(result.members[0]?.catchphrases, [{ content: '同一句测试转写', count: 2 }]) + db.close() + }) + + it('omits members whose normalized voice phrases never repeat', (t) => { + const db = createCatchphraseDb([ + { memberId: 1, content: '[语音 3秒] 只转写一次', count: 1 }, + { memberId: 2, content: '收到', count: 3 }, + ]) + t.after(() => db.close()) + + assert.deepEqual( + getCatchphraseAnalysis(db).members.map((member) => member.memberId), + [2] + ) + assert.deepEqual(getCatchphraseAnalysis(db, { memberId: 1 }), { members: [] }) + }) + + it('filters explicit system notifications but keeps emoji labels', () => { + const db = createCatchphraseDb([ + { memberId: 1, content: '[系统] 对方赞了你分享的 图文', count: 4 }, + { memberId: 1, content: '[系统] 你赞了对方分享的 视频', count: 3 }, + { memberId: 1, content: '[分享内容]', count: 5 }, + { memberId: 1, content: '[强壮]', count: 4, type: MessageType.EMOJI }, + { memberId: 1, content: '[躺平]', count: 3, type: MessageType.TEXT }, + ]) + + const result = getCatchphraseAnalysis(db) + + assert.deepEqual(result.members[0]?.catchphrases, [ + { content: '[强壮]', count: 4 }, + { content: '[躺平]', count: 3 }, + ]) + db.close() + }) }) describe('getLanguagePreferenceAnalysis', () => { @@ -188,4 +252,80 @@ describe('getLanguagePreferenceAnalysis', () => { assert.equal(result.members[0]?.totalMessages, 2) assert.deepEqual(result.members[0]?.catchphrases, [{ content: 'noted', count: 2 }]) }) + + it('uses voice transcription text for language analysis instead of the duration label', () => { + const db = createRowsDb([ + { memberId: 1, name: 'Alice', content: '[Voice 3s] sample transcript' }, + { memberId: 1, name: 'Alice', content: '[Voice 3s] sample transcript' }, + { memberId: 1, name: 'Alice', content: '[语音 2秒]' }, + { memberId: 2, name: 'Bob', content: '普通文本' }, + { memberId: 2, name: 'Bob', content: '普通文本' }, + ]) + + const result = getLanguagePreferenceAnalysis(db, { locale: 'en-US' }) + const alice = result.members.find((member: { name: string }) => member.name === 'Alice') + + assert.equal(alice?.totalMessages, 2) + assert.deepEqual(alice?.catchphrases, [{ content: 'sample transcript', count: 2 }]) + assert.equal( + alice?.topWords.some((word: { word: string }) => word.word === 'voice'), + false + ) + assert.equal( + alice?.topWords.some((word: { word: string }) => word.word === 'sample'), + true + ) + }) + + it('filters system notifications while retaining bracketed emoji phrases', () => { + const db = createRowsDb([ + { memberId: 1, name: 'Alice', content: '[系统] 对方赞了你分享的 图文' }, + { memberId: 1, name: 'Alice', content: '[系统] 对方赞了你分享的 图文' }, + { memberId: 1, name: 'Alice', content: '[分享内容] share_noise' }, + { memberId: 1, name: 'Alice', content: '[分享内容] share_noise' }, + { memberId: 1, name: 'Alice', content: '[强壮]' }, + { memberId: 1, name: 'Alice', content: '[强壮]' }, + { memberId: 1, name: 'Alice', content: '[躺平]' }, + { memberId: 1, name: 'Alice', content: '[躺平]' }, + ]) + + const result = getLanguagePreferenceAnalysis(db, { locale: 'zh-CN' }) + const alice = result.members.find((member: { name: string }) => member.name === 'Alice') + + assert.equal(alice?.totalMessages, 4) + assert.deepEqual(alice?.catchphrases, [ + { content: '[强壮]', count: 2 }, + { content: '[躺平]', count: 2 }, + ]) + }) + + it('retains ordinary bracketed annotations in vocabulary', () => { + const db = createRowsDb([ + { memberId: 1, name: 'Alice', content: '[需求]今天讨论[微笑]' }, + { memberId: 1, name: 'Alice', content: '[需求]今天讨论[微笑]' }, + ]) + + const result = getLanguagePreferenceAnalysis(db, { locale: 'zh-CN' }) + const words = result.members[0]?.topWords ?? [] + + assert.equal(words.find((item: { word: string; count: number }) => item.word === '需求')?.count, 2) + }) + + it('keeps emoji codes as phrases but excludes them from vocabulary', () => { + const db = createRowsDb([ + { memberId: 1, name: 'Alice', content: '[微笑]' }, + { memberId: 1, name: 'Alice', content: '[微笑]' }, + { memberId: 1, name: 'Alice', content: '[V5]' }, + { memberId: 1, name: 'Alice', content: '[V5]' }, + ]) + + const result = getLanguagePreferenceAnalysis(db, { locale: 'zh-CN' }) + const alice = result.members[0] + + assert.deepEqual(alice?.catchphrases, [ + { content: '[微笑]', count: 2 }, + { content: '[V5]', count: 2 }, + ]) + assert.ok((alice?.topWords ?? []).every((word: { word: string }) => !['微笑', 'v5'].includes(word.word))) + }) }) diff --git a/packages/core/src/query/advanced/repeat.ts b/packages/core/src/query/advanced/repeat.ts index a12ed6d2d..0daf79b9e 100644 --- a/packages/core/src/query/advanced/repeat.ts +++ b/packages/core/src/query/advanced/repeat.ts @@ -2,11 +2,24 @@ * 口头禅分析模块(平台无关) */ -import type { TimeFilter } from '@openchatlab/shared-types' +import { MessageType, type TimeFilter } from '@openchatlab/shared-types' import type { DatabaseAdapter } from '../../interfaces' +import { isSystemMessageContent, stripVoiceTranscriptionPrefix } from '../../nlp' import { buildTimeFilter } from '../filters' import { isSystemPlaceholderContent } from './text-filters' +// Keep one-off voice transcription rows in the grouped query so JavaScript can +// normalize away duration labels before applying the repeated-phrase threshold. +const VOICE_TRANSCRIPTION_SQL = ` + TRIM(msg.content) LIKE '[语音] %' OR + TRIM(msg.content) LIKE '[语音 %] %' OR + TRIM(msg.content) LIKE '[Voice] %' OR + TRIM(msg.content) LIKE '[Voice %] %' OR + TRIM(msg.content) LIKE '[Audio] %' OR + TRIM(msg.content) LIKE '[Audio %] %' + ` +const CATCHPHRASE_MESSAGE_TYPES = [MessageType.TEXT, MessageType.EMOJI].join(', ') + export interface CatchphraseItem { content: string count: number @@ -28,11 +41,9 @@ export function getCatchphraseAnalysis(db: DatabaseAdapter, filter?: TimeFilter) let whereClause = clause if (whereClause.includes('WHERE')) { - whereClause += - " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2" + whereClause += ` AND COALESCE(m.account_name, '') != '系统消息' AND msg.type IN (${CATCHPHRASE_MESSAGE_TYPES}) AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2` } else { - whereClause = - " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2" + whereClause = ` WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type IN (${CATCHPHRASE_MESSAGE_TYPES}) AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2` } const rows = db @@ -48,7 +59,7 @@ export function getCatchphraseAnalysis(db: DatabaseAdapter, filter?: TimeFilter) JOIN member m ON msg.sender_id = m.id ${whereClause} GROUP BY m.id, TRIM(msg.content) - HAVING COUNT(*) >= 2 + HAVING COUNT(*) >= 2 OR (${VOICE_TRANSCRIPTION_SQL}) ORDER BY m.id, count DESC ` ) @@ -61,9 +72,11 @@ export function getCatchphraseAnalysis(db: DatabaseAdapter, filter?: TimeFilter) }> const memberMap = new Map() + const phraseMaps = new Map>() for (const row of rows) { - if (isSystemPlaceholderContent(row.content)) continue + const content = stripVoiceTranscriptionPrefix(row.content) + if (!content || isSystemMessageContent(content) || isSystemPlaceholderContent(content)) continue if (!memberMap.has(row.memberId)) { memberMap.set(row.memberId, { @@ -74,17 +87,27 @@ export function getCatchphraseAnalysis(db: DatabaseAdapter, filter?: TimeFilter) }) } - const member = memberMap.get(row.memberId)! - if (member.catchphrases.length < 100) { - member.catchphrases.push({ content: row.content, count: row.count }) + let phrases = phraseMaps.get(row.memberId) + if (!phrases) { + phrases = new Map() + phraseMaps.set(row.memberId, phrases) } + const existing = phrases.get(content) + if (existing) existing.count += Number(row.count) + else phrases.set(content, { content, count: Number(row.count) }) } const members = Array.from(memberMap.values()) + for (const member of members) { + member.catchphrases = [...(phraseMaps.get(member.memberId)?.values() ?? [])] + .filter((item) => item.count >= 2) + .sort((a, b) => b.count - a.count || a.content.localeCompare(b.content)) + .slice(0, 100) + } members.sort((a, b) => { const countDifference = (b.catchphrases[0]?.count ?? 0) - (a.catchphrases[0]?.count ?? 0) return countDifference || a.memberId - b.memberId }) - return { members } + return { members: members.filter((member) => member.catchphrases.length > 0) } } diff --git a/packages/core/src/query/advanced/text-filters.ts b/packages/core/src/query/advanced/text-filters.ts index 71c2984e1..b8c492ccd 100644 --- a/packages/core/src/query/advanced/text-filters.ts +++ b/packages/core/src/query/advanced/text-filters.ts @@ -1,3 +1,5 @@ +import { isMediaPlaceholderContent } from '../../nlp' + const SYSTEM_PLACEHOLDER_CONTENTS = new Set([ '[图片]', '[视频]', @@ -24,5 +26,6 @@ const SYSTEM_PLACEHOLDER_CONTENTS = new Set([ ]) export function isSystemPlaceholderContent(content: string): boolean { - return SYSTEM_PLACEHOLDER_CONTENTS.has(content.trim()) + const trimmed = content.trim() + return SYSTEM_PLACEHOLDER_CONTENTS.has(trimmed) || isMediaPlaceholderContent(trimmed) } diff --git a/packages/core/src/query/browser-word-frequency.ts b/packages/core/src/query/browser-word-frequency.ts index ab1a796f8..a1d678e56 100644 --- a/packages/core/src/query/browser-word-frequency.ts +++ b/packages/core/src/query/browser-word-frequency.ts @@ -1,5 +1,13 @@ import type { DatabaseAdapter } from '../interfaces' -import { cleanText, isStopword, isValidWord, type WordFrequencyParams, type WordFrequencyResult } from '../nlp' +import { MessageType } from '@openchatlab/shared-types' +import { + cleanText, + isStopword, + isSystemMessageContent, + isValidWord, + type WordFrequencyParams, + type WordFrequencyResult, +} from '../nlp' import { buildExcludeKeywordsConditions } from './message-sql' function tokenize(text: string, locale: string): string[] { @@ -34,7 +42,7 @@ export function getBrowserWordFrequency(db: DatabaseAdapter, params: WordFrequen } = params const conditions = [ "COALESCE(m.account_name, '') != '系统消息'", - 'msg.type = 0', + `msg.type IN (${MessageType.TEXT}, ${MessageType.EMOJI})`, 'msg.content IS NOT NULL', "TRIM(msg.content) != ''", ] @@ -66,10 +74,11 @@ export function getBrowserWordFrequency(db: DatabaseAdapter, params: WordFrequen WHERE ${conditions.join(' AND ')}` ) .all(...queryParams) as Array<{ content: string }> + const analyzableMessages = messages.filter((message) => !isSystemMessageContent(message.content)) const excludedWords = new Set(excludeWords.map((word) => word.trim().toLocaleLowerCase(locale)).filter(Boolean)) const frequencies = new Map() - for (const message of messages) { + for (const message of analyzableMessages) { for (const rawWord of tokenize(message.content, locale)) { const word = rawWord.toLocaleLowerCase(locale) if (excludedWords.has(word)) continue @@ -92,7 +101,7 @@ export function getBrowserWordFrequency(db: DatabaseAdapter, params: WordFrequen percentage: rankedTotal > 0 ? Math.round((count / rankedTotal) * 10_000) / 100 : 0, })), totalWords, - totalMessages: messages.length, + totalMessages: analyzableMessages.length, uniqueWords: filteredFrequencies.length, } } diff --git a/packages/core/src/query/message-queries.ts b/packages/core/src/query/message-queries.ts index 295f6c5a4..c71b3396c 100644 --- a/packages/core/src/query/message-queries.ts +++ b/packages/core/src/query/message-queries.ts @@ -592,14 +592,14 @@ function hydrateMessagesByIds(db: DatabaseAdapter, ids: number[]): MappedMessage if (ids.length === 0) return [] const placeholders = ids.map(() => '?').join(', ') const rows = db - .prepare(`${FULL_MSG_SELECT} WHERE msg.id IN (${placeholders}) ORDER BY msg.id ASC`) + .prepare(`${FULL_MSG_SELECT} WHERE msg.id IN (${placeholders}) ORDER BY msg.ts ASC, msg.id ASC`) .all(...ids) as unknown as FullMessageRow[] return rows.map(mapMessageRow) } /** * Get surrounding context messages for given message IDs. - * Uses simple id-based ordering (not session-aware). + * Uses chronological (timestamp, id) ordering (not session-aware). */ export function getMessageContext( db: DatabaseAdapter, @@ -614,12 +614,20 @@ export function getMessageContext( contextIds.add(messageId) const beforeRows = db - .prepare('SELECT id FROM message WHERE id < ? ORDER BY id DESC LIMIT ?') + .prepare( + `SELECT msg.id FROM message msg + WHERE (msg.ts, msg.id) < (SELECT ts, id FROM message WHERE id = ?) + ORDER BY msg.ts DESC, msg.id DESC LIMIT ?` + ) .all(messageId, contextSize) as { id: number }[] beforeRows.forEach((r) => contextIds.add(r.id)) const afterRows = db - .prepare('SELECT id FROM message WHERE id > ? ORDER BY id ASC LIMIT ?') + .prepare( + `SELECT msg.id FROM message msg + WHERE (msg.ts, msg.id) > (SELECT ts, id FROM message WHERE id = ?) + ORDER BY msg.ts ASC, msg.id ASC LIMIT ?` + ) .all(messageId, contextSize) as { id: number }[] afterRows.forEach((r) => contextIds.add(r.id)) } @@ -629,7 +637,7 @@ export function getMessageContext( /** * Get context messages around search results. - * Session-aware when message_context table is available, falls back to id-based ordering. + * Uses chronological neighbors within the indexed segment, or across the chat when unindexed. */ export function getSearchMessageContext( db: DatabaseAdapter, @@ -640,6 +648,7 @@ export function getSearchMessageContext( if (messageIds.length === 0) return [] const contextIds = new Set() + const segments = new Map }>() const hasSessionData = hasTable(db, 'message_context') && @@ -654,25 +663,29 @@ export function getSearchMessageContext( | undefined if (sessionRow) { - if (contextBefore > 0) { + if (contextBefore <= 0 && contextAfter <= 0) continue + let segment = segments.get(sessionRow.segment_id) + if (!segment) { + // Sort each touched segment once per request, not once per search hit. const rows = db .prepare( - `SELECT mc.message_id as id FROM message_context mc - WHERE mc.segment_id = ? AND mc.message_id < ? - ORDER BY mc.message_id DESC LIMIT ?` + `SELECT msg.id FROM message_context mc + JOIN message msg ON msg.id = mc.message_id + WHERE mc.segment_id = ? ORDER BY msg.ts ASC, msg.id ASC` ) - .all(sessionRow.segment_id, messageId, contextBefore) as { id: number }[] - rows.forEach((r) => contextIds.add(r.id)) + .all(sessionRow.segment_id) as { id: number }[] + const ids = rows.map((row) => row.id) + segment = { ids, positions: new Map(ids.map((id, index) => [id, index])) } + segments.set(sessionRow.segment_id, segment) } - if (contextAfter > 0) { - const rows = db - .prepare( - `SELECT mc.message_id as id FROM message_context mc - WHERE mc.segment_id = ? AND mc.message_id > ? - ORDER BY mc.message_id ASC LIMIT ?` - ) - .all(sessionRow.segment_id, messageId, contextAfter) as { id: number }[] - rows.forEach((r) => contextIds.add(r.id)) + const position = segment.positions.get(messageId) + if (position !== undefined) { + for (const id of segment.ids.slice( + Math.max(0, position - Math.max(0, contextBefore)), + position + Math.max(0, contextAfter) + 1 + )) { + contextIds.add(id) + } } continue } @@ -680,13 +693,21 @@ export function getSearchMessageContext( if (contextBefore > 0) { const rows = db - .prepare('SELECT id FROM message WHERE id < ? ORDER BY id DESC LIMIT ?') + .prepare( + `SELECT msg.id FROM message msg + WHERE (msg.ts, msg.id) < (SELECT ts, id FROM message WHERE id = ?) + ORDER BY msg.ts DESC, msg.id DESC LIMIT ?` + ) .all(messageId, contextBefore) as { id: number }[] rows.forEach((r) => contextIds.add(r.id)) } if (contextAfter > 0) { const rows = db - .prepare('SELECT id FROM message WHERE id > ? ORDER BY id ASC LIMIT ?') + .prepare( + `SELECT msg.id FROM message msg + WHERE (msg.ts, msg.id) > (SELECT ts, id FROM message WHERE id = ?) + ORDER BY msg.ts ASC, msg.id ASC LIMIT ?` + ) .all(messageId, contextAfter) as { id: number }[] rows.forEach((r) => contextIds.add(r.id)) } diff --git a/packages/core/src/query/message-query-functions.ts b/packages/core/src/query/message-query-functions.ts index b4f44d051..e38fdee5a 100644 --- a/packages/core/src/query/message-query-functions.ts +++ b/packages/core/src/query/message-query-functions.ts @@ -148,8 +148,7 @@ export async function fetchMessageContext( if (contextSize > 0) { const before = await executor.all<{ id: number }>( `SELECT msg.id FROM message msg - JOIN message anchor ON anchor.id = ? - WHERE msg.ts < anchor.ts OR (msg.ts = anchor.ts AND msg.id < anchor.id) + WHERE (msg.ts, msg.id) < (SELECT ts, id FROM message WHERE id = ?) ORDER BY msg.ts DESC, msg.id DESC LIMIT ?`, [id, contextSize] ) @@ -157,8 +156,7 @@ export async function fetchMessageContext( const after = await executor.all<{ id: number }>( `SELECT msg.id FROM message msg - JOIN message anchor ON anchor.id = ? - WHERE msg.ts > anchor.ts OR (msg.ts = anchor.ts AND msg.id > anchor.id) + WHERE (msg.ts, msg.id) > (SELECT ts, id FROM message WHERE id = ?) ORDER BY msg.ts ASC, msg.id ASC LIMIT ?`, [id, contextSize] ) @@ -177,7 +175,7 @@ export async function fetchMessageContext( /** * Get context messages around search results. - * Session-aware when message_context table is available, falls back to id-based. + * Uses chronological neighbors within the indexed segment, or across the chat when unindexed. */ export async function fetchSearchMessageContext( executor: AsyncSqlExecutor, @@ -188,6 +186,7 @@ export async function fetchSearchMessageContext( if (messageIds.length === 0) return [] const contextIds = new Set() + const segments = new Map }>() const sessionCheck = await executor.get>( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='message_context'", @@ -209,40 +208,49 @@ export async function fetchSearchMessageContext( ) if (sessionRow) { - if (contextBefore > 0) { + if (contextBefore <= 0 && contextAfter <= 0) continue + let segment = segments.get(sessionRow.segment_id) + if (!segment) { + // Sort each touched segment once per request, not once per search hit. const rows = await executor.all<{ id: number }>( - `SELECT mc.message_id as id FROM message_context mc - WHERE mc.segment_id = ? AND mc.message_id < ? - ORDER BY mc.message_id DESC LIMIT ?`, - [sessionRow.segment_id, messageId, contextBefore] + `SELECT msg.id FROM message_context mc + JOIN message msg ON msg.id = mc.message_id + WHERE mc.segment_id = ? ORDER BY msg.ts ASC, msg.id ASC`, + [sessionRow.segment_id] ) - rows.forEach((r) => contextIds.add(r.id)) + const ids = rows.map((row) => row.id) + segment = { ids, positions: new Map(ids.map((id, index) => [id, index])) } + segments.set(sessionRow.segment_id, segment) } - if (contextAfter > 0) { - const rows = await executor.all<{ id: number }>( - `SELECT mc.message_id as id FROM message_context mc - WHERE mc.segment_id = ? AND mc.message_id > ? - ORDER BY mc.message_id ASC LIMIT ?`, - [sessionRow.segment_id, messageId, contextAfter] - ) - rows.forEach((r) => contextIds.add(r.id)) + const position = segment.positions.get(messageId) + if (position !== undefined) { + for (const id of segment.ids.slice( + Math.max(0, position - Math.max(0, contextBefore)), + position + Math.max(0, contextAfter) + 1 + )) { + contextIds.add(id) + } } continue } } if (contextBefore > 0) { - const rows = await executor.all<{ id: number }>('SELECT id FROM message WHERE id < ? ORDER BY id DESC LIMIT ?', [ - messageId, - contextBefore, - ]) + const rows = await executor.all<{ id: number }>( + `SELECT msg.id FROM message msg + WHERE (msg.ts, msg.id) < (SELECT ts, id FROM message WHERE id = ?) + ORDER BY msg.ts DESC, msg.id DESC LIMIT ?`, + [messageId, contextBefore] + ) rows.forEach((r) => contextIds.add(r.id)) } if (contextAfter > 0) { - const rows = await executor.all<{ id: number }>('SELECT id FROM message WHERE id > ? ORDER BY id ASC LIMIT ?', [ - messageId, - contextAfter, - ]) + const rows = await executor.all<{ id: number }>( + `SELECT msg.id FROM message msg + WHERE (msg.ts, msg.id) > (SELECT ts, id FROM message WHERE id = ?) + ORDER BY msg.ts ASC, msg.id ASC LIMIT ?`, + [messageId, contextAfter] + ) rows.forEach((r) => contextIds.add(r.id)) } } diff --git a/packages/core/src/query/session-queries.ts b/packages/core/src/query/session-queries.ts index 59f9695eb..a5a46b3a4 100644 --- a/packages/core/src/query/session-queries.ts +++ b/packages/core/src/query/session-queries.ts @@ -317,7 +317,10 @@ export function getSegmentSummaries( db: DatabaseAdapter, options?: { limit?: number; timeFilter?: TimeFilter } ): SegmentSummaryData[] { - if (!hasTable(db, 'segment')) return [] + // Schema v10 introduced summary_message_count. Earlier summaries have unknown + // coverage and are treated as stale by the v10 migration; do the same for + // read-only callers without mutating older databases. + if (!hasTable(db, 'segment') || !hasColumn(db, 'segment', 'summary_message_count')) return [] const { limit = 50, timeFilter } = options ?? {} diff --git a/packages/http-routes/src/routes/web/cache.test.ts b/packages/http-routes/src/routes/web/cache.test.ts index eb936b0c7..e61a1ee22 100644 --- a/packages/http-routes/src/routes/web/cache.test.ts +++ b/packages/http-routes/src/routes/web/cache.test.ts @@ -297,3 +297,50 @@ describe('registerCacheRoutes data directory routes', () => { await app.close() }) }) + +describe('registerCacheRoutes save-to-downloads', () => { + it('writes a plain file name inside the downloads directory', async () => { + const app = Fastify() + registerCacheRoutes(app, { pathProvider: createPathProvider() }) + await app.ready() + + const response = await app.inject({ + method: 'POST', + url: '/_web/cache/save-to-downloads', + payload: { + filename: 'result.csv', + dataUrl: `data:text/csv;charset=utf-8,${encodeURIComponent('a,b\n1,2')}`, + }, + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { + success: true, + filePath: path.join(testSystemDir, 'downloads', 'result.csv'), + }) + + await app.close() + }) + + it('refuses a file name that would escape the downloads directory', async () => { + const app = Fastify() + registerCacheRoutes(app, { pathProvider: createPathProvider() }) + await app.ready() + + const escapedPath = path.join(testSystemDir, 'escaped.txt') + const response = await app.inject({ + method: 'POST', + url: '/_web/cache/save-to-downloads', + payload: { + filename: '../escaped.txt', + dataUrl: `data:text/plain;charset=utf-8,${encodeURIComponent('attacker controlled')}`, + }, + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { success: false, error: 'Invalid filename' }) + assert.equal(fs.existsSync(escapedPath), false) + + await app.close() + }) +}) diff --git a/packages/http-routes/src/routes/web/cache.ts b/packages/http-routes/src/routes/web/cache.ts index b20ae6095..e07665adb 100644 --- a/packages/http-routes/src/routes/web/cache.ts +++ b/packages/http-routes/src/routes/web/cache.ts @@ -267,7 +267,15 @@ export function registerCacheRoutes(server: FastifyInstance, ctx: CacheRouteCont fs.mkdirSync(downloadsDir, { recursive: true }) } - const filePath = path.join(downloadsDir, filename) + // Confine the write to the downloads directory: browser callers pick this + // name, and `path.join` collapses `..`, so anything but a plain file name + // would otherwise resolve outside `downloadsDir`. + const safeName = path.basename(filename) + if (safeName !== filename || safeName === '.' || safeName === '..') { + return { success: false, error: 'Invalid filename' } + } + + const filePath = path.join(downloadsDir, safeName) fs.writeFileSync(filePath, buffer) return { success: true, filePath } diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index d23d096c3..28bb8ce15 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -51,7 +51,9 @@ If you install the package globally, you can also use: If you configured a custom ChatLab data directory in `~/.chatlab/config.toml`, the MCP server will use that configuration automatically. -The server is read-only. It opens existing ChatLab session databases and does not modify chat data. +MCP tools use native read-only SQLite connections and short read transactions. They do not modify logical chat records, database schema, or the data-directory compatibility marker, and they never automatically migrate sessions. A session with an older schema must first be upgraded explicitly by a writable ChatLab runtime. + +**Live SQLite caveat:** WAL-mode reads may create/update `-wal` or `-shm` coordination files beside the database. This mode does **not** guarantee an unchanged filesystem directory, nor universal support for read-only mounts or databases requiring recovery. MCP query tools declare `readOnlyHint: true` and `destructiveHint: false` because they do not intentionally change _logical chat data_, perform migrations, or update compatibility metadata. Whether SQLite's auxiliary WAL/SHM coordination is compatible with the MCP annotation's broader "does not modify its environment" wording is a product/contract question for the upstream maintainers; the annotation is not a filesystem-sandbox guarantee. Queries do not create full temporary copies of chat databases. ## Available Capabilities diff --git a/packages/mcp-server/src/bin.test.ts b/packages/mcp-server/src/bin.test.ts index 96fda8a4b..2a9429e7c 100644 --- a/packages/mcp-server/src/bin.test.ts +++ b/packages/mcp-server/src/bin.test.ts @@ -4,7 +4,7 @@ import os from 'node:os' import path from 'node:path' import test from 'node:test' import { DataDirCompatibilityError, raiseDataDirMinRuntimeVersion } from '@openchatlab/node-runtime' -import { initStandaloneMcpRuntime } from './standalone-runtime' +import { initStandaloneMcpRuntime, resolveMcpLocale } from './standalone-runtime' function makeTempDir(): string { const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) @@ -28,3 +28,15 @@ test('initStandaloneMcpRuntime rejects incompatible data directories before stdi (error) => error instanceof DataDirCompatibilityError && error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' ) }) + +test('resolveMcpLocale prefers the configured language and falls back to the system locale', () => { + const cases: Array<{ lang: string; systemLocale: string; expected: string }> = [ + { lang: 'zh-CN', systemLocale: 'en-US', expected: 'zh-CN' }, + { lang: '', systemLocale: 'en-US', expected: 'en-US' }, + { lang: '', systemLocale: 'zh-CN', expected: 'zh-CN' }, + ] + + for (const { lang, systemLocale, expected } of cases) { + assert.equal(resolveMcpLocale({ locale: { lang } }, systemLocale), expected, `${lang || '(empty)'}/${systemLocale}`) + } +}) diff --git a/packages/mcp-server/src/bin.ts b/packages/mcp-server/src/bin.ts index 9d83e0bfb..b9e4216b4 100644 --- a/packages/mcp-server/src/bin.ts +++ b/packages/mcp-server/src/bin.ts @@ -9,7 +9,7 @@ import { loadConfig } from '@openchatlab/config' import { startMcpServer } from './server' -import { initStandaloneMcpRuntime } from './standalone-runtime' +import { initStandaloneMcpRuntime, resolveMcpLocale } from './standalone-runtime' import { getMcpPackageVersion } from './runtime-version' function main(): void { @@ -18,7 +18,7 @@ function main(): void { const version = getMcpPackageVersion() const { dbManager } = initStandaloneMcpRuntime(version, userDataDir) - startMcpServer({ version, dbManager }).catch((err) => { + startMcpServer({ version, dbManager, locale: resolveMcpLocale(config) }).catch((err) => { console.error('[chatlab-mcp] Fatal error:', err) process.exit(1) }) diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index d1ca22255..5e1e233ff 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -6,4 +6,5 @@ */ export { startMcpServer } from './server' +export { resolveMcpLocale } from './standalone-runtime' export type { McpServerOptions, McpDatabaseManager } from './types' diff --git a/packages/mcp-server/src/legacy-schema.test.ts b/packages/mcp-server/src/legacy-schema.test.ts new file mode 100644 index 000000000..29f512ba6 --- /dev/null +++ b/packages/mcp-server/src/legacy-schema.test.ts @@ -0,0 +1,286 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { CHAT_DB_TABLES, CURRENT_SCHEMA_VERSION } from '@openchatlab/core' +import { DatabaseManager, NodePathProvider, readDataDirCompatibilityMeta } from '@openchatlab/node-runtime' +import { startMcpServer } from './server' +import { openTestSqliteDatabase } from '../../../tests/helpers/sqlite.mts' + +test('real MCP tools and resources reject a released schema-v4 database until a writable upgrade', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-v4-preflight-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const dataDir = path.join(root, 'data') + const dbDir = path.join(dataDir, 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'legacy-v4.db') + + // Matches the published v4 member.name/nickname layout covered by + // database-manager.test.ts, rather than a new-schema fixture with a v4 tag. + const fixture = openTestSqliteDatabase(dbPath) + try { + fixture.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, platform TEXT NOT NULL, type TEXT NOT NULL, + imported_at INTEGER NOT NULL, schema_version INTEGER DEFAULT 4 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Old Chat', 'qq', 'group', 1000, 4); + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, name TEXT NOT NULL, nickname TEXT + ); + INSERT INTO member (platform_id, name, nickname) + VALUES ('u1', 'Alice Account', 'Alice Group'); + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, ts INTEGER NOT NULL, type INTEGER NOT NULL, content TEXT + ); + INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'hello'); + CREATE TABLE chat_session ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, end_ts INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, is_manual INTEGER DEFAULT 0, summary TEXT + ); + CREATE TABLE message_context ( + message_id INTEGER PRIMARY KEY, session_id INTEGER NOT NULL, topic_id INTEGER + ); + `) + } finally { + fixture.close() + } + + const beforeDb = fs.readFileSync(dbPath) + const markerPath = path.join(dataDir, '.chatlab-meta.json') + assert.equal(readDataDirCompatibilityMeta(dataDir), null) + + const dbManager = new DatabaseManager(new NodePathProvider(dataDir), { + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + t.after(() => dbManager.closeAll()) + + // The shared read-only API is also used by CLI auto-import analysis. It must + // continue to discover genuine v4 sessions without requiring MCP's v10 schema. + assert.deepEqual(dbManager.listSessionIdsReadonly(), ['legacy-v4']) + const sharedDb = dbManager.openReadonly('legacy-v4') + assert.ok(sharedDb) + assert.equal(sharedDb.readonly, true) + sharedDb.close() + assert.deepEqual(fs.readFileSync(dbPath), beforeDb) + assert.equal(fs.existsSync(markerPath), false) + + const client = new Client({ name: 'legacy-schema-test-client', version: '1.0.0' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + // Exercise the actual registration and resource callbacks, substituting only + // in-memory transport for stdio as in the protocol contract test above. + const connect = McpServer.prototype.connect + t.mock.method(McpServer.prototype, 'connect', function (this: McpServer) { + t.after(() => this.close()) + return connect.call(this, serverTransport) + }) + await startMcpServer({ version: '0.37.6', dbManager }) + await client.connect(clientTransport) + try { + for (const [name, args] of [ + ['chatlab_list_sessions', {}], + ['chatlab_get_session_info', { session_id: 'legacy-v4' }], + ['chatlab_get_recent_messages', { session_id: 'legacy-v4' }], + ['chatlab_execute_sql', { session_id: 'legacy-v4', sql: 'SELECT 1' }], + ] as const) { + const result = await client.callTool({ name, arguments: { ...args, format: 'json' } }) + assert.equal(result.isError, true, `${name} should require a schema upgrade`) + assert.match(JSON.stringify(result.content), /schema v4.*schema v10.*migrate/i) + } + for (const uri of [ + 'chatlab://sessions', + 'chatlab://sessions/legacy-v4/meta', + 'chatlab://sessions/legacy-v4/schema', + ]) { + await assert.rejects(client.readResource({ uri }), /schema v4.*schema v10.*migrate/i, uri) + } + assert.deepEqual(fs.readFileSync(dbPath), beforeDb) + assert.equal(fs.existsSync(markerPath), false, 'MCP must not repair the compatibility marker') + + // This explicit writable operation is intentionally outside MCP dispatch. + const migrated = dbManager.open('legacy-v4') + assert.ok(migrated) + assert.equal( + (migrated.prepare('SELECT schema_version FROM meta').get() as { schema_version: number }).schema_version, + CURRENT_SCHEMA_VERSION + ) + dbManager.close('legacy-v4') + const afterUpgrade = fs.readFileSync(dbPath) + const repairedMarker = fs.readFileSync(markerPath) + + const list = await client.callTool({ name: 'chatlab_list_sessions', arguments: { format: 'json' } }) + assert.equal(list.isError, undefined, JSON.stringify(list.content)) + assert.match(JSON.stringify(list.content), /Old Chat/) + const info = await client.callTool({ + name: 'chatlab_get_session_info', + arguments: { session_id: 'legacy-v4', format: 'json' }, + }) + assert.equal(info.isError, undefined, JSON.stringify(info.content)) + + const listResource = await client.readResource({ uri: 'chatlab://sessions' }) + assert.match(JSON.stringify(listResource.contents), /Old Chat/) + const metaResource = await client.readResource({ uri: 'chatlab://sessions/legacy-v4/meta' }) + assert.match(JSON.stringify(metaResource.contents), /Old Chat/) + const schemaResource = await client.readResource({ uri: 'chatlab://sessions/legacy-v4/schema' }) + assert.match(JSON.stringify(schemaResource.contents), /member/) + assert.deepEqual(fs.readFileSync(dbPath), afterUpgrade) + assert.deepEqual(fs.readFileSync(markerPath), repairedMarker) + } finally { + await client.close() + } +}) + +test('MCP tools and resources refuse invalid SQLite schema versions before SQL dispatch', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-invalid-schema-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const dataDir = path.join(root, 'data') + const dbDir = path.join(dataDir, 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'invalid-version.db') + const fixture = openTestSqliteDatabase(dbPath) + try { + fixture.exec(` + CREATE TABLE meta ( + name TEXT, platform TEXT, type TEXT, imported_at INTEGER, schema_version INTEGER + ); + INSERT INTO meta VALUES ('Legacy', 'qq', 'group', 1000, 'not-a-version'); + CREATE TABLE member (id INTEGER, platform_id TEXT, name TEXT, nickname TEXT); + INSERT INTO member VALUES (1, 'legacy-user', 'Alice', 'Alice Group'); + CREATE TABLE message (id INTEGER, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT); + INSERT INTO message VALUES (1, 1, 1000, 0, 'legacy message'); + `) + const actual = fixture.prepare('SELECT schema_version, typeof(schema_version) AS storage FROM meta').get() as { + schema_version: unknown + storage: string + } + assert.equal(actual.schema_version, 'not-a-version') + assert.equal(actual.storage, 'text') + } finally { + fixture.close() + } + + const originalDb = fs.readFileSync(dbPath) + const markerPath = path.join(dataDir, '.chatlab-meta.json') + assert.equal(readDataDirCompatibilityMeta(dataDir), null) + + const dbManager = new DatabaseManager(new NodePathProvider(dataDir), { + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + t.after(() => dbManager.closeAll()) + // CLI import analysis must retain read-only access to older/unrecognized + // schema candidates, without silently applying MCP's stricter rule. + assert.deepEqual(dbManager.listSessionIdsReadonly(), ['invalid-version']) + const sharedDb = dbManager.openReadonly('invalid-version') + assert.ok(sharedDb) + assert.equal(sharedDb.readonly, true) + sharedDb.close() + + const client = new Client({ name: 'invalid-schema-test-client', version: '1.0.0' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const connect = McpServer.prototype.connect + t.mock.method(McpServer.prototype, 'connect', function (this: McpServer) { + t.after(() => this.close()) + return connect.call(this, serverTransport) + }) + await startMcpServer({ version: '0.37.6', dbManager }) + await client.connect(clientTransport) + try { + for (const [name, args] of [ + ['chatlab_list_sessions', {}], + ['chatlab_execute_sql', { session_id: 'invalid-version', sql: 'SELECT 1 AS ok' }], + ['chatlab_get_session_info', { session_id: 'invalid-version' }], + ['chatlab_get_schema', { session_id: 'invalid-version' }], + ] as const) { + const result = await client.callTool({ name, arguments: { ...args, format: 'json' } }) + assert.equal(result.isError, true, `${name} incorrectly accepted an invalid schema version`) + assert.match(JSON.stringify(result.content), /invalid schema version/i, name) + assert.doesNotMatch(JSON.stringify(result.content), /no such column/i, name) + } + + for (const uri of [ + 'chatlab://sessions', + 'chatlab://sessions/invalid-version/meta', + 'chatlab://sessions/invalid-version/schema', + ]) { + await assert.rejects(client.readResource({ uri }), /invalid schema version/i, uri) + } + assert.deepEqual(fs.readFileSync(dbPath), originalDb) + assert.equal(fs.existsSync(markerPath), false) + } finally { + await client.close() + } +}) + +test('real MCP tools and resources read checkpointed WAL without logical data/marker mutation', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-wal-live-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const dataDir = path.join(root, 'data') + const dbDir = path.join(dataDir, 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'wal.db') + const fixture = openTestSqliteDatabase(dbPath) + try { + fixture.pragma('journal_mode = WAL') + fixture.exec(CHAT_DB_TABLES) + fixture.exec(` + INSERT INTO meta (name, platform, type, imported_at) VALUES ('WAL live', 'qq', 'group', 1000); + INSERT INTO member (platform_id, account_name) VALUES ('alice', 'Alice'); + INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'WAL query works'); + `) + } finally { + fixture.close() + } + const dbManager = new DatabaseManager(new NodePathProvider(dataDir), { + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + t.after(() => dbManager.closeAll()) + dbManager.raiseCurrentChatDbCompatibilityGate() + const original = fs.readFileSync(dbPath) + const metaPath = path.join(dataDir, '.chatlab-meta.json') + const marker = fs.readFileSync(metaPath) + + const client = new Client({ name: 'wal-live-test', version: '1.0.0' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const connect = McpServer.prototype.connect + t.mock.method(McpServer.prototype, 'connect', function (this: McpServer) { + t.after(() => this.close()) + return connect.call(this, serverTransport) + }) + await startMcpServer({ version: '0.37.6', dbManager }) + await client.connect(clientTransport) + try { + for (const [name, args] of [ + ['chatlab_list_sessions', {}], + ['chatlab_get_session_info', { session_id: 'wal' }], + ['chatlab_get_recent_messages', { session_id: 'wal' }], + ['chatlab_execute_sql', { session_id: 'wal', sql: 'SELECT content FROM message' }], + ] as const) { + const result = await client.callTool({ name, arguments: { ...args, format: 'json' } }) + assert.equal(result.isError, undefined, `${name}: ${JSON.stringify(result.content)}`) + } + for (const uri of ['chatlab://sessions', 'chatlab://sessions/wal/meta', 'chatlab://sessions/wal/schema']) { + const resource = await client.readResource({ uri }) + assert.ok(resource.contents.length > 0, uri) + } + // Native readonly WAL readers may create coordination sidecars, but may + // not modify logical chat records, DB schema or compatibility metadata. + assert.deepEqual(fs.readFileSync(dbPath), original) + assert.deepEqual(fs.readFileSync(metaPath), marker) + const readonlyDb = openTestSqliteDatabase(dbPath, { readonly: true }) + try { + assert.deepEqual(readonlyDb.prepare('SELECT content FROM message').all(), [{ content: 'WAL query works' }]) + } finally { + readonlyDb.close() + } + } finally { + await client.close() + } +}) diff --git a/packages/mcp-server/src/live-safety.test.ts b/packages/mcp-server/src/live-safety.test.ts new file mode 100644 index 000000000..a4c84a028 --- /dev/null +++ b/packages/mcp-server/src/live-safety.test.ts @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import Database from 'better-sqlite3' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { CHAT_DB_TABLES } from '@openchatlab/core' +import { DatabaseManager, NodePathProvider } from '@openchatlab/node-runtime' +import { startMcpServer } from './server' + +/** Full MCP protocol tests for the two trust boundaries identified in PR review. */ +test('MCP confines session IDs to real ChatLab databases and rejects SQL prepare side effects', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-safety-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const dataDir = path.join(root, 'data') + const dbDir = path.join(dataDir, 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'valid.db') + const db = new Database(dbPath) + try { + db.pragma('journal_mode=WAL') + db.exec(CHAT_DB_TABLES) + db.exec(` + INSERT INTO meta(name, platform, type, imported_at) + VALUES('Valid chat', 'qq', 'group', 1000); + INSERT INTO member(platform_id, account_name) VALUES('p1', 'Alice'); + INSERT INTO message(sender_id, ts, type, content) VALUES(1, 1000, 0, 'allowed chat content'); + `) + } finally { + db.close() + } + + const privateDbPath = path.join(dataDir, 'private.db') + const privateDb = new Database(privateDbPath) + try { + privateDb.exec("CREATE TABLE secrets(value TEXT); INSERT INTO secrets VALUES('SYNTHETIC_OUTSIDE_SESSION_SECRET');") + } finally { + privateDb.close() + } + const foreignDbPath = path.join(dbDir, 'foreign.db') + const foreignDb = new Database(foreignDbPath) + try { + foreignDb.exec("CREATE TABLE secrets(value TEXT); INSERT INTO secrets VALUES('NON_CHATLAB_SECRET');") + } finally { + foreignDb.close() + } + + const manager = new DatabaseManager(new NodePathProvider(dataDir), { + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + manager.raiseCurrentChatDbCompatibilityGate() + t.after(() => manager.closeAll()) + assert.deepEqual(manager.listSessionIdsMcpReadonly(), ['valid']) + // File symlinks require elevated privilege on some Windows test runners. + // Exercise the symlink boundary on POSIX; Windows-specific path checks still run. + if (process.platform !== 'win32') fs.symlinkSync(privateDbPath, path.join(dbDir, 'linked.db')) + const markerPath = path.join(dataDir, '.chatlab-meta.json') + const beforeMarker = fs.readFileSync(markerPath) + const beforeDb = fs.readFileSync(dbPath) + + const client = new Client({ name: 'mcp-safety-client', version: '1.0.0' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const connect = McpServer.prototype.connect + t.mock.method(McpServer.prototype, 'connect', function (this: McpServer) { + t.after(() => this.close()) + return connect.call(this, serverTransport) + }) + await startMcpServer({ version: '0.37.6', dbManager: manager }) + await client.connect(clientTransport) + try { + for (const sessionId of [ + '../private', + '..\\private', + 'C:\\outside', + '/tmp/outside', + 'name:stream', + 'bad\u0000id', + '.', + '..', + 'linked', + 'foreign', + ]) { + const result = await client.callTool({ + name: 'chatlab_execute_sql', + arguments: { session_id: sessionId, sql: 'SELECT * FROM secrets', format: 'json' }, + }) + assert.equal(result.isError, true, `${JSON.stringify(sessionId)} escaped session boundary`) + assert.doesNotMatch(JSON.stringify(result.content), /SYNTHETIC_OUTSIDE_SESSION_SECRET|NON_CHATLAB_SECRET/) + } + for (const uri of [ + 'chatlab://sessions/..%2Fprivate/meta', + 'chatlab://sessions/..%5Cprivate/schema', + 'chatlab://sessions/linked/meta', + 'chatlab://sessions/foreign/schema', + ]) { + let body: string + try { + const result = await client.readResource({ uri }) + body = JSON.stringify(result.contents) + } catch (error) { + body = String(error) + } + assert.doesNotMatch(body, /SYNTHETIC_OUTSIDE_SESSION_SECRET|NON_CHATLAB_SECRET/, uri) + assert.match(body, /Session not found|Invalid MCP session|Not a ChatLab|not found|Invalid Request/i, uri) + } + + const check = async (sql: string) => + client.callTool({ name: 'chatlab_execute_sql', arguments: { session_id: 'valid', sql, format: 'json' } }) + // Query a second SQLite connection to prove that prepare() itself did not + // change the process-global temp_store_directory setting. + const probe = new Database(':memory:') + const beforeTempDirectory = probe.pragma('temp_store_directory') + try { + const pragma = await check(`PRAGMA temp_store_directory='${root.replace(/'/g, "''")}' -- LIMIT`) + assert.equal(pragma.isError, true) + assert.match(JSON.stringify(pragma.content), /Only SELECT or WITH/i) + assert.deepEqual(probe.pragma('temp_store_directory'), beforeTempDirectory) + } finally { + probe.close() + } + for (const sql of [ + 'ATTACH DATABASE ":memory:" AS extra', + 'EXPLAIN PRAGMA temp_store_directory', + 'BEGIN', + '/* comment */ PRAGMA writable_schema=ON', + ]) { + const result = await check(sql) + assert.equal(result.isError, true, sql) + assert.match(JSON.stringify(result.content), /Only SELECT or WITH/i, sql) + } + // WITH is allowed lexically, but SQLite's native readonly flag must + // still reject DML hidden behind the CTE entry point. + const withWrite = await check( + "WITH c(x) AS (SELECT 1) UPDATE message SET content='TAMPERED' WHERE id IN (SELECT x FROM c)" + ) + assert.match(JSON.stringify(withWrite.content), /read-only statements/i) + + const validSql = [ + 'SELECT content FROM message', + '-- comment\n SELECT content FROM message', + '/* leading */\nWITH c(x) AS (SELECT 7) SELECT x FROM c', + ] + for (const sql of validSql) { + const result = await check(sql) + assert.equal(result.isError, undefined, `${sql}: ${JSON.stringify(result.content)}`) + } + assert.deepEqual(fs.readFileSync(dbPath), beforeDb) + assert.deepEqual(fs.readFileSync(markerPath), beforeMarker) + const other = new Database(privateDbPath, { readonly: true }) + try { + assert.deepEqual(other.prepare('SELECT value FROM secrets').all(), [ + { value: 'SYNTHETIC_OUTSIDE_SESSION_SECRET' }, + ]) + } finally { + other.close() + } + } finally { + await client.close() + } +}) diff --git a/packages/mcp-server/src/schema.test.ts b/packages/mcp-server/src/schema.test.ts new file mode 100644 index 000000000..c08c0cca4 --- /dev/null +++ b/packages/mcp-server/src/schema.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { z } from 'zod' +import { jsonSchemaToZod } from './schema' + +for (const { type, values, invalid } of [ + { type: 'string', values: ['alpha', 'beta'], invalid: [1] }, + { type: 'number', values: [10, 20], invalid: ['10'] }, + { type: 'boolean', values: [true, false], invalid: ['true'] }, +]) { + test(`converts ${type} arrays without coercing scalars or array elements`, () => { + const shape = jsonSchemaToZod({ values: { type: 'array', items: { type }, description: 'Array values' } }, [ + 'values', + ]) + const schema = z.object(shape) + + assert.deepEqual(schema.parse({ values }), { values }) + assert.deepEqual(schema.parse({ values: [] }), { values: [] }) + for (const value of [undefined, null, values[0], invalid, [...values, ...invalid]]) { + assert.equal(schema.safeParse({ values: value }).success, false) + } + assert.equal(shape.values.description, 'Array values') + }) +} + +test('keeps optional arrays optional and does not apply JSON Schema defaults', () => { + const schema = z.object(jsonSchemaToZod({ values: { type: 'array', items: { type: 'string' }, default: ['alpha'] } })) + + assert.deepEqual(schema.parse({}), {}) + assert.deepEqual(schema.parse({ values: ['beta'] }), { values: ['beta'] }) +}) + +for (const items of [{ type: 'object' }, { type: 'array' }, { type: 'integer' }, undefined]) { + test(`preserves the string/enum fallback for arrays with ${items?.type ?? 'missing'} items`, () => { + const shape = jsonSchemaToZod( + { + required: { type: 'array', items, description: 'Unsupported array' }, + optional: { type: 'array', items, default: ['alpha'] }, + choice: { type: 'array', items, enum: ['alpha', 'beta'] }, + }, + ['required'] + ) + const schema = z.object(shape) + + assert.deepEqual(schema.parse({ required: 'alpha' }), { required: 'alpha' }) + const values = { required: 'alpha', optional: 'beta', choice: 'alpha' } + assert.deepEqual(schema.parse(values), values) + assert.equal(schema.safeParse({}).success, false) + assert.equal(schema.safeParse({ ...values, choice: 'gamma' }).success, false) + for (const value of [[], ['alpha'], [{ name: 'Alice', count: 4 }]]) { + assert.equal(schema.safeParse({ ...values, required: value }).success, false) + assert.equal(schema.safeParse({ ...values, optional: value }).success, false) + } + assert.equal(shape.required.description, 'Unsupported array') + }) +} + +test('preserves scalar, enum, fallback, required and default conversion behavior', () => { + const shape = jsonSchemaToZod( + { + text: { type: 'string', description: 'Text value' }, + count: { type: 'number', default: 5 }, + enabled: { type: 'boolean' }, + mode: { type: 'string', enum: ['text', 'json'] }, + fallback: { type: 'object' }, + }, + ['text', 'mode'] + ) + const schema = z.object(shape) + const required = { text: 'hello', mode: 'json' } + const all = { ...required, count: 2, enabled: false, fallback: 'value' } + + assert.deepEqual(schema.parse(required), required) + assert.deepEqual(schema.parse(all), all) + for (const value of [ + {}, + { ...all, text: 1 }, + { ...all, count: '2' }, + { ...all, enabled: 'false' }, + { ...all, mode: 'xml' }, + { ...all, fallback: {} }, + ]) { + assert.equal(schema.safeParse(value).success, false) + } + assert.equal(shape.text.description, 'Text value') +}) diff --git a/packages/mcp-server/src/schema.ts b/packages/mcp-server/src/schema.ts index f4497fd9f..835ffa2be 100644 --- a/packages/mcp-server/src/schema.ts +++ b/packages/mcp-server/src/schema.ts @@ -6,10 +6,13 @@ import { z } from 'zod' /** * Convert simple JSON Schema properties to a Zod shape object. - * Supports string / number / boolean / enum types. + * Supports string / number / boolean / enum types and arrays of primitives. */ export function jsonSchemaToZod( - properties: Record, + properties: Record< + string, + { type: string; description?: string; default?: unknown; enum?: unknown[]; items?: { type: string } } + >, required?: string[] ): Record { const shape: Record = {} @@ -25,6 +28,15 @@ export function jsonSchemaToZod( case 'boolean': zodType = z.boolean().describe(prop.description ?? '') break + case 'array': { + const itemType = prop.items?.type + if (itemType === 'string' || itemType === 'number' || itemType === 'boolean') { + const itemSchema = itemType === 'number' ? z.number() : itemType === 'boolean' ? z.boolean() : z.string() + zodType = z.array(itemSchema).describe(prop.description ?? '') + break + } + } + // Fall through to the existing string/enum fallback for unsupported item types. case 'string': default: if (prop.enum) { diff --git a/packages/mcp-server/src/server.test.ts b/packages/mcp-server/src/server.test.ts new file mode 100644 index 000000000..8275f592b --- /dev/null +++ b/packages/mcp-server/src/server.test.ts @@ -0,0 +1,375 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import Database from 'better-sqlite3' +import { CHAT_DB_TABLES } from '@openchatlab/core' +import { MCP_TOOL_REGISTRY } from '@openchatlab/tools' +import { + DatabaseManager, + NodePathProvider, + raiseDataDirMinRuntimeVersion, + readDataDirCompatibilityMeta, +} from '@openchatlab/node-runtime' +import { registerTools } from './server' + +test('advertises logical ChatLab MCP queries as read-only', async () => { + const server = new McpServer({ name: 'chatlab-test', version: '0.0.0' }) + const client = new Client({ name: 'chatlab-test-client', version: '0.0.0' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + + try { + registerTools(server, { listSessionIdsMcpReadonly: () => [], openMcpReadonly: () => null }) + await server.connect(serverTransport) + await client.connect(clientTransport) + + const { tools } = await client.listTools() + assert.deepEqual( + tools.map((tool) => tool.name).sort(), + MCP_TOOL_REGISTRY.map((tool) => `chatlab_${tool.name}`).sort() + ) + for (const tool of tools) { + assert.deepEqual( + tool.annotations, + { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + tool.name + ) + } + } finally { + await client.close() + await server.close() + } +}) + +test('rejects unsafe raw MCP SQL before opening a database', async () => { + const server = new McpServer({ name: 'chatlab-sql-precheck', version: '0.0.0' }) + const client = new Client({ name: 'chatlab-sql-precheck-client', version: '0.0.0' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + let databaseOpenCalls = 0 + + try { + registerTools(server, { + listSessionIdsMcpReadonly: () => [], + openMcpReadonly: () => { + databaseOpenCalls++ + throw new Error('Unsafe SQL should never open a database') + }, + }) + await server.connect(serverTransport) + await client.connect(clientTransport) + + const result = await client.callTool({ + name: 'chatlab_execute_sql', + arguments: { session_id: 'missing', sql: "PRAGMA temp_store_directory='/tmp'" }, + }) + assert.equal(result.isError, true) + assert.match(JSON.stringify(result.content), /Only SELECT or WITH queries/i) + assert.equal(databaseOpenCalls, 0) + } finally { + await client.close() + await server.close() + } +}) + +test('MCP rejects schema v5 before dispatch without migrating or raising the compatibility gate', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-readonly-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + + const dataDir = path.join(root, 'data') + const dbDir = path.join(dataDir, 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'legacy.db') + + // Version 5 predates the v6 migration that can raise the compatibility gate. + const fixture = new Database(dbPath) + try { + fixture.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT 5 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Legacy chat', 'qq', 'group', 1000, 5); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT + ); + INSERT INTO member (id, platform_id, account_name) VALUES (1, 'legacy-user', 'Alice'); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + INSERT INTO message (id, sender_id, ts, type, content) + VALUES (1, 1, 1000, 0, 'hello from legacy schema'); + `) + } finally { + fixture.close() + } + const initialDatabase = fs.readFileSync(dbPath) + assert.equal(readDataDirCompatibilityMeta(dataDir), null) + + const dbManager = new DatabaseManager(new NodePathProvider(dataDir), { + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + t.after(() => dbManager.closeAll()) + + const server = new McpServer({ name: 'chatlab-test', version: '0.37.6' }) + const client = new Client({ name: 'chatlab-test-client', version: '0.0.0' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + + try { + registerTools(server, dbManager) + await server.connect(serverTransport) + await client.connect(clientTransport) + + for (const [name, args] of [ + ['chatlab_list_sessions', {}], + ['chatlab_get_schema', { session_id: 'legacy' }], + ['chatlab_get_session_info', { session_id: 'legacy' }], + ['chatlab_execute_sql', { session_id: 'legacy', sql: 'SELECT content FROM message' }], + ] as const) { + const result = await client.callTool({ name, arguments: { ...args, format: 'json' } }) + assert.equal(result.isError, true, `${name} should require an explicit upgrade`) + assert.match(JSON.stringify(result.content), /schema v5.*schema v10.*migrate/i) + assert.equal(readDataDirCompatibilityMeta(dataDir), null, `${name} raised the compatibility gate`) + assert.deepEqual(fs.readFileSync(dbPath), initialDatabase, `${name} modified the legacy database`) + } + } finally { + await client.close() + await server.close() + } + + const after = new Database(dbPath, { readonly: true }) + try { + const version = after.prepare('SELECT schema_version FROM meta').get() as { schema_version: number } + assert.equal(version.schema_version, 5) + assert.equal(after.prepare("SELECT name FROM sqlite_master WHERE name = 'segment'").get(), undefined) + } finally { + after.close() + } +}) + +test('MCP requires an explicit v9 upgrade and preserves current v10 summary results', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-v9-summaries-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const dataDir = path.join(root, 'data') + const dbDir = path.join(dataDir, 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + + const legacyPath = path.join(dbDir, 'v9.db') + const legacyDb = new Database(legacyPath) + try { + legacyDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, platform TEXT NOT NULL, type TEXT NOT NULL, + imported_at INTEGER NOT NULL, schema_version INTEGER DEFAULT 9 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('v9 chat', 'qq', 'group', 1000, 9); + CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT UNIQUE, account_name TEXT); + INSERT INTO member VALUES (1, 'legacy-user', 'Alice'); + CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT); + INSERT INTO message VALUES (1, 1, 1000, 0, 'hello'); + CREATE TABLE segment ( + id INTEGER PRIMARY KEY, start_ts INTEGER, end_ts INTEGER, + message_count INTEGER, summary TEXT + ); + INSERT INTO segment VALUES (1, 1000, 1000, 1, 'Old unverified summary'); + CREATE TABLE message_context (message_id INTEGER PRIMARY KEY, segment_id INTEGER, topic_id INTEGER); + INSERT INTO message_context VALUES (1, 1, NULL); + `) + } finally { + legacyDb.close() + } + + const currentPath = path.join(dbDir, 'v10.db') + const currentDb = new Database(currentPath) + try { + currentDb.exec(CHAT_DB_TABLES) + currentDb.exec(` + INSERT INTO meta (name, platform, type, imported_at) + VALUES ('v10 chat', 'qq', 'group', 1000); + INSERT INTO member (id, platform_id, account_name) VALUES (1, 'new-user', 'Bob'); + INSERT INTO message (id, sender_id, ts, type, content) VALUES (1, 1, 1000, 0, 'hello'); + INSERT INTO segment (id, start_ts, end_ts, message_count, summary, summary_message_count) + VALUES (1, 1000, 1000, 1, 'Verified summary', 1); + INSERT INTO message_context (message_id, segment_id, topic_id) VALUES (1, 1, NULL); + `) + } finally { + currentDb.close() + } + + const pathProvider = new NodePathProvider(dataDir) + // Schema v9 has crossed the compatibility-gate boundary at v6. A writable + // runtime must have repaired this metadata before read-only MCP access. + raiseDataDirMinRuntimeVersion(pathProvider, { + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reason: 'segment-schema', + runtime: { version: '0.37.6', kind: 'desktop' }, + module: 'test-fixture', + }) + const beforeV9 = fs.readFileSync(legacyPath) + const beforeV10 = fs.readFileSync(currentPath) + const beforeGate = fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')) + + const dbManager = new DatabaseManager(pathProvider, { + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + t.after(() => dbManager.closeAll()) + const server = new McpServer({ name: 'chatlab-test', version: '0.37.6' }) + const client = new Client({ name: 'chatlab-test-client', version: '0.0.0' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + + try { + registerTools(server, dbManager) + await server.connect(serverTransport) + await client.connect(clientTransport) + + const oldResult = await client.callTool({ + name: 'chatlab_get_segment_summaries', + arguments: { session_id: 'v9', format: 'json' }, + }) + assert.equal(oldResult.isError, true, JSON.stringify(oldResult.content)) + assert.match(JSON.stringify(oldResult.content), /schema v9.*schema v10.*migrate/i) + assert.deepEqual(fs.readFileSync(legacyPath), beforeV9) + assert.deepEqual(fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')), beforeGate) + + const currentResult = await client.callTool({ + name: 'chatlab_get_segment_summaries', + arguments: { session_id: 'v10', format: 'json' }, + }) + assert.equal(currentResult.isError, undefined, JSON.stringify(currentResult.content)) + const parsed = JSON.parse((currentResult.content as Array<{ text: string }>)[0].text) + assert.equal(parsed.total, 1) + assert.equal(parsed.segments[0].summary, 'Verified summary') + assert.deepEqual(parsed.segments[0].participants, ['Bob']) + assert.deepEqual(fs.readFileSync(currentPath), beforeV10) + assert.deepEqual(fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')), beforeGate) + } finally { + await client.close() + await server.close() + } + + const verifyLegacy = new Database(legacyPath, { readonly: true }) + try { + assert.equal( + (verifyLegacy.prepare('SELECT schema_version FROM meta').get() as { schema_version: number }).schema_version, + 9 + ) + assert.equal( + verifyLegacy.prepare("SELECT name FROM pragma_table_info('segment') WHERE name='summary_message_count'").get(), + undefined + ) + } finally { + verifyLegacy.close() + } +}) + +test('read-only MCP rejects schema v6 without a repaired compatibility gate', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-missing-gate-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const dataDir = path.join(root, 'data') + const dbDir = path.join(dataDir, 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'v6.db') + const fixture = new Database(dbPath) + try { + fixture.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, platform TEXT NOT NULL, type TEXT NOT NULL, + imported_at INTEGER NOT NULL, schema_version INTEGER DEFAULT 6 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('v6 chat', 'qq', 'group', 1000, 6); + CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT UNIQUE, account_name TEXT); + CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT); + `) + } finally { + fixture.close() + } + const beforeDb = fs.readFileSync(dbPath) + assert.equal(readDataDirCompatibilityMeta(dataDir), null) + + const pathProvider = new NodePathProvider(dataDir) + const manager = new DatabaseManager(pathProvider, { runtime: { version: '0.37.6', kind: 'mcp' } }) + t.after(() => manager.closeAll()) + const server = new McpServer({ name: 'chatlab-test', version: '0.37.6' }) + const client = new Client({ name: 'chatlab-test-client', version: '0.0.0' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + try { + registerTools(server, manager) + await server.connect(serverTransport) + await client.connect(clientTransport) + + for (const [name, args] of [ + ['chatlab_list_sessions', {}], + ['chatlab_get_session_info', { session_id: 'v6' }], + ['chatlab_get_schema', { session_id: 'v6' }], + ['chatlab_execute_sql', { session_id: 'v6', sql: 'SELECT 1' }], + ] as const) { + const result = await client.callTool({ name, arguments: { ...args, format: 'json' } }) + assert.equal(result.isError, true, `${name} should reject unrepaired metadata`) + assert.match(JSON.stringify(result.content), /current writable ChatLab runtime/i) + assert.equal(readDataDirCompatibilityMeta(dataDir), null) + assert.deepEqual(fs.readFileSync(dbPath), beforeDb, `${name} changed the database`) + } + + // Merely having metadata is insufficient: the marker must include the + // migration's minimum version, compatibility level and reason. + raiseDataDirMinRuntimeVersion(pathProvider, { + minRuntimeVersion: '0.24.0', + dataCompatibilityVersion: 0, + reason: 'unrelated', + runtime: { version: '0.37.6', kind: 'desktop' }, + module: 'test-fixture', + }) + const inadequateGate = fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')) + const inadequateResult = await client.callTool({ + name: 'chatlab_get_schema', + arguments: { session_id: 'v6', format: 'json' }, + }) + assert.equal(inadequateResult.isError, true) + assert.match(JSON.stringify(inadequateResult.content), /current writable ChatLab runtime/i) + assert.deepEqual(fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')), inadequateGate) + assert.deepEqual(fs.readFileSync(dbPath), beforeDb) + + // The writable runtime is responsible for repairing this marker separately. + raiseDataDirMinRuntimeVersion(pathProvider, { + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reason: 'segment-schema', + runtime: { version: '0.37.6', kind: 'desktop' }, + module: 'test-fixture', + }) + const gateAfterRepair = fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')) + const result = await client.callTool({ + name: 'chatlab_get_session_info', + arguments: { session_id: 'v6', format: 'json' }, + }) + assert.equal(result.isError, true, JSON.stringify(result.content)) + assert.match(JSON.stringify(result.content), /schema v6.*schema v10.*migrate/i) + assert.deepEqual(fs.readFileSync(dbPath), beforeDb) + assert.deepEqual(fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')), gateAfterRepair) + } finally { + await client.close() + await server.close() + } +}) diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 3786f3cb3..682558359 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -9,14 +9,25 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' import { getSessionMeta, getSessionOverview, getDatabaseSchema } from '@openchatlab/core' -import { MCP_TOOL_REGISTRY, CoreDataProvider } from '@openchatlab/tools' +import { MCP_TOOL_REGISTRY, CoreDataProvider, getLocalizedToolMetadata } from '@openchatlab/tools' import type { SessionListContext } from '@openchatlab/tools/src/definitions/sessions' import type { McpDatabaseManager, McpServerOptions } from './types' import { jsonSchemaToZod } from './schema' import { formatToolResultAsText } from './format' +import { assertMcpSqlReadOnly } from './sql-guard' const MCP_TOOL_PREFIX = 'chatlab_' +// These tools only read logical chat data, never migrate sessions or modify +// the compatibility marker. SQLite's ordinary WAL/SHM coordination can still +// change auxiliary filesystem files; that contract distinction is documented +// explicitly and left for upstream maintainer approval. +const READ_ONLY_TOOL_ANNOTATIONS = { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, +} as const + const FORMAT_PARAM = z.enum(['text', 'json']).optional().describe('text (default) or json') /** Tools where JSON is a better default (raw SQL results, schema) */ @@ -30,71 +41,95 @@ function applyFormat(content: string, format: string | undefined, toolName: stri return textResult ?? content } -function registerTools(server: McpServer, dbManager: McpDatabaseManager): void { +export function registerTools(server: McpServer, dbManager: McpDatabaseManager, locale?: string): void { for (const tool of MCP_TOOL_REGISTRY) { const mcpName = `${MCP_TOOL_PREFIX}${tool.name}` + const { description, inputSchema } = getLocalizedToolMetadata(tool, locale) if (tool.name === 'list_sessions') { const zodShape = { - ...jsonSchemaToZod(tool.inputSchema.properties, tool.inputSchema.required), + ...jsonSchemaToZod(inputSchema.properties, inputSchema.required), format: FORMAT_PARAM, } - server.tool(mcpName, tool.description, zodShape, async (params) => { - const format = params.format as string | undefined - const context: SessionListContext = { - db: null as any, - sessionId: '', - listSessionIds: () => dbManager.listSessionIds(), - openDb: (id) => dbManager.open(id), + server.registerTool( + mcpName, + { description, inputSchema: zodShape, annotations: READ_ONLY_TOOL_ANNOTATIONS }, + async (params) => { + const format = params.format as string | undefined + const context: SessionListContext = { + db: null as any, + sessionId: '', + listSessionIds: () => dbManager.listSessionIdsMcpReadonly(), + openDb: (id) => dbManager.openMcpReadonly(id), + // Close each independent session transaction before opening the next. + closeDb: (db) => db.close(), + } + const toolParams = { ...params } as Record + delete toolParams.format + const result = await tool.handler(toolParams, context) + const text = applyFormat(result.content, format, tool.name) + return { content: [{ type: 'text' as const, text }] } } - const toolParams = { ...params } as Record - delete toolParams.format - const result = await tool.handler(toolParams, context) - const text = applyFormat(result.content, format, tool.name) - return { content: [{ type: 'text' as const, text }] } - }) + ) continue } const zodShape = { session_id: z.string().describe('Session ID'), - ...jsonSchemaToZod(tool.inputSchema.properties, tool.inputSchema.required), + ...jsonSchemaToZod(inputSchema.properties, inputSchema.required), format: FORMAT_PARAM, } - server.tool(mcpName, tool.description, zodShape, async (params) => { - const sessionId = params.session_id as string - const format = params.format as string | undefined - const db = dbManager.open(sessionId) - if (!db) { - return { - content: [{ type: 'text' as const, text: `Session not found: ${sessionId}` }], - isError: true, + server.registerTool( + mcpName, + { description, inputSchema: zodShape, annotations: READ_ONLY_TOOL_ANNOTATIONS }, + async (params) => { + const sessionId = params.session_id as string + const format = params.format as string | undefined + // Reject SQL that can have prepare-time side effects before even opening + // the SQLite database, which might itself create WAL/SHM sidecars. + const rawSql = (params as Record).sql + if ((tool.name === 'execute_sql' || tool.name === 'render_chart') && typeof rawSql === 'string') { + assertMcpSqlReadOnly(rawSql) + } + const db = dbManager.openMcpReadonly(sessionId) + if (!db) { + return { + content: [{ type: 'text' as const, text: `Session not found: ${sessionId}` }], + isError: true, + } } - } - - const toolParams = { ...params } as Record - delete toolParams.session_id - delete toolParams.format - const result = await tool.handler(toolParams, { db, sessionId, dataProvider: new CoreDataProvider(db) }) - const text = applyFormat(result.content, format, tool.name) - return { content: [{ type: 'text' as const, text }] } - }) + try { + const toolParams = { ...params } as Record + delete toolParams.session_id + delete toolParams.format + const result = await tool.handler(toolParams, { db, sessionId, dataProvider: new CoreDataProvider(db) }) + const text = applyFormat(result.content, format, tool.name) + return { content: [{ type: 'text' as const, text }] } + } finally { + db.close() + } + } + ) } } function registerResources(server: McpServer, dbManager: McpDatabaseManager): void { server.resource('sessions-list', 'chatlab://sessions', { description: '所有已导入的聊天会话列表' }, async () => { - const sessionIds = dbManager.listSessionIds() + const sessionIds = dbManager.listSessionIdsMcpReadonly() const sessions = sessionIds .map((id) => { - const db = dbManager.open(id) + const db = dbManager.openMcpReadonly(id) if (!db) return null - const meta = getSessionMeta(db) - if (!meta) return null - return { id, name: meta.name, platform: meta.platform, type: meta.type } + try { + const meta = getSessionMeta(db) + if (!meta) return null + return { id, name: meta.name, platform: meta.platform, type: meta.type } + } finally { + db.close() + } }) .filter(Boolean) @@ -115,22 +150,26 @@ function registerResources(server: McpServer, dbManager: McpDatabaseManager): vo { description: '会话元信息(名称、平台、消息数等)' }, async (uri, params) => { const sessionId = params.sessionId as string - const db = dbManager.open(sessionId) + const db = dbManager.openMcpReadonly(sessionId) if (!db) { return { contents: [{ uri: uri.href, text: '{"error": "Session not found"}', mimeType: 'application/json' }] } } - const meta = getSessionMeta(db) - const overview = getSessionOverview(db) - - return { - contents: [ - { - uri: uri.href, - text: JSON.stringify({ ...meta, ...overview }, null, 2), - mimeType: 'application/json', - }, - ], + try { + const meta = getSessionMeta(db) + const overview = getSessionOverview(db) + + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ ...meta, ...overview }, null, 2), + mimeType: 'application/json', + }, + ], + } + } finally { + db.close() } } ) @@ -141,32 +180,36 @@ function registerResources(server: McpServer, dbManager: McpDatabaseManager): vo { description: '会话数据库的表结构' }, async (uri, params) => { const sessionId = params.sessionId as string - const db = dbManager.open(sessionId) + const db = dbManager.openMcpReadonly(sessionId) if (!db) { return { contents: [{ uri: uri.href, text: '{"error": "Session not found"}', mimeType: 'application/json' }] } } - const schema = getDatabaseSchema(db) + try { + const schema = getDatabaseSchema(db) - return { - contents: [ - { - uri: uri.href, - text: JSON.stringify(schema, null, 2), - mimeType: 'application/json', - }, - ], + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(schema, null, 2), + mimeType: 'application/json', + }, + ], + } + } finally { + db.close() } } ) } export async function startMcpServer(options: McpServerOptions): Promise { - const { version, name = 'chatlab', dbManager } = options + const { version, name = 'chatlab', dbManager, locale } = options const server = new McpServer({ name, version }) - registerTools(server, dbManager) + registerTools(server, dbManager, locale) registerResources(server, dbManager) const transport = new StdioServerTransport() diff --git a/packages/mcp-server/src/session-lifecycle.test.ts b/packages/mcp-server/src/session-lifecycle.test.ts new file mode 100644 index 000000000..4cf7a50e6 --- /dev/null +++ b/packages/mcp-server/src/session-lifecycle.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { CHAT_DB_TABLES } from '@openchatlab/core' +import type { DatabaseAdapter } from '@openchatlab/core' +import { DatabaseManager, NodePathProvider } from '@openchatlab/node-runtime' +import { startMcpServer } from './server' +import { openTestSqliteDatabase } from '../../../tests/helpers/sqlite.mts' + +test('MCP list_sessions closes each read transaction before opening the next one', async (t) => { + const root = fs.mkdtempSync(path.join(process.cwd(), '.chatlab-mcp-list-lifecycle-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const dataDir = path.join(root, 'data') + const dbDir = path.join(dataDir, 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + + const firstPath = path.join(dbDir, 'first.db') + const fixture = openTestSqliteDatabase(firstPath) + try { + fixture.pragma('journal_mode = WAL') + fixture.exec(CHAT_DB_TABLES) + fixture.exec(` + INSERT INTO meta (name, platform, type, imported_at) + VALUES ('Session One', 'qq', 'group', 1000); + `) + } finally { + fixture.close() + } + const secondPath = path.join(dbDir, 'second.db') + fs.copyFileSync(firstPath, secondPath) + const second = openTestSqliteDatabase(secondPath) + try { + second.exec("UPDATE meta SET name = 'Session Two'") + } finally { + second.close() + } + + const pathProvider = new NodePathProvider(dataDir) + const manager = new DatabaseManager(pathProvider, { runtime: { version: '0.37.6', kind: 'mcp' } }) + t.after(() => manager.closeAll()) + manager.raiseCurrentChatDbCompatibilityGate() + const markerPath = path.join(dataDir, '.chatlab-meta.json') + const beforeMarker = fs.readFileSync(markerPath) + + let activeConnections = 0 + let peakConnections = 0 + let sessionsOpened = 0 + let sessionsClosed = 0 + const trackedManager = { + listSessionIdsMcpReadonly: () => manager.listSessionIdsMcpReadonly(), + openMcpReadonly(sessionId: string): DatabaseAdapter | null { + const db = manager.openMcpReadonly(sessionId) + if (!db) return null + activeConnections++ + sessionsOpened++ + peakConnections = Math.max(peakConnections, activeConnections) + return { + readonly: db.readonly, + exec: (sql) => db.exec(sql), + prepare: (sql) => db.prepare(sql), + transaction: (fn) => db.transaction(fn), + pragma: (pragma) => db.pragma(pragma), + close() { + db.close() + activeConnections-- + sessionsClosed++ + }, + } + }, + } + + const client = new Client({ name: 'session-list-lifetime-test-client', version: '1.0.0' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const connect = McpServer.prototype.connect + t.mock.method(McpServer.prototype, 'connect', function (this: McpServer) { + t.after(() => this.close()) + return connect.call(this, serverTransport) + }) + + await startMcpServer({ version: '0.37.6', dbManager: trackedManager }) + await client.connect(clientTransport) + try { + const result = await client.callTool({ name: 'chatlab_list_sessions', arguments: { format: 'json' } }) + assert.equal(result.isError, undefined, JSON.stringify(result.content)) + const parsed = JSON.parse((result.content as Array<{ text: string }>)[0].text) + assert.equal(parsed.total, 2) + assert.deepEqual(parsed.sessions.map((s: { id: string }) => s.id).sort(), ['first', 'second']) + assert.equal(peakConnections, 1, 'a second session connection opened before the previous one was closed') + assert.equal(activeConnections, 0) + assert.equal(sessionsOpened, 2) + assert.equal(sessionsClosed, 2) + + const resource = await client.readResource({ uri: 'chatlab://sessions' }) + assert.match(JSON.stringify(resource.contents), /Session One/) + assert.match(JSON.stringify(resource.contents), /Session Two/) + assert.ok(fs.readdirSync(dbDir).includes('first.db')) + assert.ok(fs.readdirSync(dbDir).includes('second.db')) + assert.deepEqual(fs.readFileSync(markerPath), beforeMarker) + } finally { + await client.close() + } +}) diff --git a/packages/mcp-server/src/sql-guard.ts b/packages/mcp-server/src/sql-guard.ts new file mode 100644 index 000000000..ec3bf9bf7 --- /dev/null +++ b/packages/mcp-server/src/sql-guard.ts @@ -0,0 +1,27 @@ +/** + * MCP-only guard for user-provided SQL. SQLite can change global settings + * during prepare() (e.g. some PRAGMAs), before stmt.readonly can be checked. + * This lexical precheck is deliberately narrow, not a replacement for SQLite's + * native single-statement/read-only verification in the shared SQL executor. + */ +export function assertMcpSqlReadOnly(sql: string): void { + let pos = 0 + while (pos < sql.length) { + while (pos < sql.length && /\s/.test(sql[pos])) pos++ + if (sql.startsWith('--', pos)) { + const end = sql.indexOf('\n', pos + 2) + pos = end < 0 ? sql.length : end + 1 + continue + } + if (sql.startsWith('/*', pos)) { + const end = sql.indexOf('*/', pos + 2) + if (end < 0) throw new Error('Only SELECT or WITH queries are allowed in MCP') + pos = end + 2 + continue + } + break + } + if (!/^(SELECT|WITH)\b/i.test(sql.slice(pos))) { + throw new Error('Only SELECT or WITH queries are allowed in MCP') + } +} diff --git a/packages/mcp-server/src/standalone-runtime.ts b/packages/mcp-server/src/standalone-runtime.ts index 7324c5455..52465306f 100644 --- a/packages/mcp-server/src/standalone-runtime.ts +++ b/packages/mcp-server/src/standalone-runtime.ts @@ -16,3 +16,14 @@ export function initStandaloneMcpRuntime( const dbManager = new DatabaseManager(pathProvider, { runtime }) return { dbManager, pathProvider, runtime } } + +/** + * Locale for MCP tool descriptions: the configured UI language, or the system locale when unset. + * Mirrors Desktop's initLocale, so Chinese systems keep Chinese tool descriptions. + */ +export function resolveMcpLocale( + config: { locale?: { lang?: string } }, + systemLocale: string = Intl.DateTimeFormat().resolvedOptions().locale +): string { + return config.locale?.lang || systemLocale +} diff --git a/packages/mcp-server/src/types.ts b/packages/mcp-server/src/types.ts index a9f4e4c12..74f6b0848 100644 --- a/packages/mcp-server/src/types.ts +++ b/packages/mcp-server/src/types.ts @@ -9,8 +9,10 @@ import type { DatabaseAdapter } from '@openchatlab/core' * Both CLI's DatabaseManager and Desktop's helper can satisfy this contract. */ export interface McpDatabaseManager { - listSessionIds(): string[] - open(sessionId: string): DatabaseAdapter | null + /** List only MCP-supported chat sessions; reject any requiring an upgrade. */ + listSessionIdsMcpReadonly(): string[] + /** Open a non-cached, strictly read-only MCP connection after schema preflight; caller closes it. */ + openMcpReadonly(sessionId: string): DatabaseAdapter | null } export interface McpServerOptions { @@ -18,4 +20,6 @@ export interface McpServerOptions { /** MCP server name exposed to clients (default: 'chatlab') */ name?: string dbManager: McpDatabaseManager + /** Locale deciding whether tool descriptions are Chinese or English (default: English) */ + locale?: string } diff --git a/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts b/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts index 884e84c3d..dc8dfffaf 100644 --- a/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts +++ b/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts @@ -29,6 +29,18 @@ describe('normalizeOpenAICompatibleBaseUrl', () => { }) describe('buildPiModel', () => { + it('reserves output space for reasoning while preserving explicit limits', () => { + for (const config of [ + { provider: 'deepseek', model: 'deepseek-v4-flash' }, + { provider: 'openai', model: 'o3' }, + { provider: 'openai-compatible', model: 'qwen3:8b' }, + ]) { + assert.equal(buildPiModel(config).maxTokens, 16_384) + assert.equal(buildPiModel({ ...config, maxTokens: 4096 }).maxTokens, 4096) + } + assert.equal(buildPiModel({ provider: 'openai', model: 'gpt-4o' }).maxTokens, 4096) + }) + it('builds a Google Generative AI model', () => { const model = buildPiModel({ provider: 'gemini', @@ -109,7 +121,7 @@ describe('buildPiModel', () => { baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', }) assert.equal(model.reasoning, true) - assert.deepEqual(model.compat, { thinkingFormat: 'qwen' }) + assert.deepEqual(model.compat, { thinkingFormat: 'qwen', supportsDeveloperRole: false }) }) it('auto-infers reasoning=true and compat.thinkingFormat=qwen for custom self-hosted qwen3 model', () => { @@ -119,7 +131,7 @@ describe('buildPiModel', () => { { findModelFn: () => null } ) assert.equal(model.reasoning, true) - assert.deepEqual(model.compat, { thinkingFormat: 'qwen' }) + assert.deepEqual(model.compat, { thinkingFormat: 'qwen', supportsDeveloperRole: false }) }) it('auto-infers reasoning=true for custom model saved with only capabilities:["chat"]', () => { @@ -143,7 +155,7 @@ describe('buildPiModel', () => { } ) assert.equal(model.reasoning, true) - assert.deepEqual(model.compat, { thinkingFormat: 'qwen' }) + assert.deepEqual(model.compat, { thinkingFormat: 'qwen', supportsDeveloperRole: false }) }) it('auto-infers reasoning=false and compat=undefined for non-reasoning Anthropic model', () => { diff --git a/packages/node-runtime/src/ai/agent/__tests__/core.test.ts b/packages/node-runtime/src/ai/agent/__tests__/core.test.ts index 184900445..9284570a5 100644 --- a/packages/node-runtime/src/ai/agent/__tests__/core.test.ts +++ b/packages/node-runtime/src/ai/agent/__tests__/core.test.ts @@ -14,6 +14,7 @@ import { import { runAgentCore } from '../core' import type { AgentCoreEvent, AgentCoreOptions } from '../types' +import { countTokens } from '../../tokenizer' const model: Model<'openai-completions'> = { id: 'test-model', @@ -146,6 +147,156 @@ function createOptions(overrides: Partial = {}): AgentCoreOpti } describe('runAgentCore runtime contract', () => { + it('fits accumulated tool results into each request without changing the transcript', async () => { + const smallModel = { ...model, contextWindow: 8192, maxTokens: 2048 } + const text = '订单记录 order details 12345\n'.repeat(300) + const tool = createTool('lookup', async () => ({ content: [{ type: 'text', text }], details: null })) + const requests: Context[] = [] + const result = await runAgentCore( + createOptions({ + piModel: smallModel, + tools: [tool], + streamFn: createScriptedStream([ + ...[0, 1].map((round) => (context: Context) => { + requests.push(context) + return assistantMessage( + [0, 1, 2].map((i) => ({ + type: 'toolCall' as const, + id: `lookup-${round}-${i}`, + name: 'lookup', + arguments: {}, + })), + { stopReason: 'toolUse' } + ) + }), + (context) => { + requests.push(context) + return assistantMessage([{ type: 'text', text: 'Finished' }]) + }, + ]), + }) + ) + assert.equal(result.error, undefined) + assert.equal(requests.length, 3) + for (const context of requests) { + const input = countTokens( + JSON.stringify({ + systemPrompt: context.systemPrompt, + tools: context.tools, + messages: context.messages.map(({ role, content }) => ({ role, content })), + }) + ) + assert.ok(input + smallModel.maxTokens <= smallModel.contextWindow, `request input was ${input} tokens`) + const calls = context.messages.flatMap((m) => + m.role === 'assistant' ? m.content.filter((b) => b.type === 'toolCall').map((b) => b.id) : [] + ) + assert.deepEqual( + context.messages.flatMap((m) => (m.role === 'toolResult' ? [m.toolCallId] : [])), + calls + ) + } + const originalResults = result.finalMessages.filter((m) => m.role === 'toolResult') + assert.equal(originalResults.length, 6) + assert.ok(originalResults.every((m) => m.content[0].type === 'text' && m.content[0].text === text)) + assert.ok( + requests + .at(-1)! + .messages.some( + (m) => m.role === 'toolResult' && m.content.some((b) => b.type === 'text' && b.text.includes('订单记录')) + ) + ) + }) + + it('reports protected context overflow before sending a doomed provider request', async () => { + let providerCalls = 0 + const result = await runAgentCore( + createOptions({ + piModel: { ...model, contextWindow: 8192 }, + userMessage: '不可丢失的用户问题'.repeat(2000), + streamFn: createScriptedStream([ + () => { + providerCalls++ + return assistantMessage([{ type: 'text', text: 'Unexpected' }]) + }, + ]), + }) + ) + assert.equal(providerCalls, 0) + assert.match(result.error ?? '', /context/i) + }) + + it('keeps existing history that fits the window but exceeds the output reserve target', async () => { + const historyText = '重要聊天证据 '.repeat(600) + let captured: Context | undefined + let outputBudget = 0 + const result = await runAgentCore( + createOptions({ + piModel: { ...model, contextWindow: 8192, maxTokens: 2048 }, + history: [{ role: 'user', content: historyText }], + streamFn: createScriptedStream([ + (context, options) => { + captured = context + outputBudget = options?.maxTokens ?? 0 + return assistantMessage([{ type: 'text', text: 'Continued' }]) + }, + ]), + }) + ) + assert.equal(result.error, undefined) + const firstBlock = captured?.messages[0].content[0] + assert.ok(typeof firstBlock === 'object' && firstBlock.type === 'text') + assert.equal(firstBlock.text, historyText) + assert.equal(outputBudget, 2048, 'Preserve the configured output budget when the history and answer both fit') + }) + + for (const partialText of ['', 'The main conclusion is']) { + it(`reports output truncation without losing ${partialText ? 'partial text' : 'thinking-only output'}`, async () => { + const events: AgentCoreEvent[] = [] + const content: AssistantMessage['content'] = [{ type: 'thinking', thinking: 'Comparing the evidence' }] + if (partialText) content.push({ type: 'text', text: partialText }) + const result = await runAgentCore( + createOptions({ + locale: 'en-US', + streamFn: createScriptedStream([assistantMessage(content, { stopReason: 'length' })]), + onEvent: (event) => events.push(event), + }) + ) + + assert.match(result.error ?? '', /output limit/i) + assert.equal(result.stopReason, 'length') + assert.deepEqual(result.finalMessages.at(-1)?.content, content) + assert.deepEqual( + events.filter((event) => event.type === 'content').map((event) => event.content), + partialText ? [partialText] : [] + ) + assert.ok(events.some((event) => event.type === 'thinking_delta')) + }) + } + + it('stops on truncated tool arguments instead of spending more tokens on an automatic retry', async () => { + let toolExecuted = false + const result = await runAgentCore( + createOptions({ + tools: [ + createTool('lookup', async () => { + toolExecuted = true + return { content: [{ type: 'text', text: 'Should not run' }], details: {} } + }), + ], + locale: 'en-US', + streamFn: createScriptedStream([ + assistantMessage([{ type: 'toolCall', id: 'partial-call', name: 'lookup', arguments: {} }], { + stopReason: 'length', + }), + ]), + }) + ) + + assert.equal(toolExecuted, false) + assert.equal(result.stopReason, 'length') + assert.match(result.error ?? '', /output limit/i) + }) + it('streams thinking, content, and usage into ChatLab events', async () => { const events: AgentCoreEvent[] = [] const result = await runAgentCore( @@ -182,6 +333,8 @@ describe('runAgentCore runtime contract', () => { cacheReadTokens: 0, cacheWriteTokens: 0, }) + assert.equal(result.error, undefined) + assert.equal(result.stopReason, 'stop') }) it('executes a tool and continues to the final response', async () => { diff --git a/packages/node-runtime/src/ai/agent/core.ts b/packages/node-runtime/src/ai/agent/core.ts index 131a676d8..b15734dda 100644 --- a/packages/node-runtime/src/ai/agent/core.ts +++ b/packages/node-runtime/src/ai/agent/core.ts @@ -7,12 +7,19 @@ import { Agent as PiAgentCore } from '@earendil-works/pi-agent-core' import type { AgentEvent as PiAgentEvent, AgentMessage as PiAgentMessage } from '@earendil-works/pi-agent-core' -import { type Message as PiMessage, type Usage as PiUsage, clampThinkingLevel } from '@earendil-works/pi-ai' +import { + type Context as PiContext, + type AssistantMessage, + type Message as PiMessage, + type Usage as PiUsage, + clampThinkingLevel, +} from '@earendil-works/pi-ai' import { StreamingThinkTagParser, needsStreamingThinkParsing } from '@openchatlab/core' import type { ToolProgress } from '@openchatlab/shared-types' import type { AgentCoreOptions, AgentCoreResult, AgentTokenUsage } from './types' -import { initTokenizer } from '../tokenizer' +import { countTokens, initTokenizer } from '../tokenizer' +import { createAiTranslate } from '../i18n' import { streamSimple as defaultStreamSimple } from '../pi-runtime' import { DEFAULT_MAX_TOOL_ROUNDS } from './constants' import { toPiHistoryMessages, type ReplayOptions } from './history' @@ -21,6 +28,94 @@ function isPiMessage(message: PiAgentMessage): message is PiMessage { return message.role === 'user' || message.role === 'assistant' || message.role === 'toolResult' } +function fitToolResults(context: PiContext, contextWindow: number, maxTokens: number) { + // Match Pi's output safety margin. cl100k is an estimate for other providers; + // also cover Pi's character estimate so trimming does not starve the answer. + const safetyTokens = Math.min(4096, Math.floor(contextWindow / 2)) + const budget = contextWindow - safetyTokens - Math.min(maxTokens, Math.floor(contextWindow / 4)) + const estimate = (text: string) => { + // Bound tokenizer work for long unbroken tool output (e.g. CJK or encoded data). + let tokens = 0 + for (let i = 0; i < text.length; i += 256) tokens += countTokens(text.slice(i, i + 256)) + return Math.max(tokens, Math.ceil(text.length / 4)) + } + const messageTokens = (message: PiMessage) => + 8 + + estimate( + JSON.stringify({ + role: message.role, + content: message.content, + ...(message.role === 'toolResult' ? { toolCallId: message.toolCallId, toolName: message.toolName } : {}), + }) + ) + let tokens = + 8 + + estimate(context.systemPrompt ?? '') + + estimate( + JSON.stringify( + context.tools?.map(({ name, description, parameters }) => ({ name, description, parameters })) ?? [] + ) + ) + + context.messages.reduce((sum, message) => sum + messageTokens(message), 0) + if (tokens <= budget) return { context, maxTokens: Math.min(maxTokens, contextWindow - tokens - 16) } + + const messages = context.messages.slice() + const marker = '\n[Tool result truncated to fit the context window. Use a narrower query if needed.]' + // Retain recent evidence first; edit only the request view, never the transcript. + for (let i = 0; i < messages.length && tokens > budget; i++) { + const message = messages[i] + if (message.role !== 'toolResult') continue + const content = message.content.slice() + for (let j = 0; j < content.length && tokens > budget; j++) { + const block = content[j] + if (block.type !== 'text') continue + const before = messageTokens({ ...message, content }) + content[j] = { ...block, text: marker } + const after = messageTokens({ ...message, content }) + if (after >= before) { + content[j] = block + continue + } + if (tokens - before + after <= budget) { + let low = 0 + let high = block.text.length + while (low < high) { + const mid = Math.ceil((low + high) / 2) + content[j] = { ...block, text: block.text.slice(0, mid) + marker } + if (tokens - before + messageTokens({ ...message, content }) <= budget) low = mid + else high = mid - 1 + } + content[j] = { ...block, text: block.text.slice(0, low) + marker } + } + tokens += messageTokens({ ...message, content }) - before + } + messages[i] = { ...message, content } + } + // The reserve is a target, not a new hard limit on existing user/history text. + // OpenAI Responses enforces a minimum of 16 output tokens. + if (tokens + 16 > contextWindow) + throw new Error( + 'Agent context exceeds the model window after trimming tool results. Start a new conversation or shorten the request.' + ) + + // Provider usage described the untrimmed prefix. Invalidate it only in this + // request so Pi estimates the edited context instead of reusing stale totals. + return { + context: { + ...context, + messages: messages.map((message) => + message.role === 'assistant' + ? { + ...message, + usage: { ...message.usage, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }, + } + : message + ), + }, + maxTokens: Math.max(1, Math.min(maxTokens, contextWindow - tokens - 16)), + } +} + export async function runAgentCore(options: AgentCoreOptions): Promise { const { piModel, @@ -51,6 +146,7 @@ export async function runAgentCore(options: AgentCoreOptions): Promise { if (!usage) return @@ -62,7 +158,7 @@ export async function runAgentCore(options: AgentCoreOptions): Promise apiKey, - streamFn: resolvedStreamFn, + streamFn: (model, context, streamOptions) => { + const fitted = fitToolResults(context, model.contextWindow, model.maxTokens) + onConvertToLlm?.(fitted.context.messages) + return resolvedStreamFn(model, fitted.context, { ...streamOptions, maxTokens: fitted.maxTokens }) + }, convertToLlm: (messages) => { const filtered = messages.filter( (msg): msg is PiMessage => msg.role === 'user' || msg.role === 'assistant' || msg.role === 'toolResult' ) - onConvertToLlm?.(filtered) return filtered }, afterToolCall: async ({ result }) => (result as { isError?: boolean }).isError === true ? { isError: true } : undefined, - shouldStopAfterTurn: async () => hasCompletedFinalAnswerTurn, + // A cut-off tool call must not trigger an automatic paid retry either. + shouldStopAfterTurn: async ({ message }) => message.stopReason === 'length' || hasCompletedFinalAnswerTurn, prepareNextTurnWithContext: ({ context }) => hasReachedToolRoundLimit ? { @@ -238,6 +338,7 @@ export async function runAgentCore(options: AgentCoreOptions): Promise steerMessage?: string /** Override the thinking level for this request. Clamped to what the model supports. */ thinkingLevel?: ThinkingLevel @@ -68,6 +71,7 @@ export interface AgentCoreOptions { export interface AgentCoreResult { usage: AgentTokenUsage error?: string + stopReason?: StopReason finalMessages: Message[] toolsUsed: string[] toolRounds: number diff --git a/packages/node-runtime/src/ai/cross-chat-agent.ts b/packages/node-runtime/src/ai/cross-chat-agent.ts index e29b24e46..783c92026 100644 --- a/packages/node-runtime/src/ai/cross-chat-agent.ts +++ b/packages/node-runtime/src/ai/cross-chat-agent.ts @@ -175,6 +175,8 @@ export async function runCrossChatAgent(options: RunCrossChatAgentOptions): Prom maxToolRounds: DEFAULT_MAX_TOOL_ROUNDS, abortSignal, providerSessionId: aiChatId, + locale, + logger, steerMessage: createAiTranslate(locale)('ai.agent.answerWithoutTools'), thinkingLevel, onConvertToLlm: (messages) => { @@ -194,11 +196,18 @@ export async function runCrossChatAgent(options: RunCrossChatAgentOptions): Prom return } if (result.error) { - onEvent({ type: 'error', error: { name: 'AgentError', message: formatAIError(result.error) } }) + onEvent({ + type: 'error', + error: { + name: result.stopReason === 'length' ? 'OutputLimitError' : 'AgentError', + message: formatAIError(result.error), + }, + }) } - handler.emitStatus('completed', cachedMessages, { force: true }) - logger?.info('CrossChatAgent', 'Cross-chat agent execution completed', { + handler.emitStatus(result.error ? 'error' : 'completed', cachedMessages, { force: true }) + logger?.info('CrossChatAgent', 'Cross-chat agent execution ended', { aiChatId, + stopReason: result.stopReason, toolRounds: result.toolRounds, toolsUsed: result.toolsUsed.length, }) diff --git a/packages/node-runtime/src/ai/i18n/locales/en-US.ts b/packages/node-runtime/src/ai/i18n/locales/en-US.ts index 4ff4e44f8..eeca6597f 100644 --- a/packages/node-runtime/src/ai/i18n/locales/en-US.ts +++ b/packages/node-runtime/src/ai/i18n/locales/en-US.ts @@ -3,6 +3,12 @@ */ export default { ai: { + topics: { + reasoningLimitReached: + 'The model reached its output limit with only reasoning and no topic result. Use a different fast model or an endpoint that supports disabling thinking.', + outputLimitReached: + 'The topic response reached the model output limit and may be incomplete. Use a different fast model and retry.', + }, tools: { search_messages: { desc: 'Search chat records by keywords. This is the primary evidence tool for factual questions about keywords, member messages, or whether a topic appeared. Can specify time range and sender to filter messages. Supports minute-level time queries.', @@ -229,14 +235,18 @@ Returned summaries are brief descriptions of each segment, helping quickly locat response_time_analysis: { desc: 'Analyze response times between messages, showing median and average reply speed per member. Useful for "how quickly do people reply" or "who replies the fastest".', params: { - days: 'Number of recent days to analyze', + days: 'Recent days to analyze when no explicit or selected time range is available; defaults to 30', + start_time: 'Start time, format "YYYY-MM-DD HH:mm". Explicit dates override the selected range and days', + end_time: 'End time, format "YYYY-MM-DD HH:mm". Explicit dates override the selected range and days', top_n: 'Number of top members to return', }, }, keyword_frequency: { desc: 'Segment text messages and rank high-frequency keywords. Supports Chinese, English, and Japanese. Useful for "what do people talk about most" or "what are the hot keywords".', params: { - days: 'Number of recent days to analyze', + days: 'Recent days to analyze when no explicit or selected time range is available; defaults to 30', + start_time: 'Start time, format "YYYY-MM-DD HH:mm". Explicit dates override the selected range and days', + end_time: 'End time, format "YYYY-MM-DD HH:mm". Explicit dates override the selected range and days', top_n: 'Number of top keywords to return', }, }, @@ -259,6 +269,8 @@ Returned summaries are brief descriptions of each segment, helping quickly locat // ===== AI Agent system prompts ===== agent: { answerWithoutTools: 'Please answer based on the information already retrieved, do not call any more tools.', + outputLimitReached: + 'The response reached the model output limit (including reasoning) and is incomplete. Generated content has been kept. Send "continue" or narrow your question and try again.', toolError: 'Error: {{error}}', currentDateIs: 'Current date is', chatContext: { diff --git a/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts b/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts index f758029ac..0ab59ce78 100644 --- a/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts +++ b/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts @@ -3,6 +3,12 @@ */ export default { ai: { + topics: { + reasoningLimitReached: + 'モデルが出力上限に達しましたが、思考内容のみが返され、トピックの結果は生成されませんでした。高速モデルを変更するか、思考を無効にできるエンドポイントを使用してください。', + outputLimitReached: + 'トピックの応答がモデルの出力上限に達し、内容が不完全な可能性があります。高速モデルを変更して再試行してください。', + }, tools: { search_messages: { desc: 'キーワードでチャット履歴を検索する。キーワード、メンバーの発言、特定トピックが出たかどうかなどの事実質問に対する主要な証拠取得ツール。時間範囲や送信者でメッセージをフィルタリングできる。分単位の精度で時間クエリをサポートする。', @@ -229,14 +235,18 @@ export default { response_time_analysis: { desc: 'メッセージ間の応答時間を分析し、メンバーごとの中央値と平均返信速度を集計する。「みんなどのくらいで返信するか」「誰が最も速く返信するか」に適している。', params: { - days: '直近何日間のデータを集計するか', + days: '有効な明示日時や選択期間がない場合に集計する直近日数。デフォルトは30日', + start_time: '開始時刻。形式 "YYYY-MM-DD HH:mm"。明示日時は選択期間と days より優先される', + end_time: '終了時刻。形式 "YYYY-MM-DD HH:mm"。明示日時は選択期間と days より優先される', top_n: '上位何名を返却するか', }, }, keyword_frequency: { desc: '指定期間のテキストメッセージを分詞し、高頻度キーワードをランキングする。中国語・英語・日本語の分詞に対応。「みんなが最もよく話す話題は何か」「高頻度キーワードは何か」に適している。', params: { - days: '直近何日間のデータを集計するか', + days: '有効な明示日時や選択期間がない場合に集計する直近日数。デフォルトは30日', + start_time: '開始時刻。形式 "YYYY-MM-DD HH:mm"。明示日時は選択期間と days より優先される', + end_time: '終了時刻。形式 "YYYY-MM-DD HH:mm"。明示日時は選択期間と days より優先される', top_n: '上位何個のキーワードを返却するか', }, }, @@ -258,6 +268,8 @@ export default { agent: { answerWithoutTools: '取得済みの情報に基づいて回答してください。これ以上ツールを呼び出さないでください。', + outputLimitReached: + 'モデルの出力上限(思考過程を含む)に達したため、回答は未完了です。生成済みの内容は保持されています。「続けて」と送信するか、質問の範囲を絞って再試行してください。', toolError: 'エラー: {{error}}', currentDateIs: '現在の日付は', chatContext: { diff --git a/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts b/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts index d2394131d..7268ea041 100644 --- a/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts +++ b/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts @@ -3,6 +3,11 @@ */ export default { ai: { + topics: { + reasoningLimitReached: + '模型达到输出上限,但仅返回了思考内容,未生成话题结果。请更换快速模型,或使用支持关闭思考的接口。', + outputLimitReached: '话题结果达到模型输出上限,内容可能不完整。请更换快速模型后重试。', + }, tools: { search_messages: { desc: '根据关键词搜索聊天记录,是回答关键词、成员发言、是否出现过某话题等事实问题的主要证据工具。可以指定时间范围和发送者来筛选消息。支持精确到分钟级别的时间查询。', @@ -220,14 +225,18 @@ export default { response_time_analysis: { desc: '分析消息之间的响应时间,按成员维度统计中位数和平均回复速度。适用于"大家平均多久回复消息"、"谁回复最快"。', params: { - days: '统计最近多少天的数据', + days: '没有有效的显式时间或所选时间范围时,统计最近多少天的数据,默认 30', + start_time: '开始时间,格式 "YYYY-MM-DD HH:mm"。显式时间优先于所选范围和 days', + end_time: '结束时间,格式 "YYYY-MM-DD HH:mm"。显式时间优先于所选范围和 days', top_n: '返回前多少名', }, }, keyword_frequency: { desc: '对指定时间段的文本消息进行分词,统计高频关键词排行。支持中英日文分词。适用于"大家最常说什么"、"高频关键词是什么"。', params: { - days: '统计最近多少天的数据', + days: '没有有效的显式时间或所选时间范围时,统计最近多少天的数据,默认 30', + start_time: '开始时间,格式 "YYYY-MM-DD HH:mm"。显式时间优先于所选范围和 days', + end_time: '结束时间,格式 "YYYY-MM-DD HH:mm"。显式时间优先于所选范围和 days', top_n: '返回前多少个关键词', }, }, @@ -249,6 +258,8 @@ export default { agent: { answerWithoutTools: '请根据已获取的信息给出回答,不要再调用工具。', + outputLimitReached: + '本次回复达到模型输出上限(包含思考过程),回答未完成。已保留生成的内容,你可以发送“继续”或缩小问题范围后重试。', toolError: '错误: {{error}}', currentDateIs: '当前日期是', chatContext: { diff --git a/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts b/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts index dee8025bf..acf7139bc 100644 --- a/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts +++ b/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts @@ -3,6 +3,11 @@ */ export default { ai: { + topics: { + reasoningLimitReached: + '模型達到輸出上限,但僅回傳了思考內容,未產生話題結果。請更換快速模型,或使用支援關閉思考的介面。', + outputLimitReached: '話題結果達到模型輸出上限,內容可能不完整。請更換快速模型後重試。', + }, tools: { search_messages: { desc: '根據關鍵詞搜尋聊天紀錄,是回答關鍵詞、成員發言、是否出現過某話題等事實問題的主要證據工具。可以指定時間範圍和傳送者來篩選訊息。支援精確到分鐘級別的時間查詢。', @@ -220,14 +225,18 @@ export default { response_time_analysis: { desc: '分析訊息之間的回應時間,按成員維度統計中位數和平均回覆速度。適用於「大家平均多久回覆訊息」、「誰回覆最快」。', params: { - days: '統計最近多少天的資料', + days: '沒有有效的明確時間或所選時間範圍時,統計最近多少天的資料,預設 30', + start_time: '開始時間,格式 "YYYY-MM-DD HH:mm"。明確指定的時間優先於所選範圍和 days', + end_time: '結束時間,格式 "YYYY-MM-DD HH:mm"。明確指定的時間優先於所選範圍和 days', top_n: '回傳前多少名', }, }, keyword_frequency: { desc: '對指定時間段的文字訊息進行分詞,統計高頻關鍵詞排行。支援中英日文分詞。適用於「大家最常說什麼」、「高頻關鍵詞是什麼」。', params: { - days: '統計最近多少天的資料', + days: '沒有有效的明確時間或所選時間範圍時,統計最近多少天的資料,預設 30', + start_time: '開始時間,格式 "YYYY-MM-DD HH:mm"。明確指定的時間優先於所選範圍和 days', + end_time: '結束時間,格式 "YYYY-MM-DD HH:mm"。明確指定的時間優先於所選範圍和 days', top_n: '回傳前多少個關鍵詞', }, }, @@ -249,6 +258,8 @@ export default { agent: { answerWithoutTools: '請根據已取得的資訊給出回答,不要再呼叫工具。', + outputLimitReached: + '本次回覆達到模型輸出上限(包含思考過程),回答尚未完成。已保留產生的內容,你可以傳送「繼續」或縮小問題範圍後重試。', toolError: '錯誤: {{error}}', currentDateIs: '目前日期是', chatContext: { diff --git a/packages/node-runtime/src/ai/llm-builder.ts b/packages/node-runtime/src/ai/llm-builder.ts index 6e2fdd419..1e86aac81 100644 --- a/packages/node-runtime/src/ai/llm-builder.ts +++ b/packages/node-runtime/src/ai/llm-builder.ts @@ -156,6 +156,10 @@ export function buildPiModel(config: PiModelConfig, options?: BuildPiModelOption : baseUrl const { reasoning, compat, thinkingLevelMap } = inferReasoning(config.provider, modelId, modelDef) + // Qwen chat-completions endpoints reject developer messages, even for reasoning models. + // Override Pi's default for official and custom-hosted Qwen models without disabling thinking. + const requiresSystemRole = + apiFormat === 'openai-completions' && (config.provider.toLowerCase() === 'qwen' || /\bqwen|qwq/i.test(modelId)) return { id: modelId, @@ -169,7 +173,8 @@ export function buildPiModel(config: PiModelConfig, options?: BuildPiModelOption input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow, - maxTokens: config.maxTokens ?? 4096, - compat, + // Reasoning and visible text share the output budget on compatible APIs. + maxTokens: config.maxTokens ?? (reasoning ? 16_384 : 4096), + compat: requiresSystemRole ? { ...compat, supportsDeveloperRole: false } : compat, } } diff --git a/packages/node-runtime/src/data-dir-compat.test.ts b/packages/node-runtime/src/data-dir-compat.test.ts index 2ea98491b..09b952b4d 100644 --- a/packages/node-runtime/src/data-dir-compat.test.ts +++ b/packages/node-runtime/src/data-dir-compat.test.ts @@ -291,3 +291,63 @@ test('raising minRuntimeVersion records prerelease runtime by stable core versio version: '0.26.4', }) }) + +test('satisfied compatibility requirements preserve the original metadata across runtime upgrades', () => { + for (const minRuntimeVersion of ['0.25.1', '0.26.0']) { + const userDataDir = makeTempDir() + const provider = makePathProvider(userDataDir) + const existing = raiseDataDirMinRuntimeVersion(provider, { + minRuntimeVersion, + dataCompatibilityVersion: 2, + reason: 'segment-schema', + runtime: { version: '0.37.3', kind: 'cli' }, + module: 'chat-db-migration', + now: () => 1000, + }) + const metaPath = path.join(userDataDir, '.chatlab-meta.json') + const original = fs.readFileSync(metaPath, 'utf-8') + + const result = raiseDataDirMinRuntimeVersion(provider, { + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reason: 'segment-schema', + runtime: { version: '0.37.4', kind: 'desktop' }, + module: 'chat-db-migration', + now: () => 2000, + }) + + assert.deepEqual(result, existing) + assert.equal(fs.readFileSync(metaPath, 'utf-8'), original) + } +}) + +test('required compatibility updates propagate replacement failures and preserve the previous metadata', (t) => { + const userDataDir = makeTempDir() + const provider = makePathProvider(userDataDir) + const input = { + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reason: 'segment-schema', + runtime: { version: '0.37.4', kind: 'desktop' as const }, + module: 'chat-db-migration', + } + const existing = raiseDataDirMinRuntimeVersion(provider, input) + const metaPath = path.join(userDataDir, '.chatlab-meta.json') + const original = fs.readFileSync(metaPath, 'utf-8') + const denied = Object.assign(new Error('Metadata replacement denied'), { code: 'EPERM' }) + const renameSync = fs.renameSync + t.mock.method(fs, 'renameSync', (source: fs.PathLike, target: fs.PathLike) => { + if (target === metaPath) throw denied + renameSync(source, target) + }) + + for (const update of [{ minRuntimeVersion: '0.26.0' }, { dataCompatibilityVersion: 2 }, { reason: 'new-schema' }]) { + assert.throws( + () => raiseDataDirMinRuntimeVersion(provider, { ...input, ...update }), + (error) => error === denied + ) + assert.equal(fs.readFileSync(metaPath, 'utf-8'), original) + assert.deepEqual(readDataDirCompatibilityMeta(userDataDir), existing) + assert.deepEqual(fs.readdirSync(userDataDir), ['.chatlab-meta.json']) + } +}) diff --git a/packages/node-runtime/src/data-dir-compat.ts b/packages/node-runtime/src/data-dir-compat.ts index 809b12a60..85e886b17 100644 --- a/packages/node-runtime/src/data-dir-compat.ts +++ b/packages/node-runtime/src/data-dir-compat.ts @@ -36,7 +36,10 @@ export interface AssertDataDirCompatibilityOptions { env?: Pick } -type DataDirCompatibilityErrorCode = 'DATA_DIR_REQUIRES_NEWER_RUNTIME' | 'DATA_DIR_COMPATIBILITY_META_INVALID' +type DataDirCompatibilityErrorCode = + | 'DATA_DIR_REQUIRES_NEWER_RUNTIME' + | 'DATA_DIR_COMPATIBILITY_META_INVALID' + | 'DATA_DIR_COMPATIBILITY_REPAIR_REQUIRED' export class DataDirCompatibilityError extends Error { readonly code: DataDirCompatibilityErrorCode @@ -157,6 +160,17 @@ export function raiseDataDirMinRuntimeVersion( } const existing = readDataDirCompatibilityMeta(userDataDir) + // This is a migration marker, not a last-open timestamp. Replacing an already sufficient file + // can fail on Windows even when the metadata and chat databases are readable. + if ( + existing && + compareStableSemver(existing.minRuntimeVersion, input.minRuntimeVersion) >= 0 && + existing.dataCompatibilityVersion >= input.dataCompatibilityVersion && + existing.reasons.includes(input.reason) + ) { + return existing + } + const minRuntimeVersion = existing && compareStableSemver(existing.minRuntimeVersion, input.minRuntimeVersion) > 0 ? existing.minRuntimeVersion diff --git a/packages/node-runtime/src/database-manager.test.ts b/packages/node-runtime/src/database-manager.test.ts index 15ab6d832..4ba7c13d0 100644 --- a/packages/node-runtime/src/database-manager.test.ts +++ b/packages/node-runtime/src/database-manager.test.ts @@ -4,7 +4,7 @@ import os from 'node:os' import path from 'node:path' import test from 'node:test' import Database from 'better-sqlite3' -import type { PathProvider } from '@openchatlab/core' +import type { DatabaseAdapter, PathProvider } from '@openchatlab/core' import { CHAT_DB_SCHEMA, CURRENT_SCHEMA_VERSION, getSessionInfo } from '@openchatlab/core' import { DataDirCompatibilityError, readDataDirCompatibilityMeta } from './data-dir-compat' import { DatabaseManager, listDatabaseCandidateIds } from './database-manager' @@ -999,6 +999,56 @@ test('open repairs the data directory gate for existing current-schema databases assert.deepEqual(meta?.reasons, ['segment-schema']) }) +test('existing sessions remain readable after upgrades when compatible metadata cannot be replaced', (t) => { + const root = makeTempDir() + const provider = createPathProvider(root) + fs.mkdirSync(provider.getDatabaseDir(), { recursive: true }) + for (const id of ['first', 'second']) { + const db = new Database(path.join(provider.getDatabaseDir(), `${id}.db`), { nativeBinding }) + try { + db.exec(CHAT_DB_SCHEMA) + db.prepare('INSERT INTO meta (name, platform, type, imported_at) VALUES (?, ?, ?, ?)').run( + id, + 'qq', + 'group', + 1000 + ) + db.exec(` + INSERT INTO member (platform_id, account_name) VALUES ('u1', 'Test Member'); + INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'Saved message'); + `) + } finally { + db.close() + } + } + + const previousManager = new DatabaseManager(provider, { + nativeBinding, + runtime: { version: '0.37.3', kind: 'desktop' }, + }) + previousManager.raiseCurrentChatDbCompatibilityGate() + const metaPath = path.join(provider.getUserDataDir(), '.chatlab-meta.json') + const original = fs.readFileSync(metaPath, 'utf-8') + const renameSync = fs.renameSync + t.mock.method(fs, 'renameSync', (source: fs.PathLike, target: fs.PathLike) => { + if (target === metaPath) throw Object.assign(new Error('Metadata replacement denied'), { code: 'EPERM' }) + renameSync(source, target) + }) + + for (const kind of ['desktop', 'cli'] as const) { + const manager = new DatabaseManager(provider, { nativeBinding, runtime: { version: '0.37.4', kind } }) + t.after(() => manager.closeAll()) + assert.deepEqual(manager.listSessionIds().sort(), ['first', 'second']) + for (const id of manager.listSessionIds()) { + const db = manager.open(id) + assert.ok(db) + assert.equal(getSessionInfo(db)?.name, id) + assert.deepEqual(db.prepare('SELECT content FROM message').all(), [{ content: 'Saved message' }]) + } + assert.equal(fs.readFileSync(metaPath, 'utf-8'), original) + } +}) + test('open keeps a higher existing data directory runtime requirement after migration', () => { const root = makeTempDir() const dbDir = path.join(root, 'data', 'databases') @@ -1278,3 +1328,263 @@ test('session deletion rejects a live topic execution owned by another runtime', reopened.close() } }) + +test('read-only MCP schema preflight rejects all pending and future versions without touching database bytes', () => { + for (let version = 0; version <= CURRENT_SCHEMA_VERSION + 1; version++) { + const root = makeTempDir() + const provider = createPathProvider(root) + fs.mkdirSync(provider.getDatabaseDir(), { recursive: true }) + const dbPath = path.join(provider.getDatabaseDir(), 'schema-check.db') + const db = new Database(dbPath, { nativeBinding }) + try { + const versionColumn = version === 0 ? '' : ', schema_version INTEGER' + const versionValue = version === 0 ? '' : `, ${version}` + db.exec(` + CREATE TABLE meta (name TEXT, platform TEXT, type TEXT, imported_at INTEGER${versionColumn}); + INSERT INTO meta VALUES ('Schema Check', 'qq', 'group', 1000${versionValue}); + CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT); + CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT); + `) + } finally { + db.close() + } + + const manager = new DatabaseManager(provider, { + nativeBinding, + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + // Version 6+ requires an existing compatibility gate. In this test we + // establish it before read-only access so schema validation is isolated. + if (version >= 6) manager.raiseCurrentChatDbCompatibilityGate() + const beforeDb = fs.readFileSync(dbPath) + const markerPath = path.join(provider.getUserDataDir(), '.chatlab-meta.json') + const beforeMarker = fs.existsSync(markerPath) ? fs.readFileSync(markerPath) : null + + if (version === CURRENT_SCHEMA_VERSION) { + const opened = manager.openMcpReadonly('schema-check') + assert.ok(opened) + assert.equal(opened.readonly, true) + opened.close() + } else { + const guidance = version < CURRENT_SCHEMA_VERSION ? /migrate it first/ : /newer schema/ + assert.throws( + () => manager.openMcpReadonly('schema-check'), + (error: unknown) => + error instanceof Error && + error.message.includes(`schema v${version}`) && + error.message.includes(`schema v${CURRENT_SCHEMA_VERSION}`) && + guidance.test(error.message), + `unexpected schema v${version} should be rejected before query dispatch` + ) + } + + assert.deepEqual(fs.readFileSync(dbPath), beforeDb, `schema v${version} was modified`) + assert.deepEqual(fs.existsSync(markerPath) ? fs.readFileSync(markerPath) : null, beforeMarker) + manager.closeAll() + } +}) + +test('MCP reports future schemas accurately even if their compatibility marker is missing', () => { + const root = makeTempDir() + const provider = createPathProvider(root) + fs.mkdirSync(provider.getDatabaseDir(), { recursive: true }) + const dbPath = path.join(provider.getDatabaseDir(), 'future.db') + const db = new Database(dbPath, { nativeBinding }) + try { + db.exec(` + CREATE TABLE meta ( + name TEXT, platform TEXT, type TEXT, imported_at INTEGER, schema_version INTEGER + ); + INSERT INTO meta VALUES ('Future Chat', 'qq', 'group', 1000, ${CURRENT_SCHEMA_VERSION + 1}); + CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT); + CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT); + `) + } finally { + db.close() + } + const before = fs.readFileSync(dbPath) + const manager = new DatabaseManager(provider, { + nativeBinding, + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + assert.equal(readDataDirCompatibilityMeta(provider.getUserDataDir()), null) + + assert.throws( + () => manager.openMcpReadonly('future'), + (error: unknown) => + error instanceof Error && + /newer schema/i.test(error.message) && + /Upgrade the ChatLab runtime/i.test(error.message) && + !/compatibility marker/i.test(error.message) + ) + assert.throws(() => manager.listSessionIdsMcpReadonly(), /Upgrade the ChatLab runtime/i) + assert.deepEqual(fs.readFileSync(dbPath), before) + assert.equal(readDataDirCompatibilityMeta(provider.getUserDataDir()), null) + manager.closeAll() +}) + +test('MCP schema preflight rejects invalid SQLite version values without affecting shared reads', () => { + const cases: Array<{ label: string; sqlValue: string; sqlType: string }> = [ + { label: 'text', sqlValue: "'not-a-version'", sqlType: 'text' }, + { label: 'fractional', sqlValue: '10.5', sqlType: 'real' }, + { label: 'negative', sqlValue: '-1', sqlType: 'integer' }, + { label: 'unsafe integer', sqlValue: '9223372036854775807', sqlType: 'integer' }, + { label: 'blob', sqlValue: "X'616263'", sqlType: 'blob' }, + ] + for (const { label, sqlValue, sqlType } of cases) { + const root = makeTempDir() + const provider = createPathProvider(root) + fs.mkdirSync(provider.getDatabaseDir(), { recursive: true }) + const dbPath = path.join(provider.getDatabaseDir(), 'invalid-version.db') + const fixture = new Database(dbPath, { nativeBinding }) + try { + fixture.exec(` + CREATE TABLE meta ( + name TEXT, platform TEXT, type TEXT, imported_at INTEGER, + schema_version INTEGER + ); + INSERT INTO meta VALUES ('Legacy', 'qq', 'group', 1000, ${sqlValue}); + CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT, name TEXT, nickname TEXT); + CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT); + `) + const stored = fixture.prepare('SELECT typeof(schema_version) AS storage FROM meta').get() as { + storage: string + } + assert.equal(stored.storage, sqlType, label) + } finally { + fixture.close() + } + + const before = fs.readFileSync(dbPath) + const manager = new DatabaseManager(provider, { + nativeBinding, + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + try { + // CLI auto-import relies on shared read-only access to legacy candidates. + assert.deepEqual(manager.listSessionIdsReadonly(), ['invalid-version'], label) + const shared = manager.openReadonly('invalid-version') + assert.ok(shared) + shared.close() + + assert.throws(() => manager.openMcpReadonly('invalid-version'), /invalid schema version/i, label) + assert.throws(() => manager.listSessionIdsMcpReadonly(), /invalid schema version/i, label) + assert.deepEqual(fs.readFileSync(dbPath), before, `${label}: read must not alter the DB`) + assert.equal(readDataDirCompatibilityMeta(provider.getUserDataDir()), null, label) + } finally { + manager.closeAll() + fs.rmSync(root, { recursive: true, force: true }) + } + } +}) + +test('MCP live WAL reads use one committed SQLite snapshot across multiple SELECTs', () => { + const root = makeTempDir() + const provider = createPathProvider(root) + fs.mkdirSync(provider.getDatabaseDir(), { recursive: true }) + const dbPath = path.join(provider.getDatabaseDir(), 'concurrent.db') + const writer = new Database(dbPath, { nativeBinding }) + const manager = new DatabaseManager(provider, { + nativeBinding, + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + try { + writer.pragma('journal_mode=WAL') + writer.exec(CHAT_DB_SCHEMA) + writer.exec(` + INSERT INTO meta(name, platform, type, imported_at) VALUES('Concurrent', 'qq', 'group', 1000); + INSERT INTO member(platform_id, account_name) VALUES('p1', 'Alice'); + INSERT INTO message(sender_id, ts, type, content) VALUES(1, 1000, 0, 'committed earlier'); + `) + manager.raiseCurrentChatDbCompatibilityGate() + const markerPath = path.join(provider.getUserDataDir(), '.chatlab-meta.json') + const beforeMarker = fs.readFileSync(markerPath) + const count = (db: DatabaseAdapter) => (db.prepare('SELECT count(*) as n FROM message').get() as { n: number }).n + const first = manager.openMcpReadonly('concurrent') + assert.ok(first) + try { + assert.equal(first.readonly, true) + assert.equal(count(first), 1) + writer.exec("INSERT INTO message(sender_id, ts, type, content) VALUES(1, 1001, 0, 'committed later')") + // The version check and both queries must see the same transaction view. + assert.equal(count(first), 1) + const second = manager.openMcpReadonly('concurrent') + assert.ok(second) + try { + assert.equal(count(second), 2) + } finally { + second.close() + } + assert.equal(count(first), 1) + } finally { + first.close() + } + assert.equal((writer.pragma('wal_checkpoint(TRUNCATE)') as Array<{ busy: number }>)[0].busy, 0) + assert.deepEqual(fs.readFileSync(markerPath), beforeMarker) + } finally { + manager.closeAll() + writer.close() + } +}) + +test('MCP does not return uncommitted changes from a rollback-journal writer', () => { + const root = makeTempDir() + const provider = createPathProvider(root) + fs.mkdirSync(provider.getDatabaseDir(), { recursive: true }) + const dbPath = path.join(provider.getDatabaseDir(), 'rollback.db') + const writer = new Database(dbPath, { nativeBinding }) + const manager = new DatabaseManager(provider, { + nativeBinding, + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + try { + writer.exec(CHAT_DB_SCHEMA) + writer.exec(` + INSERT INTO meta(name, platform, type, imported_at) VALUES('Rollback', 'qq', 'group', 1000); + INSERT INTO member(platform_id, account_name) VALUES('p1', 'Alice'); + `) + writer.pragma('journal_mode=DELETE') + writer.pragma('cache_size=10') + writer.pragma('cache_spill=ON') + const insert = writer.prepare('INSERT INTO message(sender_id, ts, type, content) VALUES(1, ?, 0, ?)') + writer.transaction(() => { + for (let i = 0; i < 90; i++) insert.run(i, 'committed '.padEnd(1600, 'a')) + })() + manager.raiseCurrentChatDbCompatibilityGate() + writer.exec('BEGIN IMMEDIATE') + try { + writer.exec("UPDATE message SET content = 'UNCOMMITTED ' || content") + assert.equal(fs.existsSync(dbPath + '-journal'), true) + try { + const reader = manager.openMcpReadonly('rollback') + if (reader) { + try { + const uncommitted = reader + .prepare("SELECT count(*) AS n FROM message WHERE content LIKE 'UNCOMMITTED%'") + .get() as { n: number } + assert.equal(uncommitted.n, 0, 'a live MCP reader must never expose uncommitted rollback pages') + } finally { + reader.close() + } + } + } catch (error) { + assert.match(String(error), /BUSY|locked/i, 'only a SQLite lock error is acceptable') + } + } finally { + writer.exec('ROLLBACK') + } + const clean = manager.openMcpReadonly('rollback') + assert.ok(clean) + try { + assert.equal( + (clean.prepare("SELECT count(*) AS n FROM message WHERE content LIKE 'UNCOMMITTED%'").get() as { n: number }).n, + 0 + ) + } finally { + clean.close() + } + } finally { + manager.closeAll() + writer.close() + } +}) diff --git a/packages/node-runtime/src/database-manager.ts b/packages/node-runtime/src/database-manager.ts index 3117db73f..85c57a2cf 100644 --- a/packages/node-runtime/src/database-manager.ts +++ b/packages/node-runtime/src/database-manager.ts @@ -19,7 +19,13 @@ import { } from '@openchatlab/core' import { openBetterSqliteDatabase } from './better-sqlite3-adapter' import { deleteSessionCache } from './cache/session-cache' -import { assertDataDirCompatible, type RuntimeIdentity } from './data-dir-compat' +import { + assertDataDirCompatible, + DataDirCompatibilityError, + isRuntimeVersionAtLeast, + readDataDirCompatibilityMeta, + type RuntimeIdentity, +} from './data-dir-compat' import { CHAT_DB_COMPATIBILITY_RAISES, getChatDbMigrations, raiseChatDbCompatibilityGate } from './migrations' import { getContactsFactsCacheDir } from './services/contacts/paths' import { @@ -58,6 +64,14 @@ export function listDatabaseCandidateIds(databaseDir: string): string[] { .map((fileName) => fileName.slice(0, -3)) } +/** A non-ChatLab SQLite file may be skipped during discovery, not opened directly. */ +class NotChatLabSessionError extends Error { + constructor(sessionId: string) { + super(`Not a ChatLab session database: ${sessionId}`) + this.name = 'NotChatLabSessionError' + } +} + export class DatabaseManager { private cache = new Map() private nativeBinding?: string @@ -126,6 +140,109 @@ export class DatabaseManager { } } + /** + * Open an uncached session database strictly for reading, without migrations. + * This shared API is also used by CLI import analysis: do not require the + * current schema here. The caller owns and must close the connection. + */ + openReadonly(sessionId: string): DatabaseAdapter | null { + this.assertCompatible() + if (!fs.existsSync(this.getDbPath(sessionId))) return null + return this.openRawSessionDatabase(sessionId, { readonly: true }) + } + + /** + * Validate MCP session paths without tightening the shared CLI/desktop APIs. + * Reject out-of-scope files, directory traversal, symlinks and non-files. + */ + private resolveMcpSessionPath(sessionId: string): string | null { + if ( + !sessionId || + sessionId === '.' || + sessionId === '..' || + /[\\/:]/.test(sessionId) || + [...sessionId].some((character) => character.charCodeAt(0) < 32) || + /[. ]$/.test(sessionId) || + path.isAbsolute(sessionId) || + path.win32.isAbsolute(sessionId) + ) { + throw new Error(`Invalid MCP session ID: ${JSON.stringify(sessionId)}`) + } + + const databaseDir = this.pathProvider.getDatabaseDir() + const dbPath = path.join(databaseDir, `${sessionId}.db`) + let entry: fs.Stats + try { + entry = fs.lstatSync(dbPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error + } + if (!entry.isFile() || entry.isSymbolicLink()) { + throw new Error(`Invalid MCP session database path: ${sessionId}`) + } + + // A configured database root may itself be a symlink. Resolve both sides + // and verify the real target is the expected direct child of that root. + const root = fs.realpathSync(databaseDir) + const actual = fs.realpathSync(dbPath) + if (path.relative(root, actual) !== `${sessionId}.db`) { + throw new Error(`MCP session database is outside the configured directory: ${sessionId}`) + } + return dbPath + } + + /** + * MCP-only live SQLite read. No migrations or marker updates, and no + * full-file snapshot: the native read transaction isolates committed data. + * Normal WAL coordination may create/update -wal or -shm sidecars. + */ + openMcpReadonly(sessionId: string): DatabaseAdapter | null { + this.assertCompatible() + const dbPath = this.resolveMcpSessionPath(sessionId) + if (!dbPath) return null + const db = openBetterSqliteDatabase(dbPath, { readonly: true, nativeBinding: this.nativeBinding }) + + try { + // BEGIN is deferred; the first schema query fixes the SQLite read view. + // Closing this one-request connection releases the read transaction. + db.exec('BEGIN') + if (!isChatSessionDb(db)) { + throw new NotChatLabSessionError(sessionId) + } + const schemaVersion = getSchemaVersion(db) + // SQLite INTEGER-affinity columns may actually contain TEXT, REAL or + // BLOB values. A type assertion cannot protect numeric comparisons + // from coercing invalid values (e.g. nonnumeric text to NaN). + if (!Number.isSafeInteger(schemaVersion) || schemaVersion < 0) { + throw new Error( + `ChatLab session ${sessionId} has an invalid schema version. ` + + 'Cannot safely serve this database through read-only MCP tools.' + ) + } + // The runtime cannot understand a future schema or safely repair its + // compatibility metadata; report the version mismatch before the gate. + if (schemaVersion > CURRENT_SCHEMA_VERSION) { + throw new Error( + `ChatLab session ${sessionId} uses schema v${schemaVersion}, but read-only MCP tools require ` + + `schema v${CURRENT_SCHEMA_VERSION}. Upgrade the ChatLab runtime to one that supports this newer schema.` + ) + } + + this.assertReadonlyCompatibilityGate(schemaVersion) + if (schemaVersion < CURRENT_SCHEMA_VERSION) { + throw new Error( + `ChatLab session ${sessionId} uses schema v${schemaVersion}, but read-only MCP tools require ` + + `schema v${CURRENT_SCHEMA_VERSION}. Open the session with the current writable ChatLab runtime to migrate it first.` + ) + } + return db + } catch (error) { + db.close() + throw error + } + } + /** * Open a session DB in read-write mode and auto-run pending migrations. * Falls back to `open(id, { readonly: false })` if migration is unnecessary. @@ -204,13 +321,15 @@ export class DatabaseManager { /** * 只读列举聊天会话 ID,不触发数据库迁移或兼容门禁提升。 + * CLI 导入预分析需要枚举未升级的旧库,因此不可强制当前 Schema。 */ listSessionIdsReadonly(): string[] { this.assertCompatible() const dbDir = this.pathProvider.getDatabaseDir() return listDatabaseCandidateIds(dbDir).filter((sessionId) => { - const db = this.openRawSessionDatabase(sessionId, { readonly: true }) + const db = this.openReadonly(sessionId) + if (!db) return false try { return isChatSessionDb(db) } finally { @@ -219,6 +338,29 @@ export class DatabaseManager { }) } + /** MCP session discovery performs the same strict schema preflight as MCP queries. */ + listSessionIdsMcpReadonly(): string[] { + this.assertCompatible() + + return listDatabaseCandidateIds(this.pathProvider.getDatabaseDir()).filter((sessionId) => { + let db: DatabaseAdapter | null + try { + db = this.openMcpReadonly(sessionId) + } catch (error) { + // Discovery may ignore proven non-ChatLab databases, but must not + // silently swallow damaged databases or schema/gate/permission errors. + if (error instanceof NotChatLabSessionError) return false + throw error + } + if (!db) return false + try { + return true // openMcpReadonly has already validated its type and schema. + } finally { + db.close() + } + }) + } + getUserDataDir(): string { return this.pathProvider.getUserDataDir() } @@ -296,6 +438,38 @@ export class DatabaseManager { raiseChatDbCompatibilityGate(this.pathProvider, this.runtime) } + /** + * A read-only MCP request cannot repair missing compatibility metadata. Reject + * affected schemas until a writable runtime has safely restored the gate. + */ + private assertReadonlyCompatibilityGate(schemaVersion: number): void { + if (!this.runtime || !shouldRepairCompatibilityGate(schemaVersion)) return + + const userDataDir = this.pathProvider.getUserDataDir() + const meta = readDataDirCompatibilityMeta(userDataDir) + const missingGate = CHAT_DB_COMPATIBILITY_RAISES.find( + ({ migrationVersion, minRuntimeVersion, dataCompatibilityVersion, reason }) => + schemaVersion >= migrationVersion && + (!meta || + !isRuntimeVersionAtLeast(meta.minRuntimeVersion, minRuntimeVersion) || + meta.dataCompatibilityVersion < dataCompatibilityVersion || + !meta.reasons.includes(reason)) + ) + if (!missingGate) return + + throw new DataDirCompatibilityError( + 'DATA_DIR_COMPATIBILITY_REPAIR_REQUIRED', + `ChatLab session schema v${schemaVersion} requires an updated data-directory compatibility marker. ` + + 'Open the data directory with the current writable ChatLab runtime to repair it before using read-only MCP tools.', + { + userDataDir, + metaPath: path.join(userDataDir, '.chatlab-meta.json'), + currentVersion: this.runtime.version, + minRuntimeVersion: missingGate.minRuntimeVersion, + } + ) + } + private assertCompatible(): void { if (!this.runtime) return assertDataDirCompatible(this.pathProvider, this.runtime) diff --git a/packages/node-runtime/src/import/auto-import-matcher.test.ts b/packages/node-runtime/src/import/auto-import-matcher.test.ts index f547f70f4..a46589de3 100644 --- a/packages/node-runtime/src/import/auto-import-matcher.test.ts +++ b/packages/node-runtime/src/import/auto-import-matcher.test.ts @@ -4,6 +4,7 @@ import os from 'node:os' import path from 'node:path' import test from 'node:test' import { CHAT_DB_SCHEMA } from '@openchatlab/core' +import { parseFileSync } from '@openchatlab/parser' import { MessageType, type ParsedMessage } from '@openchatlab/shared-types' import { openBetterSqliteDatabase } from '../better-sqlite3-adapter' import { resolveAutoImportTarget, resolveAutoImportTargetPlan, type AutoImportMatcherDeps } from './auto-import-matcher' @@ -186,6 +187,69 @@ test('matches a unique group session by stable group id', async (t) => { ) }) +test('reimports short Discord DMs and appends non-overlapping exports to the same session', async (t) => { + const tempDir = makeTempDir() + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + const sourcePath = path.join(tempDir, 'discord.json') + const writeExport = (id: number) => + fs.writeFileSync( + sourcePath, + JSON.stringify({ + guild: { id: '0', name: 'Direct Messages' }, + channel: { id: 'dm-channel-1', type: 'DirectTextChat', name: 'Alice' }, + messages: [ + { + id: String(id), + type: 'Default', + timestamp: `2026-01-01T00:00:0${id}Z`, + content: `Message ${id}`, + author: { id: 'alice', name: 'Alice', discriminator: '0000', isBot: false, roles: [] }, + attachments: [], + embeds: [], + stickers: [], + }, + ], + }), + 'utf8' + ) + + writeExport(1) + const parsed = await parseFileSync(sourcePath) + const dbPath = path.join(tempDir, 'existing.db') + seedSession(dbPath, parsed.meta, parsed.members, parsed.messages) + + for (const id of [1, 2]) { + writeExport(id) + const result = await autoImportFile(sourcePath, { + ...createDeps(tempDir, ['existing']), + sessionExists: (sessionId) => sessionId === 'existing', + createSession: async () => { + throw new Error('must not create a duplicate Discord DM') + }, + appendSession: (sessionId, filePath) => + incrementalImport(sessionId, filePath, { + openDatabase: (_sessionId, readonly = false) => openBetterSqliteDatabase(dbPath, { readonly, nativeBinding }), + onProgress: () => {}, + }), + }) + assert.equal(result.success, true) + assert.equal(result.sessionId, 'existing') + assert.equal(result.importMode, 'incremental') + assert.equal(result.matchedBy, 'stable-id') + assert.equal(result.newMessageCount, id === 1 ? 0 : 1) + assert.equal(result.duplicateCount, id === 1 ? 1 : 0) + } + const db = openBetterSqliteDatabase(dbPath, { readonly: true, nativeBinding }) + try { + assert.deepEqual(db.prepare('SELECT platform_message_id FROM message ORDER BY ts').all(), [ + { platform_message_id: '1' }, + { platform_message_id: '2' }, + ]) + } finally { + db.close() + } +}) + test('matches a unique private session by stable owner and participant ids', async (t) => { const tempDir = makeTempDir() t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) diff --git a/packages/node-runtime/src/logging/app-logger.ts b/packages/node-runtime/src/logging/app-logger.ts index b6893ae68..99627cf0a 100644 --- a/packages/node-runtime/src/logging/app-logger.ts +++ b/packages/node-runtime/src/logging/app-logger.ts @@ -10,7 +10,7 @@ import * as fs from 'fs' import * as path from 'path' -import { extractErrorInfo, extractErrorStack } from '../ai/ai-logger' +import { extractErrorInfo } from '../ai/ai-logger' type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' @@ -92,7 +92,7 @@ function formatData(data: unknown): string { if (data === undefined) return '' if (data instanceof Error) { const info = extractErrorInfo(data) - const stack = extractErrorStack(data) + const stack = data.stack return JSON.stringify(info) + (stack ? `\n${stack}` : '') } if (typeof data === 'string') return data diff --git a/packages/node-runtime/src/nlp/word-frequency.test.ts b/packages/node-runtime/src/nlp/word-frequency.test.ts index 081416d72..bec9821d4 100644 --- a/packages/node-runtime/src/nlp/word-frequency.test.ts +++ b/packages/node-runtime/src/nlp/word-frequency.test.ts @@ -112,4 +112,46 @@ describe('computeWordFrequency excludeKeywords pushdown', () => { }) assert.equal(wildcard.totalMessages, 3) }) + + it('excludes explicit system notifications while keeping emoji messages eligible', () => { + const insert = raw.prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, ?, ?)') + insert.run(4, 1, 4000, 0, '[系统] 你赞了对方分享的 systemnoise') + insert.run(5, 1, 5000, 0, '[系统] 你赞了对方分享的 systemnoise') + insert.run(6, 1, 6000, 5, '[强壮]') + insert.run(7, 1, 7000, 0, '[分享内容] share_noise') + insert.run(8, 1, 8000, 0, '[分享内容] share_noise') + + const result = computeWordFrequency(db, { + sessionId: 's1', + locale: 'en-US', + minCount: 1, + posFilterMode: 'all', + enableStopwords: false, + }) + + const words = result.words.map((word) => word.word) + assert.ok(!words.includes('systemnoise')) + assert.ok(!words.includes('share_noise')) + assert.equal(result.totalMessages, 4) + }) + + it('treats Douyin emoji codes as emoji instead of vocabulary', () => { + const insert = raw.prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, ?, ?)') + insert.run(4, 1, 4000, 0, '[kisskiss]') + insert.run(5, 1, 5000, 5, '[V5]') + + const result = computeWordFrequency(db, { + sessionId: 's1', + locale: 'en-US', + minCount: 1, + posFilterMode: 'all', + enableStopwords: false, + }) + + const words = result.words.map((word) => word.word) + assert.ok(!words.includes('kisskiss')) + assert.ok(!words.includes('v5')) + // Emoji messages stay eligible for analysis; they only contribute no words. + assert.equal(result.totalMessages, 5) + }) }) diff --git a/packages/node-runtime/src/nlp/word-frequency.ts b/packages/node-runtime/src/nlp/word-frequency.ts index 054487739..5c2421a4a 100644 --- a/packages/node-runtime/src/nlp/word-frequency.ts +++ b/packages/node-runtime/src/nlp/word-frequency.ts @@ -5,7 +5,8 @@ * 供 Electron worker 和 Server HTTP 路由共用。 */ -import { buildExcludeKeywordsConditions, type DatabaseAdapter } from '@openchatlab/core' +import { buildExcludeKeywordsConditions, isSystemMessageContent, type DatabaseAdapter } from '@openchatlab/core' +import { MessageType } from '@openchatlab/shared-types' import type { SupportedLocale, DictType, @@ -44,7 +45,7 @@ function buildMessageQuery( } conditions.push("COALESCE(m.account_name, '') != '系统消息'") - conditions.push('msg.type = 0') + conditions.push(`msg.type IN (${MessageType.TEXT}, ${MessageType.EMOJI})`) conditions.push('msg.content IS NOT NULL') conditions.push("TRIM(msg.content) != ''") @@ -83,7 +84,12 @@ export function computeWordFrequency(db: DatabaseAdapter, params: WordFrequencyP return { words: [], totalWords: 0, totalMessages: 0, uniqueWords: 0 } } - const texts = messages.map((m) => m.content) + const analyzableMessages = messages.filter((message) => !isSystemMessageContent(message.content)) + if (analyzableMessages.length === 0) { + return { words: [], totalWords: 0, totalMessages: 0, uniqueWords: 0 } + } + + const texts = analyzableMessages.map((m) => m.content) const segmentOptions = { minLength: minWordLength, @@ -124,7 +130,7 @@ export function computeWordFrequency(db: DatabaseAdapter, params: WordFrequencyP return { words, totalWords: result.totalWords, - totalMessages: messages.length, + totalMessages: analyzableMessages.length, uniqueWords: result.uniqueWords, posTagStats, } diff --git a/packages/node-runtime/src/semantic-index/embedding/local.ts b/packages/node-runtime/src/semantic-index/embedding/local.ts index f20195053..8e6f47426 100644 --- a/packages/node-runtime/src/semantic-index/embedding/local.ts +++ b/packages/node-runtime/src/semantic-index/embedding/local.ts @@ -52,9 +52,16 @@ export async function createProxyFetch(proxyUrl: string): Promise } const { fetch: undiciFetch, ProxyAgent } = await import('undici') const dispatcher = new ProxyAgent(proxyUrl) - return ((input, init) => { + return (async (input, init) => { const nextInit: UndiciRequestInit = { ...(init as UndiciRequestInit | undefined), dispatcher } - return undiciFetch(input as Parameters[0], nextInit) as unknown as ReturnType + const response = await undiciFetch(input as Parameters[0], nextInit) + // Transformers.js only caches model files for `instanceof Response`; undici's own Response + // class fails that check, so re-wrap the stream in a global Response. + return new Response(response.body as ReadableStream | null, { + status: response.status, + statusText: response.statusText, + headers: response.headers as unknown as HeadersInit, + }) }) as typeof fetch } diff --git a/packages/node-runtime/src/semantic-index/embedding/providers.test.ts b/packages/node-runtime/src/semantic-index/embedding/providers.test.ts index 8866beb25..e870038c8 100644 --- a/packages/node-runtime/src/semantic-index/embedding/providers.test.ts +++ b/packages/node-runtime/src/semantic-index/embedding/providers.test.ts @@ -1,4 +1,6 @@ import assert from 'node:assert/strict' +import http from 'node:http' +import net, { type AddressInfo } from 'node:net' import test from 'node:test' import { createProxyFetch, @@ -100,6 +102,42 @@ test('local model download proxy rejects SOCKS URLs explicitly', async () => { await assert.rejects(() => createProxyFetch('socks5://127.0.0.1:1080'), /SOCKS proxy is not supported/) }) +test('local model download proxy returns a global Response through the proxy', async (t) => { + const target = http.createServer((_req, res) => res.end('model-bytes')) + let proxied = 0 + const proxy = http.createServer() + proxy.on('connect', (req, socket, head) => { + proxied += 1 + const [host, port] = (req.url ?? '').split(':') + const upstream = net.connect(Number(port), host, () => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + upstream.write(head) + upstream.pipe(socket) + socket.pipe(upstream) + }) + }) + const listen = (server: http.Server) => + new Promise((resolve) => + server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port)) + ) + const targetPort = await listen(target) + const proxyPort = await listen(proxy) + t.after(() => { + target.closeAllConnections() + proxy.closeAllConnections() + target.close() + proxy.close() + }) + + const proxyFetch = await createProxyFetch(`http://127.0.0.1:${proxyPort}`) + const response = await proxyFetch(`http://127.0.0.1:${targetPort}/model.onnx`) + + // Transformers.js caches model files only for `instanceof Response`; anything else fails to load + assert.ok(response instanceof globalThis.Response) + assert.equal(await response.text(), 'model-bytes') + assert.equal(proxied, 1) +}) + test('local model download source resolves to the selected remote host', () => { assert.equal(resolveModelDownloadRemoteHost('huggingface'), 'https://huggingface.co/') assert.equal(resolveModelDownloadRemoteHost('hf-mirror'), 'https://hf-mirror.com/') diff --git a/packages/node-runtime/src/services/analytics.test.ts b/packages/node-runtime/src/services/analytics.test.ts index fb4450e2b..dfb0b694a 100644 --- a/packages/node-runtime/src/services/analytics.test.ts +++ b/packages/node-runtime/src/services/analytics.test.ts @@ -144,6 +144,44 @@ test('track normalizes the shared locale instead of accepting arbitrary event da } }) +test('insight telemetry accepts only public tab categories and never forwards chat identifiers', async (t) => { + const systemDir = createTempSystemDir() + t.after(() => rmSync(systemDir, { recursive: true, force: true })) + const bodies: Array<{ payload: { name: string; url: string; data: Record } }> = [] + t.mock.method(globalThis, 'fetch', async (_input: unknown, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body))) + return new Response(null, { status: 204 }) + }) + const service = new AnalyticsService(systemDir, createOptions()) + assert.equal(await service.track('insight_viewed', { chat_type: 'group' }), true) + assert.equal( + await service.track('insight_tab_used', { + chat_type: 'private', + tab_id: 'journey', + session_id: 'secret-chat', + name: 'Alice', + text: 'private message', + }), + true + ) + assert.equal(bodies[0].payload.url, '/insights/group') + assert.equal(bodies[1].payload.url, '/insights/private/journey') + assert.equal(bodies[1].payload.data.chat_type, 'private') + assert.equal(bodies[1].payload.data.tab_id, 'journey') + assert.equal(JSON.stringify(bodies).includes('secret-chat'), false) + assert.equal('name' in bodies[1].payload.data, false) + assert.equal('text' in bodies[1].payload.data, false) + for (const props of [ + { chat_type: 'group', tab_id: 'journey' }, + { chat_type: 'group', tab_id: '/private/path' }, + { chat_type: 'secret-chat', tab_id: 'overview' }, + { chat_type: 'private' }, + ]) + assert.equal(await service.track('insight_tab_used', props), false) + assert.equal(await service.track('insight_viewed', { chat_type: 'secret-chat' }), false) + assert.equal(bodies.length, 2) +}) + test('anonymous id persists across AnalyticsService instances', async () => { const systemDir = createTempSystemDir() const originalFetch = globalThis.fetch diff --git a/packages/node-runtime/src/services/analytics.ts b/packages/node-runtime/src/services/analytics.ts index fe7314f1a..68baf6ff7 100644 --- a/packages/node-runtime/src/services/analytics.ts +++ b/packages/node-runtime/src/services/analytics.ts @@ -52,6 +52,8 @@ const EVENT_NAMES = new Set([ 'chat_import_failed', 'incremental_import_used', 'feature_used', + 'insight_viewed', + 'insight_tab_used', 'ai_setup_completed', 'ai_request_started', 'ai_request_completed', @@ -79,6 +81,19 @@ const FAILURE_REASONS = new Set([ 'unknown', ]) +const INSIGHT_TABS = { + group: new Set(['overview', 'type-analysis', 'time-analysis', 'topic', 'relationship']), + private: new Set([ + 'overview', + 'type-analysis', + 'time-analysis', + 'topic', + 'relationship', + 'journey', + 'language-preference', + ]), +} + const APP_LOCALES = new Set(['zh-CN', 'zh-TW', 'en-US', 'ja-JP']) function getUmamiConfigFromEnv(): AnalyticsServiceOptions['umami'] | undefined { @@ -135,6 +150,15 @@ function normalizeEvent(eventName: string, props: Record = {}): properties.feature_id = props.feature_id } + if (name === 'insight_viewed' || name === 'insight_tab_used') { + if (props.chat_type !== 'group' && props.chat_type !== 'private') return null + properties.chat_type = props.chat_type + if (name === 'insight_tab_used') { + if (typeof props.tab_id !== 'string' || !INSIGHT_TABS[props.chat_type].has(props.tab_id)) return null + properties.tab_id = props.tab_id + } + } + if (name === 'ai_request_completed') { if (typeof props.success !== 'boolean') return null properties.success = props.success @@ -149,6 +173,8 @@ function normalizeEvent(eventName: string, props: Record = {}): } function eventPath(event: NormalizedEvent): string { + if (event.name === 'insight_viewed') return `/insights/${event.properties.chat_type}` + if (event.name === 'insight_tab_used') return `/insights/${event.properties.chat_type}/${event.properties.tab_id}` if (event.name === 'incremental_import_used') return '/import/incremental' if (event.name.startsWith('chat_import_')) return '/import' if (event.name.startsWith('ai_')) return '/ai' diff --git a/packages/node-runtime/src/services/topics/model-client.test.ts b/packages/node-runtime/src/services/topics/model-client.test.ts index 9e7bc81f5..e1e84abaa 100644 --- a/packages/node-runtime/src/services/topics/model-client.test.ts +++ b/packages/node-runtime/src/services/topics/model-client.test.ts @@ -1,8 +1,160 @@ import assert from 'node:assert/strict' import test from 'node:test' +import type { AssistantMessage } from '@earendil-works/pi-ai' import type { AIServiceConfig } from '../../ai' +import { completeSimple } from '../../ai/pi-runtime' import { createChatTopicModelClient } from './model-client' +const anthropicConfig: AIServiceConfig = { + id: 'synthetic', + name: 'Synthetic', + provider: 'anthropic', + apiFormat: 'anthropic-messages', + model: 'qwen3.8-flash', + baseUrl: 'https://test.cn-beijing.maas.aliyuncs.com/apps/anthropic', + apiKey: 'test-key', + createdAt: 1, + updatedAt: 1, +} + +// Prevent provider-default thinking from consuming the topic result budget, without changing other APIs/models. +for (const { config, expectedThinking } of [ + { config: anthropicConfig, expectedThinking: { type: 'disabled' } }, + { + config: { ...anthropicConfig, provider: 'openai-compatible', baseUrl: 'https://example.invalid/anthropic/v1' }, + expectedThinking: { type: 'disabled' }, + }, + { config: { ...anthropicConfig, model: 'claude-sonnet-4-5' }, expectedThinking: undefined }, + { config: { ...anthropicConfig, apiFormat: 'openai-completions' as const }, expectedThinking: undefined }, +]) { + test(`topic thinking payload for ${config.provider}/${config.apiFormat}/${config.model}`, async () => { + let request: Record | undefined + const client = createChatTopicModelClient(config, { + completeSimple: (model, context, options) => + completeSimple(model, context, { + ...options, + onPayload: async (payload, model) => { + request = ((await options?.onPayload?.(payload, model)) ?? payload) as Record + throw new Error('Captured topic request before network') + }, + }), + }) + await assert.rejects( + () => + client.complete( + { systemPrompt: 'Return JSON only.', userPrompt: 'Synthetic messages' }, + { signal: new AbortController().signal, sessionId: 'synthetic' } + ), + /Captured topic request before network/ + ) + assert.ok(request) + assert.deepEqual(request.thinking, expectedThinking) + if (config.apiFormat === 'anthropic-messages') assert.equal(request.max_tokens, 4096) + }) +} + +// Truncated output must remain distinguishable from invalid JSON, while retaining billable usage. +for (const { content, stopReason, expectedLimit } of [ + { + content: [{ type: 'thinking', thinking: 'Synthetic reasoning' }], + stopReason: 'length', + expectedLimit: 'reasoning', + }, + { content: [{ type: 'text', text: '{"operations":' }], stopReason: 'length', expectedLimit: 'text' }, + { + content: [{ type: 'text', text: '{"operations":[],"assignments":[]}' }], + stopReason: 'stop', + expectedLimit: undefined, + }, +] satisfies Array<{ + content: AssistantMessage['content'] + stopReason: AssistantMessage['stopReason'] + expectedLimit?: string +}>) { + test(`topic response preserves ${stopReason}/${expectedLimit ?? 'complete'} and usage`, async () => { + const client = createChatTopicModelClient(anthropicConfig, { + completeSimple: async (model) => ({ + role: 'assistant', + content, + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 120, + output: 4096, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 4216, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason, + timestamp: Date.now(), + }), + }) + const result = await client.complete( + { systemPrompt: 'Return JSON only.', userPrompt: 'Synthetic messages' }, + { signal: new AbortController().signal, sessionId: 'synthetic' } + ) + assert.equal(result.outputLimit, expectedLimit) + assert.equal(result.inputTokens, 120) + assert.equal(result.outputTokens, 4096) + assert.equal(result.text, content[0].type === 'text' ? content[0].text : '') + }) +} + +// Prevent topic requests from failing with HTTP 400 on Qwen endpoints that reject the developer role. +for (const { provider, model, expectedRole } of [ + { provider: 'qwen', model: 'qwen3.8', expectedRole: 'system' }, + { provider: 'openai-compatible', model: 'qwen3.8', expectedRole: 'system' }, + { provider: 'openai-compatible', model: 'Qwen/QwQ-32B', expectedRole: 'system' }, + { provider: 'openai', model: 'o3', expectedRole: 'developer' }, +]) { + test(`topic requests preserve the supported prompt role for ${provider}/${model}`, async () => { + let messages: Array<{ role: string; content: unknown }> | undefined + let reasoning: boolean | undefined + const client = createChatTopicModelClient( + { + id: 'synthetic', + name: 'Synthetic', + provider, + model, + baseUrl: 'https://example.invalid/v1', + apiKey: 'test-key', + createdAt: 1, + updatedAt: 1, + }, + { + completeSimple: (model, context, options) => { + reasoning = model.reasoning + return completeSimple(model, context, { + ...options, + onPayload: async (payload, model) => { + const normalized = (await options?.onPayload?.(payload, model)) ?? payload + messages = (normalized as { messages: typeof messages }).messages + // Capture the real SDK serialization, then stop before any network request. + throw new Error('Captured topic request before network') + }, + }) + }, + } + ) + await assert.rejects( + () => + client.complete( + { systemPrompt: 'Synthetic system prompt', userPrompt: 'Synthetic user prompt' }, + { signal: new AbortController().signal, sessionId: 'synthetic' } + ), + /Captured topic request before network/ + ) + assert.equal(reasoning, true) + assert.deepEqual( + messages?.map((message) => message.role), + [expectedRole, 'user'] + ) + assert.equal(messages?.[0].content, 'Synthetic system prompt') + }) +} + test('topic model calls disable reasoning and constrain the DeepSeek payload', async () => { let capturedOptions: Parameters[2] let normalizedPayload: unknown diff --git a/packages/node-runtime/src/services/topics/model-client.ts b/packages/node-runtime/src/services/topics/model-client.ts index 0e0f8c17f..c709e264f 100644 --- a/packages/node-runtime/src/services/topics/model-client.ts +++ b/packages/node-runtime/src/services/topics/model-client.ts @@ -11,6 +11,7 @@ export interface ChatTopicModelResult { text: string inputTokens: number outputTokens: number + outputLimit?: 'reasoning' | 'text' } export interface ChatTopicModelClient { @@ -57,20 +58,35 @@ export function createChatTopicModelClient( if (result.stopReason === 'error' || result.stopReason === 'aborted') { throw new Error(result.errorMessage || `Topic model request ${result.stopReason}`) } + const text = result.content + .filter((item): item is PiTextContent => item.type === 'text') + .map((item) => item.text) + .join('') + const outputLimit: ChatTopicModelResult['outputLimit'] = + result.stopReason === 'length' + ? !text.trim() && result.content.some((item) => item.type === 'thinking') + ? 'reasoning' + : 'text' + : undefined return { - text: result.content - .filter((item): item is PiTextContent => item.type === 'text') - .map((item) => item.text) - .join(''), + text, inputTokens: result.usage.input, outputTokens: result.usage.output, + ...(outputLimit ? { outputLimit } : {}), } }, } } -function normalizeTopicModelPayload(payload: unknown, model: { provider: string; baseUrl: string }): unknown { - if (!isRecord(payload) || !isDeepSeekEndpoint(model)) return undefined +function normalizeTopicModelPayload(payload: unknown, model: ReturnType): unknown { + if (!isRecord(payload)) return undefined + + // Qwen's Anthropic-compatible endpoint may enable thinking when the field is omitted. + // Topic extraction needs JSON, not reasoning; keep this override out of interactive AI requests. + if (model.api === 'anthropic-messages' && /\bqwen/i.test(model.id)) { + return { ...payload, thinking: { type: 'disabled' } } + } + if (!isDeepSeekEndpoint(model)) return undefined // The DeepSeek chat-completions endpoint ignored max_completion_tokens in a real topic run and emitted more than // 15k output tokens. Keep this compatibility correction local to the topic runtime until the SDK fixes detection. diff --git a/packages/node-runtime/src/services/topics/service.test.ts b/packages/node-runtime/src/services/topics/service.test.ts index ca8f7dac2..c66467a1c 100644 --- a/packages/node-runtime/src/services/topics/service.test.ts +++ b/packages/node-runtime/src/services/topics/service.test.ts @@ -390,6 +390,45 @@ test('generation does not resume a checkpoint created in another timezone', asyn } }) +// Do not retry a truncated response as malformed JSON or replace an existing successful topic snapshot. +for (const outputLimit of ['reasoning', 'text'] as const) { + test(`truncated ${outputLimit} fails clearly, records usage, and preserves existing topics`, async () => { + let truncate = false + const modelClient: ChatTopicModelClient = { + modelId: 'test/model', + async complete(prompts) { + if (!truncate) return successfulTopicResult(prompts) + return { + text: outputLimit === 'reasoning' ? '' : '{"operations":[],"assignments":[]}', + inputTokens: 120, + outputTokens: 4096, + outputLimit, + } + }, + } + const { service, manager } = createHarness(modelClient, 1) + try { + const first = service.generateDay('group', '2026-08-09', 'Asia/Shanghai', 'en-US') + await waitForRun(service, 'group', first.id, 'completed') + const originalDay = service.getDay('group', '2026-08-09', 'Asia/Shanghai') + assert.ok(originalDay?.topics.length) + + truncate = true + const next = service.generateDay('group', '2026-08-09', 'Asia/Shanghai', 'en-US') + const failed = await waitForRun(service, 'group', next.id, 'failed') + assert.match(failed.lastError ?? '', /output limit/i) + if (outputLimit === 'reasoning') assert.match(failed.lastError ?? '', /reasoning/i) + assert.equal(failed.modelCalls, 1) + assert.equal(failed.inputTokens, 120) + assert.equal(failed.outputTokens, 4096) + assert.deepEqual(service.getDay('group', '2026-08-09', 'Asia/Shanghai'), originalDay) + } finally { + service.close() + manager.closeAll() + } + }) +} + test('a paused model request resumes from the persisted day checkpoint', async () => { let calls = 0 const modelClient: ChatTopicModelClient = { diff --git a/packages/node-runtime/src/services/topics/service.ts b/packages/node-runtime/src/services/topics/service.ts index 147aff54c..ab9fec4b5 100644 --- a/packages/node-runtime/src/services/topics/service.ts +++ b/packages/node-runtime/src/services/topics/service.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto' import type { PathProvider } from '@openchatlab/core' import type { ChatTopicDay, ChatTopicPreflight, ChatTopicRun, CreateChatTopicsRequest } from '@openchatlab/shared-types' import { appLogger } from '../../logging/app-logger' +import { createAiTranslate } from '../../ai/i18n' import { isRuntimeVersionAtLeast, raiseDataDirMinRuntimeVersion, type RuntimeIdentity } from '../../data-dir-compat' import type { SessionRuntimeAdapter } from '../adapters' import { @@ -542,6 +543,12 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer } const response = await modelClient.complete(attemptPrompts, { signal, sessionId }) run = recordModelUsage(run, response) + if (response.outputLimit) { + const t = createAiTranslate(run.locale ?? undefined) + throw new Error( + t(response.outputLimit === 'reasoning' ? 'ai.topics.reasoningLimitReached' : 'ai.topics.outputLimitReached') + ) + } try { return { run, value: validate(response.text) } } catch (error) { diff --git a/packages/parser/src/browser/whatsapp.test.ts b/packages/parser/src/browser/whatsapp.test.ts index bccc70ebb..55a3be567 100644 --- a/packages/parser/src/browser/whatsapp.test.ts +++ b/packages/parser/src/browser/whatsapp.test.ts @@ -98,4 +98,30 @@ describe('WhatsApp browser-safe text parser', () => { ] ) }) + + it('switches the date order only when the preferred month cannot be a month', async () => { + const content = [ + '12/25/2024, 10:30 AM - Alice: month-first date with a long year', + '13/01/24, 10:30 - Bob: day above 12 with a short year', + '25/12/24, 10:30 - Alice: day-first date with a short year', + '02/01/2024, 10:30 - Alice: day-first date with a long year', + '01/02/24, 10:30 - Bob: month-first date with a short year', + '2024/01/02 10:30 - Alice: year-first date', + '', + ].join('\n') + + const result = await parseWhatsAppText(content, 'WhatsApp locale fixture.txt') + + assert.deepEqual( + result.messages.map((message) => message.timestamp), + [ + localTs('2024-12-25T10:30:00'), + localTs('2024-01-13T10:30:00'), + localTs('2024-12-25T10:30:00'), + localTs('2024-01-02T10:30:00'), + localTs('2024-01-02T10:30:00'), + localTs('2024-01-02T10:30:00'), + ] + ) + }) }) diff --git a/packages/parser/src/browser/whatsapp.ts b/packages/parser/src/browser/whatsapp.ts index 26ed6d9df..261330865 100644 --- a/packages/parser/src/browser/whatsapp.ts +++ b/packages/parser/src/browser/whatsapp.ts @@ -332,13 +332,25 @@ function buildTimestamp( month = secondDatePart day = thirdDatePart } else if (thirdDatePart > 31) { - day = firstDatePart - month = secondDatePart year = thirdDatePart + // A second part above 12 cannot be a month, so the date must be month-first. + if (secondDatePart > 12) { + month = firstDatePart + day = secondDatePart + } else { + day = firstDatePart + month = secondDatePart + } } else { - month = firstDatePart - day = secondDatePart year = 2000 + thirdDatePart + // A first part above 12 cannot be a month, so the date must be day-first. + if (firstDatePart > 12) { + day = firstDatePart + month = secondDatePart + } else { + month = firstDatePart + day = secondDatePart + } } let hour = rawHour diff --git a/packages/parser/src/formats/chatlab-jsonl.test.ts b/packages/parser/src/formats/chatlab-jsonl.test.ts index 2dfb4d9ca..366b7dc14 100644 --- a/packages/parser/src/formats/chatlab-jsonl.test.ts +++ b/packages/parser/src/formats/chatlab-jsonl.test.ts @@ -84,6 +84,45 @@ test('ChatLab JSONL emits message batches and progress while reading', async () } }) +test('preserves a duration-bearing voice transcription in the parsed message content', async () => { + const root = makeTempDir() + const filePath = path.join(root, 'voice-transcription.jsonl') + const transcription = '[语音 3秒] 这是测试语音内容。' + fs.writeFileSync( + filePath, + [ + JSON.stringify({ + _type: 'header', + chatlab: { version: '0.0.2', exportedAt: 1711468800 }, + meta: { name: 'Voice transcription', platform: 'douyin', type: 'private' }, + }), + JSON.stringify({ _type: 'member', platformId: 'member-1', accountName: 'Alice' }), + JSON.stringify({ + _type: 'message', + sender: 'member-1', + accountName: 'Alice', + timestamp: 1711468800, + type: MessageType.TEXT, + content: transcription, + platformMessageId: 'voice-1', + }), + ].join('\n') + '\n', + 'utf-8' + ) + + try { + const messages: Array<{ type: number; content: string | null }> = [] + for await (const event of parseFileWithFormat('chatlab-jsonl', { filePath })) { + if (event.type === 'messages') { + messages.push(...event.data.map(({ type, content }) => ({ type, content }))) + } + } + assert.deepEqual(messages, [{ type: MessageType.TEXT, content: transcription }]) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + test('directory entry detection accepts a top-level ChatLab JSONL file', () => { const root = makeTempDir() const filePath = path.join(root, 'chat.jsonl') diff --git a/packages/parser/src/formats/tyrrrz-discord-exporter.test.ts b/packages/parser/src/formats/tyrrrz-discord-exporter.test.ts new file mode 100644 index 000000000..2edf2f8d1 --- /dev/null +++ b/packages/parser/src/formats/tyrrrz-discord-exporter.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { ChatType } from '@openchatlab/shared-types' + +import { detectFormat, parseFileSync } from '../index' + +const cases = [ + { channelType: 'DirectTextChat', guildName: 'Direct Messages', expectedType: ChatType.PRIVATE }, + { channelType: 'DirectGroupTextChat', guildName: 'Direct Messages', expectedType: ChatType.GROUP }, + { channelType: 'GuildTextChat', guildName: 'Example server', expectedType: ChatType.GROUP }, + { channelType: undefined, guildName: 'Direct Messages', expectedType: ChatType.GROUP }, +] + +for (const { channelType, guildName, expectedType } of cases) { + test(`Discord export classifies ${channelType ?? 'missing channel type'} without losing messages`, async (t) => { + const directory = mkdtempSync(join(tmpdir(), 'chatlab-discord-parser-')) + t.after(() => rmSync(directory, { recursive: true, force: true })) + const filePath = join(directory, 'discord.json') + writeFileSync( + filePath, + JSON.stringify({ + guild: { + id: guildName === 'Direct Messages' ? '0' : 'server-1', + name: guildName, + iconUrl: 'https://example.com/icon.png', + }, + channel: { id: 'channel-1', type: channelType, categoryId: null, category: null, name: 'Alice', topic: null }, + messages: ['Alice', 'Bob'].map((name, index) => ({ + id: String(index + 1), + type: 'Default', + timestamp: `2026-01-01T00:00:0${index}Z`, + content: `Hello from ${name}`, + author: { id: name, name, discriminator: '0000', isBot: false, roles: [] }, + attachments: [], + embeds: [], + stickers: [], + })), + }), + 'utf8' + ) + + assert.equal(detectFormat(filePath)?.id, 'discord-tyrrrz') + const result = await parseFileSync(filePath) + assert.equal(result.meta.type, expectedType) + assert.equal(result.meta.platform, 'discord') + const isPrivate = expectedType === ChatType.PRIVATE + assert.equal(result.meta.name, isPrivate ? 'Alice' : `${guildName} - Alice`) + assert.equal(result.meta.groupId, 'channel-1') + assert.equal(result.meta.groupAvatar, isPrivate ? undefined : 'https://example.com/icon.png') + assert.deepEqual( + result.members.map((member) => member.platformId), + ['Alice', 'Bob'] + ) + assert.deepEqual( + result.messages.map((message) => message.content), + ['Hello from Alice', 'Hello from Bob'] + ) + }) +} diff --git a/packages/parser/src/formats/tyrrrz-discord-exporter.ts b/packages/parser/src/formats/tyrrrz-discord-exporter.ts index 9fb9f21e9..049b6ec58 100644 --- a/packages/parser/src/formats/tyrrrz-discord-exporter.ts +++ b/packages/parser/src/formats/tyrrrz-discord-exporter.ts @@ -299,19 +299,21 @@ async function* parseDiscordExporter(options: ParseOptions): AsyncGenerator() diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 278e47416..ca99a6735 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -1420,7 +1420,7 @@ export interface Preferences { } export interface UiConfig { - default_session_tab: 'insights' | 'ai-chat' + default_session_tab: 'insights' | 'ai-chat' | 'memory' session_gap_threshold: number summary_strategy?: 'brief' | 'standard' } @@ -1440,6 +1440,8 @@ export type AnalyticsEventName = | 'chat_import_failed' | 'incremental_import_used' | 'feature_used' + | 'insight_viewed' + | 'insight_tab_used' | 'ai_setup_completed' | 'ai_request_started' | 'ai_request_completed' diff --git a/packages/sync/src/data-source-manager.test.ts b/packages/sync/src/data-source-manager.test.ts new file mode 100644 index 000000000..138769ff1 --- /dev/null +++ b/packages/sync/src/data-source-manager.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test, { type TestContext } from 'node:test' +import { DataSourceManager } from './data-source-manager' + +function createFixture(t: TestContext) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-data-source-config-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + return { root, file: path.join(root, 'data-sources.json'), manager: new DataSourceManager(root) } +} + +const sourceInput = { name: 'Test source', baseUrl: 'https://example.test', token: 'test-token', intervalMinutes: 60 } + +test('missing config can be created and existing subscriptions survive updates and reloads', (t) => { + const { root, file, manager } = createFixture(t) + assert.deepEqual(manager.loadAll(), []) + const source = manager.add(sourceInput) + const [session] = manager.addSessions(source.id, [{ name: 'Chat', remoteSessionId: 'remote-1' }]) + manager.updateSession(source.id, session.id, { targetSessionId: 'local-1', lastPullAt: 123 }) + manager.update(source.id, { name: 'Renamed source' }) + + const reloaded = new DataSourceManager(root).get(source.id) + assert.equal(reloaded?.name, 'Renamed source') + assert.equal(reloaded?.token, sourceInput.token) + assert.equal(reloaded?.sessions[0].targetSessionId, 'local-1') + assert.equal(reloaded?.sessions[0].lastPullAt, 123) + assert.deepEqual(fs.readdirSync(root), [path.basename(file)]) + + // Older configurations without pullLimit still use the established default. + const legacy = JSON.parse(fs.readFileSync(file, 'utf-8')) + delete legacy[0].pullLimit + fs.writeFileSync(file, JSON.stringify(legacy)) + assert.equal(manager.get(source.id)?.pullLimit, 1000) +}) + +test('corrupt or incompatible config is reported and cannot be replaced by CRUD operations', (t) => { + const { file, manager } = createFixture(t) + for (const contents of ['', '[', '{}', '[{"sessions":null}]']) { + fs.writeFileSync(file, contents) + const operations = [ + () => manager.loadAll(), + () => manager.add(sourceInput), + () => manager.update('source', { name: 'Changed' }), + () => manager.delete('source'), + () => manager.addSessions('source', []), + () => manager.removeSession('source', 'session'), + () => manager.updateSession('source', 'session', { lastPullAt: 456 }), + ] + for (const operation of operations) { + assert.throws(operation) + assert.equal(fs.readFileSync(file, 'utf-8'), contents) + } + } +}) + +test('read failures are not treated as missing config', (t) => { + const { file, manager } = createFixture(t) + const source = manager.add(sourceInput) + const original = fs.readFileSync(file, 'utf-8') + const readFileSync = fs.readFileSync + const readMock = t.mock.method(fs, 'readFileSync', (...args: Parameters) => { + if (args[0] === file) throw Object.assign(new Error('Permission denied'), { code: 'EACCES' }) + return readFileSync(...args) + }) + assert.throws(() => manager.loadAll()) + assert.throws(() => manager.add(sourceInput)) + readMock.mock.restore() + assert.equal(fs.readFileSync(file, 'utf-8'), original) + assert.equal(manager.get(source.id)?.token, sourceInput.token) +}) + +for (const failure of ['partial-write', 'rename'] as const) { + test(`${failure} failure preserves config, reports failure and cleans the temporary file`, (t) => { + const { root, file, manager } = createFixture(t) + const source = manager.add(sourceInput) + const original = fs.readFileSync(file, 'utf-8') + let restore: () => void + if (failure === 'partial-write') { + const writeFileSync = fs.writeFileSync + const fault = t.mock.method(fs, 'writeFileSync', (...args: Parameters) => { + if (typeof args[0] === 'string' && path.dirname(args[0]) === root) { + writeFileSync(args[0], '[{"id":') + throw Object.assign(new Error('No space left on device'), { code: 'ENOSPC' }) + } + return writeFileSync(...args) + }) + restore = () => fault.mock.restore() + } else { + const renameSync = fs.renameSync + const fault = t.mock.method(fs, 'renameSync', (from: fs.PathLike, to: fs.PathLike) => { + if (to === file) throw Object.assign(new Error('Replacement denied'), { code: 'EPERM' }) + return renameSync(from, to) + }) + restore = () => fault.mock.restore() + } + + assert.throws(() => manager.update(source.id, { name: 'Must not be saved' })) + assert.equal(fs.readFileSync(file, 'utf-8'), original) + assert.deepEqual(fs.readdirSync(root), [path.basename(file)]) + restore() + manager.update(source.id, { name: 'Recovered' }) + assert.equal(new DataSourceManager(root).get(source.id)?.name, 'Recovered') + }) +} diff --git a/packages/sync/src/data-source-manager.ts b/packages/sync/src/data-source-manager.ts index 915d808ad..21a15f370 100644 --- a/packages/sync/src/data-source-manager.ts +++ b/packages/sync/src/data-source-manager.ts @@ -7,7 +7,7 @@ * DataSource (remote server) → ImportSession[] (subscribed conversations) */ -import * as fs from 'fs' +import fs from 'node:fs' import * as path from 'path' import * as crypto from 'crypto' import { NOOP_LOGGER } from './types' @@ -15,6 +15,10 @@ import type { DataSource, DataSourceUpdatable, ImportSession, SyncLogger } from const CONFIG_FILE = 'data-sources.json' +export class DataSourceConfigError extends Error { + override name = 'DataSourceConfigError' +} + function generateId(prefix: string = 'ds'): string { return `${prefix}_${crypto.randomBytes(6).toString('hex')}` } @@ -55,34 +59,48 @@ export class DataSourceManager { // ==================== Load / Save ==================== loadAll(): DataSource[] { + const filePath = this.getConfigPath() try { - const filePath = this.getConfigPath() - if (fs.existsSync(filePath)) { - const raw = fs.readFileSync(filePath, 'utf-8') - const parsed = JSON.parse(raw) - - if (!isValidDataSourceArray(parsed)) { - this.logger.warn('[DataSource] Incompatible config format detected, returning empty.') - return [] - } - - for (const ds of parsed) { - if (!ds.pullLimit) ds.pullLimit = 1000 - } - return parsed + const raw = fs.readFileSync(filePath, 'utf-8') + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + // Native JSON errors can include configuration fragments containing access tokens. + throw new Error('Invalid JSON. Restore or repair the file before making changes.') + } + if (!isValidDataSourceArray(parsed)) { + throw new Error('Invalid data source config format. Restore or repair the file before making changes.') } + for (const ds of parsed) { + if (!ds.pullLimit) ds.pullLimit = 1000 + } + return parsed } catch (err) { - this.logger.error('[DataSource] Failed to load config', err) + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] + const error = new DataSourceConfigError(`Failed to load data source config: ${filePath}`, { cause: err }) + this.logger.error('[DataSource] Failed to load config', error) + throw error } - return [] } private saveAll(sources: DataSource[]): void { + const filePath = this.getConfigPath() + const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}` try { this.ensureDir() - fs.writeFileSync(this.getConfigPath(), JSON.stringify(sources, null, 2), 'utf-8') + fs.writeFileSync(tempPath, JSON.stringify(sources, null, 2), { encoding: 'utf-8', mode: 0o600 }) + fs.renameSync(tempPath, filePath) } catch (err) { - this.logger.error('[DataSource] Failed to save config', err) + const error = new DataSourceConfigError(`Failed to save data source config: ${filePath}`, { cause: err }) + this.logger.error('[DataSource] Failed to save config', error) + throw error + } finally { + try { + fs.rmSync(tempPath, { force: true }) + } catch (err) { + this.logger.error('[DataSource] Failed to remove temporary config', err) + } } } diff --git a/packages/sync/src/pull-engine.test.ts b/packages/sync/src/pull-engine.test.ts index 5cdab3216..43563f2be 100644 --- a/packages/sync/src/pull-engine.test.ts +++ b/packages/sync/src/pull-engine.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, it } from 'node:test' import assert from 'node:assert/strict' -import * as fs from 'fs' +import fs from 'node:fs' import * as os from 'os' import * as path from 'path' import { PullEngine } from './pull-engine' +import { DataSourceManager, DataSourceConfigError } from './data-source-manager' import type { DataSource, DataImporter, @@ -148,6 +149,45 @@ function createEngine(options: { } describe('PullEngine', () => { + it('config write failure reports failure without advancing the cursor or leaving progress running', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-pull-config-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const manager = new DataSourceManager(root) + const source = manager.add({ baseUrl: 'https://example.test', token: '', intervalMinutes: 1 }) + const [session] = manager.addSessions(source.id, [{ name: 'Chat', remoteSessionId: 'remote-1' }]) + manager.updateSession(source.id, session.id, { lastPullAt: 100, targetSessionId: 'local-1' }) + const file = path.join(root, 'data-sources.json') + const original = fs.readFileSync(file, 'utf-8') + const emptyPage = writeTempJson({}) + const results: string[] = [] + const engine = new PullEngine({ + dsManager: manager, + fetcher: { fetchToTempFile: async () => emptyPage }, + importer: { sessionExists: () => true, importFile: async () => ({ success: true, newMessageCount: 0 }) }, + notifier: { + onSessionListChanged: () => {}, + onPullResult: (_sourceId, _sessionId, status) => results.push(status), + }, + }) + const writeFileSync = fs.writeFileSync + let failNextWrite = true + t.mock.method(fs, 'writeFileSync', (...args: Parameters) => { + if (failNextWrite && typeof args[0] === 'string' && path.dirname(args[0]) === root) { + failNextWrite = false + throw Object.assign(new Error('No space left on device'), { code: 'ENOSPC' }) + } + return writeFileSync(...args) + }) + + await withImmediateTimers(async () => { + await assert.rejects(engine.triggerPull(source.id, session.id), DataSourceConfigError) + assert.deepEqual(engine.getProgress(), []) + }) + assert.deepEqual(results, ['error']) + assert.equal(fs.readFileSync(file, 'utf-8'), original) + assert.equal(manager.get(source.id)?.sessions[0].lastPullAt, 100) + }) + it('imports a small final page instead of treating it as empty', async () => { const session = createSession() const dataSource = createDataSource() diff --git a/packages/sync/src/pull-engine.ts b/packages/sync/src/pull-engine.ts index ad6463120..cfeabbe48 100644 --- a/packages/sync/src/pull-engine.ts +++ b/packages/sync/src/pull-engine.ts @@ -21,7 +21,7 @@ import type { PullSessionResult, PullProgress, } from './types' -import type { DataSourceManager } from './data-source-manager' +import { DataSourceConfigError, type DataSourceManager } from './data-source-manager' const MAX_PAGES_PER_PULL = 5000 const PULL_OVERLAP_SECONDS = 60 @@ -498,13 +498,20 @@ export class PullEngine { if (!sourceUnavailable) { this.logger.error(`[Pull] Pull failed for "${sess.name}": ${errMsg}`) } - this.dsManager.updateSession(sourceId, sess.id, { - lastPullAt: Math.floor(Date.now() / 1000), - lastStatus: 'error', - lastError: errMsg, - }) - this.notifier.onPullResult(sourceId, sess.id, 'error', errMsg) this.markProgressDone(sess.id) + try { + // A failed config commit must not be retried as an error-status write advancing the cursor. + if (error instanceof DataSourceConfigError) throw error + this.dsManager.updateSession(sourceId, sess.id, { + lastPullAt: Math.floor(Date.now() / 1000), + lastStatus: 'error', + lastError: errMsg, + }) + } catch (configError) { + this.notifier.onPullResult(sourceId, sess.id, 'error', getErrorMessage(configError)) + throw configError + } + this.notifier.onPullResult(sourceId, sess.id, 'error', errMsg) return { success: false, newMessageCount: 0, error: errMsg, sourceUnavailable: !!sourceUnavailable } } } diff --git a/packages/sync/src/scheduler.test.ts b/packages/sync/src/scheduler.test.ts new file mode 100644 index 000000000..f1d075866 --- /dev/null +++ b/packages/sync/src/scheduler.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test, { type TestContext } from 'node:test' +import { DataSourceManager } from './data-source-manager' +import { initScheduler, reloadTimer, stopAllTimers } from './scheduler' +import type { PullEngine } from './pull-engine' +import type { DataSource } from './types' + +function createFixture(t: TestContext) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-sync-scheduler-')) + t.after(() => { + stopAllTimers() + fs.rmSync(root, { recursive: true, force: true }) + }) + const dsManager = new DataSourceManager(root) + const source = dsManager.add({ baseUrl: 'https://example.test', token: '', intervalMinutes: 1 }) + dsManager.addSessions(source.id, [{ name: 'Test chat', remoteSessionId: 'remote-1' }]) + const pulls: string[] = [] + const pullEngine = { + async pullAllSessions(ds: DataSource) { + pulls.push(ds.id) + }, + } as PullEngine + return { file: path.join(root, 'data-sources.json'), dsManager, source, pulls, pullEngine } +} + +test('corrupt config pauses scheduler initialization without blocking the host and can be retried after repair', (t) => { + const { file, dsManager, source, pulls, pullEngine } = createFixture(t) + const original = fs.readFileSync(file, 'utf-8') + fs.writeFileSync(file, '[') + assert.doesNotThrow(() => initScheduler({ dsManager, pullEngine })) + assert.deepEqual(pulls, []) + assert.equal(fs.readFileSync(file, 'utf-8'), '[') + + fs.writeFileSync(file, original) + initScheduler({ dsManager, pullEngine }) + assert.deepEqual(pulls, [source.id]) +}) + +test('a scheduled read failure stops that timer without an uncaught exception or further pulls', (t) => { + const { file, dsManager, source, pulls, pullEngine } = createFixture(t) + t.mock.timers.enable({ apis: ['setInterval'] }) + initScheduler({ dsManager, pullEngine }) + assert.deepEqual(pulls, [source.id]) + const original = fs.readFileSync(file, 'utf-8') + fs.writeFileSync(file, '[') + assert.doesNotThrow(() => t.mock.timers.tick(60_000)) + assert.deepEqual(pulls, [source.id]) + + fs.writeFileSync(file, original) + t.mock.timers.tick(60_000) + assert.deepEqual(pulls, [source.id]) + reloadTimer(source.id, true) + t.mock.timers.tick(60_000) + assert.deepEqual(pulls, [source.id, source.id]) +}) diff --git a/packages/sync/src/scheduler.ts b/packages/sync/src/scheduler.ts index 978d79de1..bcad99f7d 100644 --- a/packages/sync/src/scheduler.ts +++ b/packages/sync/src/scheduler.ts @@ -36,14 +36,19 @@ function startTimer(ds: DataSource, skipInitialPull = false): void { } const timer = setInterval(() => { - const current = _dsManager.loadAll().find((s) => s.id === ds.id) - if (!current || !current.enabled || current.sessions.length === 0) { + try { + const current = _dsManager.loadAll().find((s) => s.id === ds.id) + if (!current || !current.enabled || current.sessions.length === 0) { + stopTimer(ds.id) + return + } + _pullEngine.pullAllSessions(current).catch((err) => { + _logger.error('[Pull] Scheduled pull failed', err) + }) + } catch (err) { stopTimer(ds.id) - return + _logger.error('[Pull] Scheduled pull stopped because data source config could not be loaded', err) } - _pullEngine.pullAllSessions(current).catch((err) => { - _logger.error('[Pull] Scheduled pull failed', err) - }) }, intervalMs) timers.set(ds.id, timer) @@ -62,13 +67,19 @@ export function stopTimer(id: string): void { export function initScheduler(options: SchedulerOptions): void { if (initialized) return - initialized = true _dsManager = options.dsManager _pullEngine = options.pullEngine _logger = options.logger ?? NOOP_LOGGER - const sources = _dsManager.loadAll() + let sources: DataSource[] + try { + sources = _dsManager.loadAll() + } catch (err) { + _logger.error('[Pull] Scheduler not started because data source config could not be loaded', err) + return + } + initialized = true for (const ds of sources) { if (ds.enabled && ds.sessions.length > 0) { startTimer(ds) diff --git a/packages/tools/src/agent-adapter.test.ts b/packages/tools/src/agent-adapter.test.ts index e29276e47..af29d5e63 100644 --- a/packages/tools/src/agent-adapter.test.ts +++ b/packages/tools/src/agent-adapter.test.ts @@ -161,3 +161,28 @@ test('cross-chat tool adapters share entities resolved during the current Agent assert.equal(afterResolve.isError, undefined) assert.deepEqual((afterResolve.details as { entries: AIMemoryEntry[] }).entries, [memory]) }) + +test('cross-chat tool adapters follow the locale for parameter descriptions', () => { + const base = { + analysisService: {} as CrossChatAnalysisToolService, + memoryService: {} as AIMemoryToolService, + aiChatId: 'global-chat-locale', + maxToolResultTokens: 16_000, + preprocessMessagesBySession: async (_sessionId: string, messages: never[]) => messages, + preprocessSummariesBySession: async (_sessionId: string, summaries: never[]) => summaries, + preprocessModelLabel: (value: string) => value, + } as unknown as Parameters[0] + + // All thirteen cross-chat tool descriptions are already English; the shared time parameters are the + // part that is Chinese in the definitions, so they are what shows whether the locale reached the adapter. + const startTimeOf = (locale: string): string | undefined => { + const tool = createCrossChatAgentToolAdapters({ ...base, locale }).find( + (candidate) => candidate.name === 'search_messages_globally' + ) + assert.ok(tool) + return (tool.parameters.properties.start_time as { description?: string }).description + } + + assert.equal(startTimeOf('zh-CN'), '起始时间, 格式: YYYY-MM-DD HH:mm') + assert.equal(startTimeOf('en-US'), 'Start time in YYYY-MM-DD HH:mm format.') +}) diff --git a/packages/tools/src/agent-adapter.ts b/packages/tools/src/agent-adapter.ts index 0cee2f1dc..ea6b173e6 100644 --- a/packages/tools/src/agent-adapter.ts +++ b/packages/tools/src/agent-adapter.ts @@ -1,4 +1,5 @@ import { CROSS_CHAT_AGENT_TOOL_REGISTRY } from './registry' +import { getLocalizedToolMetadata } from './tool-metadata' import type { CrossChatToolExecutionContext, JsonSchema, ToolDefinition, ToolProgress, ToolResult } from './types' interface AgentToolParameters { @@ -74,22 +75,25 @@ export function createCrossChatAgentToolAdapters( ...context, resolvedEntityRefs: context.resolvedEntityRefs ?? [], } - return CROSS_CHAT_AGENT_TOOL_REGISTRY.map((tool) => ({ - name: tool.name, - label: tool.name, - description: tool.description, - parameters: toAgentToolParameters(tool.inputSchema), - executionMode: tool.executionMode, - execute: async ( - _toolCallId: string, - params: unknown, - signal: AbortSignal, - onUpdate?: (update: { content: []; details: { progress: ToolProgress } }) => void - ) => - executeToolForAgent(tool, params, { - ...runContext, - abortSignal: signal, - reportProgress: (progress) => onUpdate?.({ content: [], details: { progress } }), - }), - })) + return CROSS_CHAT_AGENT_TOOL_REGISTRY.map((tool) => { + const { description, inputSchema } = getLocalizedToolMetadata(tool, context.locale) + return { + name: tool.name, + label: tool.name, + description, + parameters: toAgentToolParameters(inputSchema), + executionMode: tool.executionMode, + execute: async ( + _toolCallId: string, + params: unknown, + signal: AbortSignal, + onUpdate?: (update: { content: []; details: { progress: ToolProgress } }) => void + ) => + executeToolForAgent(tool, params, { + ...runContext, + abortSignal: signal, + reportProgress: (progress) => onUpdate?.({ content: [], details: { progress } }), + }), + } + }) } diff --git a/packages/tools/src/definitions/keyword-frequency.ts b/packages/tools/src/definitions/keyword-frequency.ts index 9f0ddf362..0289be4f0 100644 --- a/packages/tools/src/definitions/keyword-frequency.ts +++ b/packages/tools/src/definitions/keyword-frequency.ts @@ -6,7 +6,9 @@ */ import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' -import { isChineseLocale } from '../utils/format' +import { formatTimeRange, isChineseLocale } from '../utils/format' +import { parseExtendedTimeParams, type ExtendedTimeParams } from '../utils/time-params' +import { timeParamProperties } from '../utils/schemas' interface TextRow { content: string @@ -15,8 +17,12 @@ interface TextRow { const inputSchema: JsonSchema = { type: 'object', properties: { - days: { type: 'number', description: '分析最近多少天的数据,默认 30' }, + days: { + type: 'number', + description: 'Recent days to analyze when no explicit or selected time range is available; defaults to 30', + }, top_n: { type: 'number', description: '返回前多少个高频词,默认 50' }, + ...timeParamProperties, }, } @@ -31,13 +37,20 @@ async function handler(params: Record, context: ToolExecutionCo return { content: text, data: null } } + const effectiveTimeFilter = parseExtendedTimeParams(params as ExtendedTimeParams, context.timeFilter) + const timeCondition = effectiveTimeFilter + ? 'ts >= @startTs AND ts <= @endTs' + : "ts > unixepoch('now', '-' || @days || ' days')" const sql = ` SELECT content FROM message WHERE type = 0 AND content IS NOT NULL AND LENGTH(content) > 1 - AND ts > unixepoch('now', '-' || @days || ' days') + AND ${timeCondition} LIMIT 50000 ` - const rows = await context.dataProvider!.executeParameterizedSql(sql, { days }) + const rows = await context.dataProvider!.executeParameterizedSql( + sql, + effectiveTimeFilter ? { ...effectiveTimeFilter } : { days } + ) if (!rows || rows.length === 0) { const text = isZh ? '该时间范围内没有文本消息' : 'No text messages in this time range' return { content: text, data: null } @@ -63,8 +76,15 @@ async function handler(params: Record, context: ToolExecutionCo count, })) + const formattedRange = formatTimeRange(effectiveTimeFilter, locale) + const period = + typeof formattedRange === 'string' + ? isZh + ? `近${days}天` + : `Last ${days} days` + : `${formattedRange.start} – ${formattedRange.end}` const data = { - period: isZh ? `近${days}天` : `Last ${days} days`, + period, totalMessages: rows.length, totalKeywords: ranking.length, keywords: ranking.map((r) => `${r.rank}. ${r.word} (${r.count}${isZh ? '次' : ''})`), diff --git a/packages/tools/src/definitions/response-time-analysis.ts b/packages/tools/src/definitions/response-time-analysis.ts index 3f3dddb1a..183a77be8 100644 --- a/packages/tools/src/definitions/response-time-analysis.ts +++ b/packages/tools/src/definitions/response-time-analysis.ts @@ -6,7 +6,9 @@ import { computeResponseTimeStats } from '@openchatlab/core' import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' -import { isChineseLocale } from '../utils/format' +import { formatTimeRange, isChineseLocale } from '../utils/format' +import { parseExtendedTimeParams, type ExtendedTimeParams } from '../utils/time-params' +import { timeParamProperties } from '../utils/schemas' interface MsgRow { sender_id: number @@ -17,8 +19,12 @@ interface MsgRow { const inputSchema: JsonSchema = { type: 'object', properties: { - days: { type: 'number', description: '分析最近多少天的数据,默认 30' }, + days: { + type: 'number', + description: 'Recent days to analyze when no explicit or selected time range is available; defaults to 30', + }, top_n: { type: 'number', description: '返回前多少名,默认 10' }, + ...timeParamProperties, }, } @@ -28,15 +34,22 @@ async function handler(params: Record, context: ToolExecutionCo const days = (params.days as number) || 30 const topN = (params.top_n as number) || 10 + const effectiveTimeFilter = parseExtendedTimeParams(params as ExtendedTimeParams, context.timeFilter) + const timeCondition = effectiveTimeFilter + ? 'msg.ts >= @startTs AND msg.ts <= @endTs' + : "msg.ts > unixepoch('now', '-' || @days || ' days')" const sql = ` SELECT msg.sender_id, COALESCE(m.group_nickname, m.account_name) AS name, msg.ts FROM message msg JOIN member m ON msg.sender_id = m.id WHERE msg.type = 0 - AND msg.ts > unixepoch('now', '-' || @days || ' days') + AND ${timeCondition} ORDER BY msg.ts ASC ` - const rows = await context.dataProvider!.executeParameterizedSql(sql, { days }) + const rows = await context.dataProvider!.executeParameterizedSql( + sql, + effectiveTimeFilter ? { ...effectiveTimeFilter } : { days } + ) if (!rows || rows.length < 2) { const text = isZh ? '该时间范围内消息不足,无法分析响应时间' @@ -69,8 +82,15 @@ async function handler(params: Record, context: ToolExecutionCo count: s.responseCount, })) + const formattedRange = formatTimeRange(effectiveTimeFilter, locale) + const period = + typeof formattedRange === 'string' + ? isZh + ? `近${days}天` + : `Last ${days} days` + : `${formattedRange.start} – ${formattedRange.end}` const data = { - period: isZh ? `近${days}天` : `Last ${days} days`, + period, totalResponders: stats.length, ranking: ranking.map( (r) => diff --git a/packages/tools/src/definitions/sessions.test.ts b/packages/tools/src/definitions/sessions.test.ts new file mode 100644 index 000000000..c7b261a27 --- /dev/null +++ b/packages/tools/src/definitions/sessions.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { DatabaseAdapter } from '@openchatlab/core' +import { sessionsListTool, type SessionListContext } from './sessions' + +test('list_sessions closes each per-session database even when metadata query throws', () => { + let closeCount = 0 + const fakeDb: DatabaseAdapter = { + readonly: true, + prepare: () => { + throw new Error('synthetic database query failure') + }, + pragma: () => [], + exec: () => undefined, + transaction: (fn) => fn(), + close: () => { + closeCount++ + }, + } + const context: SessionListContext = { + db: fakeDb, + sessionId: '', + listSessionIds: () => ['one'], + openDb: () => fakeDb, + closeDb: (db) => db.close(), + } + assert.throws(() => sessionsListTool.handler({}, context), /synthetic database query failure/) + assert.equal(closeCount, 1) +}) diff --git a/packages/tools/src/definitions/sessions.ts b/packages/tools/src/definitions/sessions.ts index a17e58f16..27ab201ec 100644 --- a/packages/tools/src/definitions/sessions.ts +++ b/packages/tools/src/definitions/sessions.ts @@ -22,6 +22,8 @@ const inputSchema: JsonSchema = { export interface SessionListContext extends ToolExecutionContext { listSessionIds: () => string[] openDb: (sessionId: string) => DatabaseAdapter | null + /** Optional lifecycle hook for uncached per-session connections (e.g. MCP read transactions). */ + closeDb?: (db: DatabaseAdapter) => void } function handler(_params: Record, context: SessionListContext): ToolResult { @@ -32,11 +34,15 @@ function handler(_params: Record, context: SessionListContext): .map((id) => { const db = context.openDb(id) if (!db) return null - const meta = getSessionMeta(db) - if (!meta) return null - if (keyword && !meta.name.toLowerCase().includes(keyword)) return null - const overview = getSessionOverview(db) - return { id, ...meta, ...overview } + try { + const meta = getSessionMeta(db) + if (!meta) return null + if (keyword && !meta.name.toLowerCase().includes(keyword)) return null + const overview = getSessionOverview(db) + return { id, ...meta, ...overview } + } finally { + context.closeDb?.(db) + } }) .filter(Boolean) diff --git a/packages/tools/src/tool-metadata.test.ts b/packages/tools/src/tool-metadata.test.ts new file mode 100644 index 000000000..0cdfb0f52 --- /dev/null +++ b/packages/tools/src/tool-metadata.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { AGENT_TOOL_REGISTRY, CROSS_CHAT_AGENT_TOOL_REGISTRY, MCP_TOOL_REGISTRY } from './registry' +import { ENGLISH_TOOL_METADATA, getLocalizedToolMetadata } from './tool-metadata' +import type { ToolDefinition } from './types' + +/** CJK ideographs plus the CJK / fullwidth punctuation used by the Chinese tool definitions. */ +const CHINESE_PATTERN = /[\u3000-\u303F\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF]/ + +type LocalizableTool = Pick + +/** Every tool any runtime can hand to an LLM: session Agent, MCP Server and cross-chat Agent. */ +const REGISTERED_TOOLS: LocalizableTool[] = [ + ...AGENT_TOOL_REGISTRY, + ...MCP_TOOL_REGISTRY, + ...CROSS_CHAT_AGENT_TOOL_REGISTRY, +].filter((tool, index, tools) => tools.findIndex((other) => other.name === tool.name) === index) + +function definitionIsChinese(tool: LocalizableTool): boolean { + if (CHINESE_PATTERN.test(tool.description)) return true + return Object.values(tool.inputSchema.properties).some( + (property) => property.description !== undefined && CHINESE_PATTERN.test(property.description) + ) +} + +test('every tool with a Chinese definition has English metadata', () => { + const missing = REGISTERED_TOOLS.filter((tool) => definitionIsChinese(tool) && !ENGLISH_TOOL_METADATA[tool.name]).map( + (tool) => tool.name + ) + + assert.deepEqual(missing, []) +}) + +test('non-Chinese locales never receive Chinese tool or parameter descriptions', () => { + const chinese: string[] = [] + + for (const tool of REGISTERED_TOOLS) { + const { description, inputSchema } = getLocalizedToolMetadata(tool, 'en-US') + if (CHINESE_PATTERN.test(description)) chinese.push(`${tool.name} (description)`) + for (const [name, property] of Object.entries(inputSchema.properties)) { + if (property.description && CHINESE_PATTERN.test(property.description)) chinese.push(`${tool.name}.${name}`) + } + } + + assert.deepEqual(chinese, []) +}) + +test('Chinese locales keep the original definition text', () => { + for (const tool of REGISTERED_TOOLS) { + const localized = getLocalizedToolMetadata(tool, 'zh-CN') + + assert.equal(localized.description, tool.description, tool.name) + assert.equal(localized.inputSchema, tool.inputSchema, tool.name) + } +}) + +test('localization only rewrites descriptions, keeping required fields and untranslated properties', () => { + for (const tool of REGISTERED_TOOLS) { + const translations = ENGLISH_TOOL_METADATA[tool.name]?.properties ?? {} + const { inputSchema } = getLocalizedToolMetadata(tool, 'en-US') + + assert.deepEqual(inputSchema.required, tool.inputSchema.required, tool.name) + assert.deepEqual(Object.keys(inputSchema.properties), Object.keys(tool.inputSchema.properties), tool.name) + + for (const [name, property] of Object.entries(tool.inputSchema.properties)) { + const expected = translations[name] ? { ...property, description: translations[name] } : property + assert.deepEqual(inputSchema.properties[name], expected, `${tool.name}.${name}`) + } + } +}) diff --git a/packages/tools/src/tool-metadata.ts b/packages/tools/src/tool-metadata.ts index b321e4479..0ac62c552 100644 --- a/packages/tools/src/tool-metadata.ts +++ b/packages/tools/src/tool-metadata.ts @@ -2,11 +2,20 @@ import type { JsonSchema, ToolDefinition } from './types' import { isChineseLocale } from './utils/format' interface EnglishToolMetadata { - description: string + /** Omitted when the definition description is already English. */ + description?: string properties?: Record } -const ENGLISH_TOOL_METADATA: Record = { +/** + * English tool/parameter descriptions handed to the LLM on non-Chinese locales. + * + * Only translates text the definitions write in Chinese: tools whose definition description and + * parameter descriptions are already English have no entry here, and a tool with an English + * description but Chinese parameters lists only `properties`. Copying English text into this table + * would let the two copies drift apart (tool-metadata.test.ts enforces both rules). + */ +export const ENGLISH_TOOL_METADATA: Record = { get_chat_overview: { description: 'Get a chat overview including its name, platform, message and member counts, time range, and most active members.', @@ -102,6 +111,178 @@ const ENGLISH_TOOL_METADATA: Record = { max_rows: 'Maximum rows to return. Defaults to 1000 and is also capped by the execution context.', }, }, + get_segment_summaries: { + description: + 'Get the list of segment summaries for a quick view of the topics discussed in the chat history. Topics that have been discussed can be searched by keyword.', + properties: { + keywords: 'Filter summary content by keywords.', + limit: 'Maximum number of segments to return. Defaults to 20.', + start_time: 'Start time in YYYY-MM-DD HH:mm format.', + end_time: 'End time in YYYY-MM-DD HH:mm format.', + }, + }, + response_time_analysis: { + description: + 'Analyze the response-speed ranking of chat members, based on the median and mean of their reply intervals.', + properties: { + top_n: 'Number of top-ranked members to return. Defaults to 10.', + start_time: 'Start time in YYYY-MM-DD HH:mm format.', + end_time: 'End time in YYYY-MM-DD HH:mm format.', + }, + }, + keyword_frequency: { + description: + 'Count the most frequent keywords in the chat, analyzing message content through NLP word segmentation.', + properties: { + top_n: 'Number of top frequent words to return. Defaults to 50.', + start_time: 'Start time in YYYY-MM-DD HH:mm format.', + end_time: 'End time in YYYY-MM-DD HH:mm format.', + }, + }, + semantic_search_current_chat: { + properties: { + query: + 'Semantic search query. Rewrite it into a natural-language description suited to searching the current chat history (centred on the facts/people/places/events/past mentions that need to be confirmed).', + max_results: + 'Expected number of relevant excerpts to return; optional. When omitted, the default configured by the user is used; it may be raised according to question complexity, with a hard limit of 20.', + }, + }, + retrieve_chat_evidence: { + properties: { + query: + 'The question or retrieval target that has to be confirmed with chat evidence, for example "how many times have we travelled to Leshan".', + criteria: + 'Judgement criteria: what counts and what does not. Counting or judgement questions should fill this in, for example "counts: evidence of an actual trip/arrival/stay; does not count: plans only, travel guides, other people\'s experiences, general chatter".', + keywords: + 'Keyword list, given explicitly by you (the tool does not expand synonyms). Used by the hybrid/keyword modes for exact recall.', + mode: 'auto (default)/hybrid (semantic + keyword)/semantic (semantic only)/keyword (keyword only). Counting historical facts usually uses hybrid.', + max_results: 'Expected number of excerpts returned by the semantic path, with a hard limit of 20.', + start_time: 'Start time in YYYY-MM-DD HH:mm format.', + end_time: 'End time in YYYY-MM-DD HH:mm format.', + }, + }, + message_type_breakdown: { + description: + 'Break the messages of the last N days down by message type (how many text, image, voice, sticker and other messages there are). Use this to understand communication-style preferences.', + properties: { days: 'Number of most recent days of data to count.' }, + }, + peak_chat_hours_by_member: { + description: + "Analyze the hourly distribution of a given member's messages over the last N days to find their most active hours. Obtain member_id from get_members before calling this tool.", + properties: { + member_id: 'Member ID obtained from get_members.', + days: 'Number of most recent days of data to count.', + }, + }, + member_activity_trend: { + description: + "View the trend of a given member's daily message counts over the last N days. Use this to observe whether someone has become more active or more silent. Obtain member_id from get_members before calling this tool.", + properties: { + member_id: 'Member ID obtained from get_members.', + days: 'Number of most recent days of trend to view.', + }, + }, + silent_members: { + description: + 'Detect "silent members" who have not spoken for more than N days. Use this in community management to find users at risk of churn.', + properties: { days: 'Number of days without speaking that counts as silent.' }, + }, + reply_interaction_ranking: { + description: + 'Analyze the ranking of reply interactions in the chat to find who replies to whom the most. Use this to discover the core interaction relationships and the opinion leaders in the community.', + properties: { + days: 'Number of most recent days of data to count.', + limit: 'Number of top interaction pairs to return.', + }, + }, + mutual_interaction_pairs: { + description: + 'Find the member pairs that interact most frequently, based on the temporal proximity of two-way messages (one interaction is counted when the other party also speaks within 5 minutes of one party speaking). Use this to discover closely connected friend pairs.', + properties: { + days: 'Number of most recent days of data to count.', + limit: 'Number of top pairs to return.', + }, + }, + member_message_length_stats: { + description: + 'Count the average message length of each member (text messages only); long messages usually mean more thoughtful communication. Use this to discover members who communicate in depth.', + properties: { + days: 'Number of most recent days of data to count.', + top_n: 'Number of top-ranked members to return.', + }, + }, + daily_active_members: { + description: + 'Count the daily unique speakers (DAU) and message volume, used to observe how the vitality of the chat changes. Use this for questions such as "what is the chat activity trend" and "how many people have been talking recently".', + properties: { days: 'Number of most recent days of data to count.' }, + }, + conversation_initiator_stats: { + description: + "Count how many times each member starts a conversation (as the sender of the conversation's first message) to find who opens topics most often.", + properties: { + days: 'Number of most recent days of data to count.', + limit: 'Number of top-ranked members to return.', + }, + }, + activity_heatmap: { + description: + 'Return a weekday × hour matrix of message counts, suitable for generating an activity heatmap. weekday: 0=Sunday, 1=Monday, ..., 6=Saturday.', + properties: { days: 'Number of most recent days of data to count.' }, + }, + unanswered_messages: { + description: + 'Find messages from the last N days that were never replied to; these may be unresolved customer questions. Only text messages whose content is longer than 10 characters are counted (short greetings are filtered out).', + properties: { + days: 'Number of most recent days of data to search.', + limit: 'Maximum number of messages to return.', + }, + }, + list_sessions: { + description: + 'List all available chat sessions, returning basic information such as session name, platform, and message count.', + properties: { keyword: 'Filter sessions by name (optional).' }, + }, + get_session_info: { + description: + 'Get detailed information about the current session, including its name, platform, total message count, member count, and time range.', + properties: { include_members: 'Whether to include the member list (defaults to false).' }, + }, + search_messages_globally: { + properties: { + start_time: 'Start time in YYYY-MM-DD HH:mm format.', + end_time: 'End time in YYYY-MM-DD HH:mm format.', + }, + }, + get_cross_chat_overview: { + properties: { + start_time: 'Start time in YYYY-MM-DD HH:mm format.', + end_time: 'End time in YYYY-MM-DD HH:mm format.', + }, + }, + rank_private_contacts: { + properties: { + start_time: 'Start time in YYYY-MM-DD HH:mm format.', + end_time: 'End time in YYYY-MM-DD HH:mm format.', + }, + }, + rank_group_sessions: { + properties: { + start_time: 'Start time in YYYY-MM-DD HH:mm format.', + end_time: 'End time in YYYY-MM-DD HH:mm format.', + }, + }, + inspect_contact_sessions: { + properties: { + start_time: 'Start time in YYYY-MM-DD HH:mm format.', + end_time: 'End time in YYYY-MM-DD HH:mm format.', + }, + }, + inspect_shared_interactions: { + properties: { + start_time: 'Start time in YYYY-MM-DD HH:mm format.', + end_time: 'End time in YYYY-MM-DD HH:mm format.', + }, + }, } function localizeInputSchema(schema: JsonSchema, properties?: Record): JsonSchema { @@ -117,15 +298,16 @@ function localizeInputSchema(schema: JsonSchema, properties?: Record, locale?: string ): Pick { if (isChineseLocale(locale)) return { description: tool.description, inputSchema: tool.inputSchema } const metadata = ENGLISH_TOOL_METADATA[tool.name] if (!metadata) return { description: tool.description, inputSchema: tool.inputSchema } return { - description: metadata.description, + description: metadata.description ?? tool.description, inputSchema: localizeInputSchema(tool.inputSchema, metadata.properties), } } diff --git a/scripts/build-chatlab-skill-validator.mjs b/scripts/build-chatlab-skill-validator.mjs index 1aa00b05b..50b766e2f 100644 --- a/scripts/build-chatlab-skill-validator.mjs +++ b/scripts/build-chatlab-skill-validator.mjs @@ -105,11 +105,9 @@ try { { cwd: rootDir, stdio: 'inherit' } ) - for (const skillName of ['chatlab-convert', 'chatlab-convert-cn']) { - const scriptsDir = path.join(rootDir, 'skills', skillName, 'scripts') - mkdirSync(scriptsDir, { recursive: true }) - copyFileSync(outputPath, path.join(scriptsDir, 'validate-chatlab.mjs')) - } + const scriptsDir = path.join(rootDir, 'skills', 'chatlab-convert', 'scripts') + mkdirSync(scriptsDir, { recursive: true }) + copyFileSync(outputPath, path.join(scriptsDir, 'validate-chatlab.mjs')) } finally { rmSync(tempDir, { recursive: true, force: true }) } diff --git a/scripts/changelog-to-markdown.mjs b/scripts/changelog-to-markdown.mjs index 3f49d62db..8966344e5 100644 --- a/scripts/changelog-to-markdown.mjs +++ b/scripts/changelog-to-markdown.mjs @@ -26,14 +26,14 @@ const LOCALES = [ /** type → 各语言的标题(含 emoji)。缺失 type 回退为原始字符串,兼容未来扩展。 */ const TYPE_LABELS = { - feat: { cn: '✨ 新功能', en: '✨ Features', tw: '✨ 新功能', ja: '✨ 新機能' }, - fix: { cn: '🐛 修复', en: '🐛 Bug Fixes', tw: '🐛 修復', ja: '🐛 バグ修正' }, - refactor: { cn: '♻️ 重构', en: '♻️ Refactoring', tw: '♻️ 重構', ja: '♻️ リファクタリング' }, - perf: { cn: '⚡ 性能', en: '⚡ Performance', tw: '⚡ 效能', ja: '⚡ パフォーマンス' }, - docs: { cn: '📝 文档', en: '📝 Documentation', tw: '📝 文件', ja: '📝 ドキュメント' }, - style: { cn: '💄 样式', en: '💄 Styles', tw: '💄 樣式', ja: '💄 スタイル' }, - ci: { cn: '👷 CI', en: '👷 CI', tw: '👷 CI', ja: '👷 CI' }, - chore: { cn: '🔧 杂项', en: '🔧 Chores', tw: '🔧 雜項', ja: '🔧 雑務' }, + feat: { cn: '✨ 新功能', en: '✨ Features', tw: '✨ 新功能', ja: '✨ 新機能' }, + fix: { cn: '🐛 修复', en: '🐛 Bug Fixes', tw: '🐛 修復', ja: '🐛 バグ修正' }, + refactor: { cn: '♻️ 重构', en: '♻️ Refactoring', tw: '♻️ 重構', ja: '♻️ リファクタリング' }, + perf: { cn: '⚡ 性能', en: '⚡ Performance', tw: '⚡ 效能', ja: '⚡ パフォーマンス' }, + docs: { cn: '📝 文档', en: '📝 Documentation', tw: '📝 文件', ja: '📝 ドキュメント' }, + style: { cn: '💄 样式', en: '💄 Styles', tw: '💄 樣式', ja: '💄 スタイル' }, + ci: { cn: '👷 CI', en: '👷 CI', tw: '👷 CI', ja: '👷 CI' }, + chore: { cn: '🔧 杂项', en: '🔧 Chores', tw: '🔧 雜項', ja: '🔧 雑務' }, } // ── 渲染函数 ──────────────────────────────────────────────────────────────── @@ -44,6 +44,16 @@ const TYPE_LABELS = { * @param {string} langKey - 'cn' | 'en' | 'tw' | 'ja'(用于 type 标题查表) * @param {Array} entries - changelog 数组 */ +function renderChangelogItem(item) { + if (typeof item === 'string') return item + + const contributors = Array.isArray(item.contributors) ? item.contributors : [] + if (contributors.length === 0) return item.text + + const links = contributors.map((login) => `[@${login}](https://github.com/${encodeURIComponent(login)})`).join(', ') + return `${item.text} (by ${links})` +} + function renderMarkdown(h1, langKey, entries) { const lines = [`# ${h1}`, ''] @@ -66,7 +76,7 @@ function renderMarkdown(h1, langKey, entries) { lines.push(`### ${label}`) lines.push('') for (const item of items) { - lines.push(`- ${item}`) + lines.push(`- ${renderChangelogItem(item)}`) } lines.push('') } @@ -83,7 +93,7 @@ let hasError = false for (const { key, h1 } of LOCALES) { const jsonPath = resolve(ROOT, 'changelogs', `${key}.json`) - const mdPath = resolve(ROOT, 'changelogs', `${key}.md`) + const mdPath = resolve(ROOT, 'changelogs', `${key}.md`) let entries try { diff --git a/skills/chatlab-analyze-cn/SKILL.md b/skills/chatlab-analyze-cn/SKILL.md deleted file mode 100644 index 5f33fe734..000000000 --- a/skills/chatlab-analyze-cn/SKILL.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -name: chatlab-analyze-cn -description: 通过 clb CLI 分析本地 ChatLab 聊天记录。当用户要求外部 Agent 检查对话、查找证据、总结话题、比较成员,或基于已导入的 ChatLab 数据分析指定人物或群组关系时使用。 ---- - -# ChatLab 聊天分析 - -通过只读 `clb` CLI 查询和分析已经导入 ChatLab 的聊天记录。 - -## 适用场景 - -- 查找某段对话,或确认谁最先提到某件事。 -- 总结近期话题,或分析与某个人的聊天关系。 -- 比较成员活跃度、关键词或回复规律。 - -如果要导入新的聊天导出文件,请使用 `chatlab-import-cn`。 - -安装命令: - -```bash -npx skills add ChatLab/ChatLab --skill chatlab-analyze-cn -g -``` - -## 工作流程 - -### 1. 准备查询 - -检查 CLI、读取当前命令契约并列出会话: - -```bash -clb --help -clb manifest -clb sessions list --format json -``` - -只有一个相关会话时直接使用;多个会话或成员都可能匹配时,让用户从返回的候选项中选择。 - -### 2. 从专用命令开始 - -先使用能直接回答问题的最简单命令: - -```bash -clb messages search "" --session --format agent -clb messages between --member me --member --session --last 90d --format agent -clb topics list --session --last 30d --format agent -``` - -读取消息文本时使用 `--format agent`;侦察会话、成员、数量和 `--no-content` 搜索等结构时使用 `--format json`。 - -### 3. 补充上下文或统计 - -只有第一步结果不足时再深入: - -```bash -clb messages context --id 1021 --session --window 10 --format agent -clb stats keywords --session --member --last 90d --top 20 --format json -``` - -当 `meta.hasMore` 为 true 时,保持相同查询条件并使用 `--cursor ` 继续。通过数量和 Token 参数只获取回答问题所需的上下文。 - -### 4. 最后才使用 SQL - -只有专用命令无法回答时,才使用只读 SQL: - -```bash -clb schema --session --format json -clb sql "SELECT COUNT(*) AS n FROM message" --session --format json -``` - -## 隐私与回答 - -- 绝不使用 `--raw`、修改 ChatLab 数据、导入文件、更改配置或启动长期运行的服务。 -- 绝不泄露完整聊天记录。ChatLab 的安全输出会应用用户配置的隐私预处理。 -- 使用 `[#1021]`、`[#1021*]` 或 `[#1021-1024]` 等可用标记引用消息证据。 -- 先直接回答,说明实际查询的会话和时间范围,再区分观察到的事实与解释。 -- 分析关系时不要过度推断情绪意图。仅在修正方式明确时遵循 `error.hint`。 -- 优先使用用户当前使用的中文变体回答,命令、参数、JSON 字段和证据标记保持原样。 diff --git a/skills/chatlab-analyze-cn/agents/openai.yaml b/skills/chatlab-analyze-cn/agents/openai.yaml deleted file mode 100644 index cce7b004e..000000000 --- a/skills/chatlab-analyze-cn/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: 'ChatLab 聊天分析' - short_description: '通过 ChatLab CLI 安全查询并分析本地聊天记录' - default_prompt: '使用 $chatlab-analyze-cn 分析我在 ChatLab 中的本地聊天记录。' diff --git a/skills/chatlab-analyze/SKILL.md b/skills/chatlab-analyze/SKILL.md index bed6bb562..6d64eb0d6 100644 --- a/skills/chatlab-analyze/SKILL.md +++ b/skills/chatlab-analyze/SKILL.md @@ -1,39 +1,29 @@ --- name: chatlab-analyze -description: Analyze local ChatLab chat records through the clb CLI. Use when the user asks an external agent to inspect conversations, find evidence, summarize topics, compare members, or analyze a named relationship from imported ChatLab data. +description: >- + Query and analyze imported ChatLab chats with clb: find evidence, summarize topics, and compare activity or relationships. 查询和分析 ChatLab 已导入聊天记录:查找证据、总结话题、比较活跃度或关系。 --- # ChatLab Analyze Query and analyze records already imported into ChatLab through the read-only `clb` CLI. -## Good Fit +Follow the user's requested response language; otherwise use the conversation language, preserving the user's Chinese variant when applicable. Keep commands, field names, IDs, and evidence markers unchanged. -- Find conversations or determine who mentioned something first. -- Summarize recent topics or inspect a named relationship. -- Compare member activity, keywords, or response patterns. - -For importing a new chat export, use `chatlab-import` instead. - -Install this skill with: - -```bash -npx skills add ChatLab/ChatLab --skill chatlab-analyze -g -``` +For importing a new chat export, use `chatlab-import` when available; otherwise follow `clb import --help` and preview the exact import before writing. ## Workflow ### 1. Prepare the Query -Check the CLI, load its current command contract, and list sessions: +Load the current command contract once per task. List sessions only when the target is not already known: ```bash -clb --help clb manifest clb sessions list --format json ``` -Use the only relevant session. If multiple sessions or members match the request, ask the user to choose from the returned candidates. +Reuse the session, member, and time range already identified in the conversation. Ask only when the returned candidates and context cannot resolve a material ambiguity. If the CLI is unavailable, report the missing capability; installation requires authorization. ### 2. Start with a Dedicated Command @@ -56,7 +46,7 @@ clb messages context --id 1021 --session --window 10 --format agent clb stats keywords --session --member --last 90d --top 20 --format json ``` -When `meta.hasMore` is true, continue with `--cursor ` and the same query conditions. Use limits and token controls to retrieve only the context needed. +When more evidence is needed and `meta.hasMore` is true, continue with `--cursor ` and the same query conditions. Stop when the question is answered; disclose any partial coverage instead of treating a page as the complete dataset. ### 4. Use SQL Only as a Fallback @@ -69,8 +59,8 @@ clb sql "SELECT COUNT(*) AS n FROM message" --session --format json ## Privacy and Answers -- Never use `--raw`, modify ChatLab data, import files, change config, or start long-running services. +- Queries keep privacy preprocessing enabled; do not use `--raw` or mutate data/config. A separate request to import follows the import workflow. - Never reveal full chat dumps. ChatLab's safe output applies the user's privacy preprocessing. -- Cite available message evidence with markers such as `[#1021]`, `[#1021*]`, or `[#1021-1024]`. +- Cite available evidence with `[#1021]`, `[#1021*]`, or `[#1021-1024]`. Only individual IDs can be passed to `messages context --id`; merged ranges are display-only. - Start with the answer, name the queried session and time range, then separate observed facts from interpretation. - Avoid overclaiming emotional intent in relationship analysis. Follow `error.hint` only when the correction is clear. diff --git a/skills/chatlab-analyze/agents/openai.yaml b/skills/chatlab-analyze/agents/openai.yaml index 1bd7f9f31..f74d8be8a 100644 --- a/skills/chatlab-analyze/agents/openai.yaml +++ b/skills/chatlab-analyze/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: 'ChatLab Analyze' - short_description: 'Safely query and analyze local chats with ChatLab CLI' + display_name: 'ChatLab Analyze · 聊天分析' + short_description: 'Query and analyze local chats · 查询和分析本地聊天记录' default_prompt: 'Use $chatlab-analyze to analyze my local ChatLab chat records.' diff --git a/skills/chatlab-convert-cn/SKILL.md b/skills/chatlab-convert-cn/SKILL.md deleted file mode 100644 index 5aa2f98cd..000000000 --- a/skills/chatlab-convert-cn/SKILL.md +++ /dev/null @@ -1,160 +0,0 @@ ---- -name: chatlab-convert-cn -description: 将 ChatLab 暂不支持的本地聊天导出转换为经过验证的 ChatLab JSONL 或 JSON:分析源文件结构,编写并运行本地 Node.js 或 Python 转换脚本,并在导入前核对记录数量。当 ChatLab 无法识别 CSV、HTML、TXT、XML、SQLite/数据库、厂商自定义 JSON/JSONL、压缩包等聊天导出,或用户要求 Agent 适配未知聊天格式时使用。 ---- - -# ChatLab 聊天转换 - -在不修改源文件的前提下转换暂不支持的聊天导出。优先交付可复用的转换脚本和经过验证的 JSONL,而不是一次性改写文件。 - -安装命令: - -```bash -npx skills add ChatLab/ChatLab --skill chatlab-convert-cn -g -``` - -## 不可省略的规则 - -- 始终以只读方式处理源文件。脚本、样本、解压文件和输出都写入独立的同级目录。 -- 聊天数据只留在本机。不要上传到网络服务,也不要在 AI 可见的终端输出中打印消息正文。 -- 先检查结构:字段名、列名、值类型、数量、时间戳形态和脱敏样本。诊断输出中的消息正文替换为类型和长度。 -- 绝不静默跳过损坏或不支持的源记录。应携带源行号/索引失败,或保留为消息类型 `99` 并明确统计映射数量。 -- 未经允许不要安装依赖或系统工具。优先使用已有 Node.js/Python 标准库;Shell 只用于检查和编排。 -- 用户没有明确要求导入时,不要写入 ChatLab。用户明确说“转换并导入”,则所有门禁通过后无需二次确认。 - -## 工作流程 - -### 1. 确认源文件和 CLI - -确认唯一、准确的源路径。存在多个候选文件时询问用户,不要猜测。每条命令都要给路径加引号。 - -```bash -clb --help -clb validate --help -clb formats -clb import "/absolute/path/to/source" --dry-run --json -``` - -如果 dry-run 已经识别源文件,不要重复转换,改用 `chatlab-import-cn` 完成导入。 - -如果 `clb` 不存在,或已有版本没有 `validate` 命令: - -1. 告诉用户未检测到可用的 ChatLab CLI,但仍可使用本 Skill 内置的严格验证器继续转换; -2. 建议安装或更新 CLI,并在执行前征得用户同意:`npm install -g chatlab-cli@latest`; -3. 用户跳过安装、安装失败或当前无法联网时,不要阻断转换,改用 `scripts/validate-chatlab.mjs`; -4. 跳过 `clb formats` 和源文件 dry-run,并明确说明无法确认 ChatLab 是否已原生支持该源格式。 - -内置验证器需要 Node.js 22.19 或更高版本。如果 `node --version` 也不可用,可以继续编写转换器,但必须把结果标记为“尚未验证”,并引导用户安装 Node.js 和 `chatlab-cli`;未经同意不要自行安装。 - -不能用肉眼检查替代 CLI 或内置严格验证器。 - -### 2. 在不暴露正文的前提下检查结构 - -先检查文件元信息、编码、压缩包目录、JSON 字段、CSV 表头、XML/HTML 元素名或数据库表结构。不要运行会打印原始消息列的 `cat`、无限制 `head` 或 SQL 查询。 - -确实需要样本值时,编写一个简短的本地检查脚本,只输出: - -- 字段名和数据类型; -- 记录数和空值数; -- 时间戳与标识符形态; -- `` 这样的正文掩码; -- 少量结构不同的记录。 - -压缩包要解压到独立工作目录,并拒绝逃逸该目录的条目。数据库必须只读打开。 - -### 3. 确定字段映射 - -编写转换器前,完整阅读 [references/chatlab-format.md](references/chatlab-format.md)。记录源字段到 ChatLab 字段的映射,包括: - -- 会话边界以及群聊/私聊类型; -- 稳定的成员身份和本人身份; -- 时间戳单位与时区; -- 消息类型、正文、原始消息 ID 和回复关系; -- 源记录总数,以及无法完全表达的记录。 - -只有当歧义会改变身份、会话边界、时间含义或本人归属时才询问用户;这些字段不能猜测。 - -一个 ChatLab 文件只能表示一个会话。如果源文件包含多个会话,要使用防冲突且确定性的文件名分别输出,绝不能误合并。 - -### 4. 编写确定性的转换器 - -默认输出 JSONL。只有源文件较小、天然结构化,并且可以明确安全地放入内存时才使用 JSON。 - -选择当前已安装且最简单的运行时: - -- JSON、JSONL、HTML 和 JavaScript 风格数据优先使用 Node.js; -- CSV、SQLite、XML、编码复杂的文本和表格数据优先使用 Python; -- Shell 只负责发现文件和组合命令。 - -转换器必须: - -- 通过参数接收输入、输出路径,不写死本机路径; -- 保持源文件不变,并拒绝覆盖源文件; -- 先写临时输出,成功后再重命名; -- 保留源顺序,或按时间戳加稳定的源序号排序; -- 优先保留源 ID;没有消息 ID 时省略可选字段,只有回复关联需要时才生成确定性 ID; -- 缺少成员 ID 时根据稳定身份字段确定性生成,绝不能使用随机值; -- 大文件流式处理,进度输出不得包含消息正文; -- 解析失败时以非零状态退出,并输出源记录数、输出数和跳过数汇总。 - -`skipped` 默认为零。把未知消息映射为类型 `99` 属于保留,不计为跳过。 - -### 5. 先证明小样本 - -先对受限的本地样本或脚本的 sample 模式运行转换器,然后验证。有 CLI 时运行: - -```bash -clb validate "/absolute/path/to/sample.jsonl" --json -``` - -没有 CLI 时,定位本 `SKILL.md` 所在目录,并使用随 Skill 分发的验证器: - -```bash -node "/absolute/path/to/chatlab-convert-cn/scripts/validate-chatlab.mjs" "/absolute/path/to/sample.jsonl" -``` - -修复全部 error 后才能全量转换。逐项检查 warning 并说明为什么可以接受,不能自动忽略。 - -### 6. 全量转换并双重验证 - -运行完整转换,然后使用 CLI 或内置验证器验证每一个输出文件。有 CLI 时继续执行导入 dry-run: - -```bash -clb validate "/absolute/path/to/converted.jsonl" --json -clb import "/absolute/path/to/converted.jsonl" --dry-run --json -``` - -没有 CLI 时运行: - -```bash -node "/absolute/path/to/chatlab-convert-cn/scripts/validate-chatlab.mjs" "/absolute/path/to/converted.jsonl" -``` - -按验证能力区分结果,不能混用结论: - -- **格式验证通过**:CLI 或内置严格验证器返回 `ok: true`,转换器输出消息数等于验证器消息数,源消息数等于输出消息数加上用户明确接受的跳过数,且没有隐藏源解析错误; -- **导入验证通过**:在格式验证通过的基础上,`clb import --dry-run --json` 也返回 `ok: true`。 - -没有 CLI 时只能报告“格式验证通过”,并提醒用户之后可以安装 CLI 完成 dry-run,或将结果文件拖入 ChatLab;不能声称已经通过导入验证。 - -只有同时满足以下条件,才能认为转换结果已经通过完整导入验证: - -- 严格验证返回 `ok: true`; -- 导入 dry-run 返回 `ok: true`; -- 转换器输出消息数等于验证器消息数; -- 源消息数等于输出消息数加上用户明确接受的跳过数; -- 没有隐藏任何源解析错误。 - -### 7. 仅在用户要求时导入 - -如果用户只要求转换,在当前环境可完成的验证后停止。报告输出路径、脚本路径、会话数、成员数、消息数、跳过数、警告、映射限制和实际达到的验证级别,不要引用消息正文。 - -如果用户明确要求导入,使用同一个已验证文件移除 `--dry-run`: - -```bash -clb import "/absolute/path/to/converted.jsonl" --json -``` - -如果此时仍没有 CLI,说明自动导入需要 `chatlab-cli`,建议安装并征得用户同意;不能把“格式验证通过”当成已经导入。 - -存在多个输出时,逐个预览并独立导入。报告每个结果的会话 ID 和数量;不要删除转换脚本或转换结果。 diff --git a/skills/chatlab-convert-cn/agents/openai.yaml b/skills/chatlab-convert-cn/agents/openai.yaml deleted file mode 100644 index 52bbfa834..000000000 --- a/skills/chatlab-convert-cn/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: 'ChatLab 聊天转换' - short_description: '将暂不支持的聊天记录转换并验证为 ChatLab 标准格式' - default_prompt: '使用 $chatlab-convert-cn 将这个不受支持的聊天导出转换为经过验证的 ChatLab JSONL 文件。' diff --git a/skills/chatlab-convert-cn/references/chatlab-format.md b/skills/chatlab-convert-cn/references/chatlab-format.md deleted file mode 100644 index 6f4290f9d..000000000 --- a/skills/chatlab-convert-cn/references/chatlab-format.md +++ /dev/null @@ -1,144 +0,0 @@ -# ChatLab 转换协议 - -生成 ChatLab Format `0.0.2` 时遵循本协议。这里只保留转换器需要的内容;生成文件以 ChatLab CLI 或本 Skill 内置严格验证器的结果为准: - -```bash -clb validate "/absolute/path/to/output.jsonl" --json -node "/absolute/path/to/chatlab-convert-cn/scripts/validate-chatlab.mjs" "/absolute/path/to/output.jsonl" -``` - -两条命令按环境选择其一,不需要重复运行;内置验证器只验证格式,不代替 `clb import --dry-run --json` 的导入验证。 - -## 核心约束 - -- 一个输出文件只表示一个会话。 -- 使用 UTF-8。时间统一使用整数型 Unix **秒级**时间戳,绝不能使用毫秒。 -- `meta.type` 只能是 `group` 或 `private`。平台使用稳定的小写标识,例如 `qq`、`weixin` 或源产品名称。 -- `sender` 和 `ownerId` 引用成员的 `platformId`。 -- 保留每一条源记录。无法识别的消息映射为类型 `99`,不能丢弃。 -- 有原始成员 ID、消息 ID 时原样保留;即使源数据是数字,输出 ID 也使用字符串。 -- `platformMessageId` 可省略,但出现时必须唯一;`replyToMessageId` 引用其中一个消息 ID。 -- 消息按 `timestamp` 升序排列;时间相同时保留稳定的源序号。 -- `accountName` 表示账号名称,`groupNickname` 表示该会话中的昵称。 - -## 推荐的 JSONL 结构 - -每行写一个紧凑 JSON 对象。第一个非注释数据行是唯一的 `header`,随后写全部 `member`,最后写 `message`。 - -```jsonl -{"_type":"header","chatlab":{"version":"0.0.2","exportedAt":1711468800,"generator":"local-converter"},"meta":{"name":"示例群聊","platform":"example","type":"group","groupId":"group-1","ownerId":"member-1"}} -{"_type":"member","platformId":"member-1","accountName":"小红","groupNickname":"小红","aliases":["红红"],"roles":[{"id":"owner"}]} -{"_type":"member","platformId":"member-2","accountName":"小明"} -{"_type":"message","platformMessageId":"message-1","sender":"member-1","accountName":"小红","groupNickname":"小红","timestamp":1711468800,"type":0,"content":"你好"} -{"_type":"message","platformMessageId":"message-2","replyToMessageId":"message-1","sender":"member-2","accountName":"小明","timestamp":1711468810,"type":25,"content":"收到"} -``` - -允许空行和以 `#` 开头的注释行,但转换器通常不应生成它们。只有源数据完全没有成员信息时才省略成员行;只要写了成员行,就必须在第一条消息前写完所有发送者。 - -## JSON 结构 - -只有完整结果可以安全地放入内存时才使用 JSON。`members` 和 `messages` 都是必需数组。 - -```json -{ - "chatlab": { - "version": "0.0.2", - "exportedAt": 1711468800, - "generator": "local-converter" - }, - "meta": { - "name": "示例私聊", - "platform": "example", - "type": "private", - "ownerId": "member-1" - }, - "members": [ - { "platformId": "member-1", "accountName": "小红" }, - { "platformId": "member-2", "accountName": "小明" } - ], - "messages": [ - { - "sender": "member-1", - "accountName": "小红", - "timestamp": 1711468800, - "type": 0, - "content": "你好" - } - ] -} -``` - -## 字段 - -### 文件头 - -| 路径 | 必需 | 约束 | -| --------------------- | ---- | ---------------------- | -| `chatlab.version` | 是 | 固定为 `0.0.2` | -| `chatlab.exportedAt` | 是 | 整数型 Unix 秒级时间戳 | -| `chatlab.generator` | 否 | 转换器名称 | -| `chatlab.description` | 否 | 不含隐私的简短说明 | -| `meta.name` | 是 | 会话显示名称 | -| `meta.platform` | 是 | 稳定的平台标识 | -| `meta.type` | 是 | `group` 或 `private` | -| `meta.groupId` | 否 | 原始群组/会话 ID | -| `meta.groupAvatar` | 否 | Data URL 或网络 URL | -| `meta.ownerId` | 否 | 导出者的 `platformId` | - -### 成员 - -| 字段 | 必需 | 约束 | -| --------------- | ---- | ------------------------------------------------ | -| `platformId` | 是 | 稳定且唯一的字符串身份 | -| `accountName` | 是 | 账号显示名称 | -| `groupNickname` | 否 | 当前会话中的显示名称 | -| `aliases` | 否 | 字符串数组 | -| `avatar` | 否 | Data URL 或网络 URL | -| `roles` | 否 | 如 `[{"id":"owner"}]`、`[{"id":"admin"}]` 的数组 | - -### 消息 - -| 字段 | 必需 | 约束 | -| ------------------- | ---- | ------------------------------------- | -| `sender` | 是 | 发送者的 `platformId` | -| `accountName` | 是 | 发送时的账号名称 | -| `groupNickname` | 否 | 发送时的群昵称 | -| `timestamp` | 是 | 整数型 Unix 秒级时间戳 | -| `type` | 是 | 使用下表中的数字 | -| `content` | 是 | 字符串或 `null`;尽量保留有用的源表示 | -| `platformMessageId` | 否 | 唯一的原始消息 ID | -| `replyToMessageId` | 否 | 被回复消息的原始 ID | - -## 消息类型 - -| 值 | 含义 | 值 | 含义 | -| ---- | ------------- | ---- | --------- | -| `0` | 文本 | `1` | 图片 | -| `2` | 语音 | `3` | 视频 | -| `4` | 文件 | `5` | 表情/贴纸 | -| `7` | 链接/卡片 | `8` | 位置 | -| `20` | 红包 | `21` | 转账 | -| `22` | 拍一拍/戳一戳 | `23` | 通话 | -| `24` | 分享 | `25` | 回复 | -| `26` | 转发消息 | `27` | 名片 | -| `80` | 系统事件 | `81` | 撤回消息 | -| `99` | 其他/未知 | | | - -媒体消息没有内嵌正文时,在 `content` 中保留有用的占位符或源文件相对路径;只有源数据确实没有任何表示时才使用 `null`。 - -## 身份兜底规则 - -优先使用源平台不可变的用户 ID。确实缺少时,根据同一平台、同一会话内的稳定身份字段确定性生成,例如对规范化账号身份计算 SHA-256,并添加不敏感的前缀。成员顺序可能变化时不能只使用数组下标,绝不能使用随机值。 - -有原始消息 ID 时保留;没有时通常省略 `platformMessageId`。只有回复关系必须依赖消息 ID 时才生成确定性 ID,并纳入会话身份、发送者、秒级时间戳、稳定源序号和正文摘要,确保重复执行结果一致。 - -## 必须核对的数量 - -转换器必须在不输出正文的前提下报告: - -- 源会话数和源消息记录数; -- 输出会话数、成员数和消息数; -- 映射为类型 `99` 的记录数; -- 跳过数和失败数。 - -跳过数和失败数默认为零。只有源消息数等于输出消息数加用户明确接受的跳过数、严格验证通过,并且 `clb import --dry-run --json` 成功,转换才算完成。 diff --git a/skills/chatlab-convert-cn/scripts/validate-chatlab.mjs b/skills/chatlab-convert-cn/scripts/validate-chatlab.mjs deleted file mode 100644 index a21f8ff08..000000000 --- a/skills/chatlab-convert-cn/scripts/validate-chatlab.mjs +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env node -// Generated from the canonical ChatLab validator. Do not edit directly. -/* eslint-disable */ -import{existsSync as H}from"node:fs";import j from"node:path";import{createReadStream as P}from"node:fs";import{readFile as v,stat as k}from"node:fs/promises";import{extname as D}from"node:path";import{createInterface as V}from"node:readline";var R="0.0.2",S=["0.0.1",R];function C(e){return S.includes(e)}var h=(s=>(s[s.TEXT=0]="TEXT",s[s.IMAGE=1]="IMAGE",s[s.VOICE=2]="VOICE",s[s.VIDEO=3]="VIDEO",s[s.FILE=4]="FILE",s[s.EMOJI=5]="EMOJI",s[s.LINK=7]="LINK",s[s.LOCATION=8]="LOCATION",s[s.RED_PACKET=20]="RED_PACKET",s[s.TRANSFER=21]="TRANSFER",s[s.POKE=22]="POKE",s[s.CALL=23]="CALL",s[s.SHARE=24]="SHARE",s[s.REPLY=25]="REPLY",s[s.FORWARD=26]="FORWARD",s[s.CONTACT=27]="CONTACT",s[s.SYSTEM=80]="SYSTEM",s[s.RECALL=81]="RECALL",s[s.OTHER=99]="OTHER",s))(h||{});var G=100,M=1e11,F=new Set(Object.values(h).filter(e=>typeof e=="number")),E="SYSTEM",$=new Set([80,81]);function y(e,t){return{format:e,fileSize:t,errorCount:0,warningCount:0,issues:[],stats:{headerCount:0,memberCount:0,messageCount:0,uniqueSenderCount:0,commentLineCount:0,blankLineCount:0},memberIds:new Set,ownerIds:new Set,senderIds:new Set,messageIds:new Set,replyTargetIds:new Set}}function a(e,t,r,n,o={}){t==="error"?e.errorCount++:e.warningCount++,e.issues.length0?!0:(a(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",p(n,r)),!1)}function u(e,t,r,n,o){c(t,r)&&typeof t[r]!="string"&&a(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",p(o,n))}function x(e,t,r,n){return typeof t=="number"&&Number.isInteger(t)&&t>=0&&ttypeof o!="string"))&&a(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...n,path:`${r}.aliases`}),c(t,"roles")&&(Array.isArray(t.roles)?t.roles.forEach((o,i)=>{let g=`${r}.roles[${i}]`;if(!d(o)){a(e,"error","INVALID_ROLE","Expected a role object.",{...n,path:g});return}m(e,o.id,`${g}.id`,n),u(e,o,"name",`${g}.name`,n)}):a(e,"error","INVALID_ROLES","Expected roles to be an array.",{...n,path:`${r}.roles`}))}function N(e,t,r,n){if(e.stats.messageCount++,!d(t)){a(e,"error","INVALID_MESSAGE","Expected a message object.",p(n,r));return}let o=m(e,t.sender,`${r}.sender`,n)?t.sender:void 0;o!==void 0&&e.senderIds.add(o),m(e,t.accountName,`${r}.accountName`,n),x(e,t.timestamp,`${r}.timestamp`,n)&&(e.lastTimestamp!==void 0&&t.timestamp0?e.memberIds:t?void 0:e.senderIds;if(n)for(let i of e.ownerIds)n.has(i)||a(e,"error","UNKNOWN_OWNER","meta.ownerId must reference a member platformId.");if(e.memberIds.size>0)for(let i of e.senderIds)i!==E&&!e.memberIds.has(i)&&a(e,"error","UNKNOWN_MESSAGE_SENDER","Every message sender must reference an explicit member platformId.");for(let i of e.replyTargetIds)e.messageIds.has(i)||a(e,"warning","UNKNOWN_REPLY_TARGET","A replyToMessageId does not match any platformMessageId in this file.");e.stats.uniqueSenderCount=e.senderIds.size;let o=e.errorCount+e.warningCount;return{valid:e.errorCount===0,format:e.format,version:e.version,fileSize:e.fileSize,errorCount:e.errorCount,warningCount:e.warningCount,issues:e.issues,truncatedIssueCount:Math.max(0,o-e.issues.length),stats:e.stats}}async function U(e,t){let r=y("json",t),n;try{n=JSON.parse(await v(e,"utf8"))}catch{return a(r,"error","INVALID_JSON","The file is not valid JSON."),b(r,!0,!1)}return d(n)?(T(r,n.chatlab,n.meta,{}),Array.isArray(n.members)?n.members.forEach((o,i)=>_(r,o,`members[${i}]`,{})):a(r,"error","INVALID_MEMBERS","Expected members to be an array.",{path:"members"}),Array.isArray(n.messages)?n.messages.forEach((o,i)=>N(r,o,`messages[${i}]`,{})):a(r,"error","INVALID_MESSAGES","Expected messages to be an array.",{path:"messages"}),b(r,!0)):(a(r,"error","INVALID_ROOT","Expected a top-level JSON object."),b(r,!0,!1))}async function z(e,t){let r=y("jsonl",t),n=P(e,{encoding:"utf8"}),o=V({input:n,crlfDelay:1/0}),i=0,g=0,A=!1;for await(let w of o){i++;let I=w.trim();if(!I){r.stats.blankLineCount++;continue}if(I.startsWith("#")){r.stats.commentLineCount++;continue}g++;let l;try{l=JSON.parse(I)}catch{a(r,"error","INVALID_JSONL_LINE","The line is not valid JSON.",{line:i});continue}if(!d(l)){a(r,"error","INVALID_JSONL_VALUE","Expected a JSON object on every data line.",{line:i});continue}switch(g===1&&l._type!=="header"&&a(r,"error","HEADER_NOT_FIRST","The first data line must be a header.",{line:i,path:"_type"}),l._type){case"header":r.stats.headerCount>0&&a(r,"error","DUPLICATE_HEADER","Only one header line is allowed.",{line:i,path:"_type"}),T(r,l.chatlab,l.meta,{line:i});break;case"member":A&&a(r,"error","MEMBER_AFTER_MESSAGE","Member lines must appear before message lines.",{line:i}),_(r,l,`line[${i}]`,{line:i});break;case"message":A=!0,N(r,l,`line[${i}]`,{line:i});break;default:a(r,"error","UNKNOWN_LINE_TYPE","Expected _type to be header, member, or message.",{line:i,path:"_type"})}}return b(r,!1)}async function L(e){let t=await k(e),r=D(e).toLowerCase();if(r===".json")return U(e,t.size);if(r===".jsonl")return z(e,t.size);let n=y("unknown",t.size);return a(n,"error","UNSUPPORTED_EXTENSION","Expected a .json or .jsonl file."),b(n,!1,!1)}var O=process.argv[2];function f(e){process.stdout.write(`${JSON.stringify(e)} -`)}if(!O)f({ok:!1,command:"validate",error:{code:"MISSING_FILE",message:"Usage: node validate-chatlab.mjs "},meta:{apiVersion:1,standalone:!0}}),process.exitCode=2;else{let e=j.resolve(O);if(!H(e))f({ok:!1,command:"validate",error:{code:"FILE_NOT_FOUND",message:`File not found: ${e}`,hint:"Pass an absolute path or a path relative to the current working directory."},meta:{apiVersion:1,standalone:!0}}),process.exitCode=3;else try{let t=await L(e);f(t.valid?{ok:!0,command:"validate",data:t,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:t,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${t.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),t.valid||(process.exitCode=1)}catch(t){f({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:t instanceof Error?t.message:String(t)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}} diff --git a/skills/chatlab-convert/SKILL.md b/skills/chatlab-convert/SKILL.md index 7eb2cb96a..7d35b93b0 100644 --- a/skills/chatlab-convert/SKILL.md +++ b/skills/chatlab-convert/SKILL.md @@ -1,21 +1,18 @@ --- name: chatlab-convert -description: Convert unsupported local chat exports into validated ChatLab JSONL or JSON by inspecting their structure, writing and running a local Node.js or Python converter, and verifying record counts before import. Use when ChatLab cannot recognize a CSV, HTML, TXT, XML, SQLite/database, vendor-specific JSON/JSONL, archive, or other chat export format, or when a user asks an agent to adapt an unknown chat format for ChatLab. +description: >- + Convert unsupported local chat exports into validated ChatLab JSONL or JSON with a reusable local script. 用可复用的本地脚本,将不受支持的聊天导出转换并验证为 ChatLab JSONL 或 JSON。 --- # ChatLab Convert Convert an unsupported local export without modifying the source. Prefer a reproducible script and a validated JSONL result over a one-off rewritten file. -Install this skill with: - -```bash -npx skills add ChatLab/ChatLab --skill chatlab-convert -g -``` +Follow the user's requested response language; otherwise use the conversation language, preserving the user's Chinese variant when applicable. Keep commands, field names, IDs, and evidence markers unchanged. ## Non-negotiable rules -- Keep every source file read-only. Write scripts, samples, extracted archives, and output to a separate sibling directory. +- Keep every source file read-only. Write scripts, samples, extracted archives, and output to a separate working directory, using the user's output location when specified. - Keep chat data local. Do not upload it, send it to a network service, or dump message bodies into model-visible terminal output. - Inspect structure first: keys, column names, value types, counts, timestamp shapes, and masked samples. Replace message text in diagnostic output with its type and length. - Never silently skip a malformed or unsupported source record. Fail with its source row/index, or preserve it as message type `99` and count the mapping explicitly. @@ -26,7 +23,7 @@ npx skills add ChatLab/ChatLab --skill chatlab-convert -g ### 1. Confirm the source and CLI -Resolve the exact source path. If several files could be the export, ask the user instead of guessing. Quote paths in every command. +Resolve the source from the request and conversation; ask only if it remains ambiguous. Quote paths in every command. Check available capabilities once, reusing results within the task. ```bash clb --help @@ -35,18 +32,11 @@ clb formats clb import "/absolute/path/to/source" --dry-run --json ``` -If the dry run recognizes the source, do not convert it. Use `chatlab-import` for the supported file. - -If `clb` is missing, or the installed version has no `validate` command: +If the dry run recognizes the source, report that conversion is unnecessary. Import only if requested, using `chatlab-import` when available or the preview/write steps below. -1. Tell the user that ChatLab CLI was not detected but conversion can continue with this Skill's bundled strict validator. -2. Recommend installing or updating the CLI, and obtain permission before running `npm install -g chatlab-cli@latest`. -3. If the user skips installation, installation fails, or the network is unavailable, continue with `scripts/validate-chatlab.mjs`. -4. Skip `clb formats` and the source dry run, and state that native ChatLab support for the source format could not be checked. +If `clb validate` is unavailable, continue with `scripts/validate-chatlab.mjs` without pausing conversion to propose installation. The bundled validator needs Node.js 22.19 or later. An older CLI may still support `formats` and import dry-run; use the capabilities it actually provides. If no CLI is available, state that native format support and import readiness could not be checked. -The bundled validator requires Node.js 22.19 or later. If `node --version` is also unavailable, the converter may still be written, but mark the result as “not yet validated” and guide the user to install Node.js and `chatlab-cli`. Do not install either without permission. - -Do not replace either the CLI validator or bundled strict validator with visual inspection. +If neither strict validator can run, finish the converter where possible and report the output as unvalidated. Do not replace strict validation with visual inspection. Request installation authorization only when a missing runtime or CLI blocks the requested outcome. ### 2. Inspect without exposing content @@ -64,7 +54,7 @@ For an archive, extract into the separate working directory and reject entries t ### 3. Define the mapping -Read [references/chatlab-format.md](references/chatlab-format.md) completely before writing the converter. Record the mapping from source fields to ChatLab fields, including: +Read the field contract and chosen output format in [references/chatlab-format.md](references/chatlab-format.md) before writing the converter. Record the mapping from source fields to ChatLab fields, including: - conversation boundaries and group/private type; - stable member identity and owner identity; @@ -80,11 +70,7 @@ One ChatLab file represents one conversation. If the source contains multiple co Prefer JSONL. Use JSON only for a small, naturally structured export where holding the full result in memory is clearly safe. -Choose the simplest installed runtime: - -- Node.js for JSON, JSONL, HTML, and JavaScript-shaped exports; -- Python for CSV, SQLite, XML, encoding-heavy text, and tabular data; -- shell for file discovery and command composition only. +Use the simplest suitable installed Node.js or Python runtime; the source format does not mandate a particular language. The converter must: @@ -101,7 +87,7 @@ Default `skipped` to zero. Mapping an unknown message to type `99` is preservati ### 5. Prove a small sample -Run the converter on a bounded local sample or sample mode first. With the CLI available, validate that output with: +For a large file or uncertain mapping, run a bounded local sample first. A small input can be converted and validated in full directly. With the CLI available, validate that output with: ```bash clb validate "/absolute/path/to/sample.jsonl" --json @@ -137,14 +123,6 @@ Keep the result levels distinct: Without the CLI, report only “format validated.” Tell the user they can install the CLI later to run the dry-run or drag the result into ChatLab. Do not claim import validation passed. -Require all of the following before calling the result fully import validated: - -- strict validation returns `ok: true`; -- the import dry run returns `ok: true`; -- converter output message count equals the validator message count; -- source message count equals output message count plus explicitly accepted skipped records; -- no source parse error was hidden. - ### 7. Import only when requested If the user asked only to convert, stop after all validation available in the current environment. Report output and script paths, conversations, members, messages, skipped records, warnings, mapping limitations, and the validation level actually reached without quoting messages. diff --git a/skills/chatlab-convert/agents/openai.yaml b/skills/chatlab-convert/agents/openai.yaml index fb5d72c74..4c4215d1a 100644 --- a/skills/chatlab-convert/agents/openai.yaml +++ b/skills/chatlab-convert/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: 'ChatLab Convert' - short_description: 'Convert unsupported chat exports into ChatLab format' + display_name: 'ChatLab Convert · 聊天转换' + short_description: 'Convert unsupported chat exports · 转换不受支持的聊天格式' default_prompt: 'Use $chatlab-convert to convert this unsupported chat export into a validated ChatLab JSONL file.' diff --git a/skills/chatlab-convert/scripts/validate-chatlab.mjs b/skills/chatlab-convert/scripts/validate-chatlab.mjs index a21f8ff08..8c2bd1b1c 100644 --- a/skills/chatlab-convert/scripts/validate-chatlab.mjs +++ b/skills/chatlab-convert/scripts/validate-chatlab.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node // Generated from the canonical ChatLab validator. Do not edit directly. /* eslint-disable */ -import{existsSync as H}from"node:fs";import j from"node:path";import{createReadStream as P}from"node:fs";import{readFile as v,stat as k}from"node:fs/promises";import{extname as D}from"node:path";import{createInterface as V}from"node:readline";var R="0.0.2",S=["0.0.1",R];function C(e){return S.includes(e)}var h=(s=>(s[s.TEXT=0]="TEXT",s[s.IMAGE=1]="IMAGE",s[s.VOICE=2]="VOICE",s[s.VIDEO=3]="VIDEO",s[s.FILE=4]="FILE",s[s.EMOJI=5]="EMOJI",s[s.LINK=7]="LINK",s[s.LOCATION=8]="LOCATION",s[s.RED_PACKET=20]="RED_PACKET",s[s.TRANSFER=21]="TRANSFER",s[s.POKE=22]="POKE",s[s.CALL=23]="CALL",s[s.SHARE=24]="SHARE",s[s.REPLY=25]="REPLY",s[s.FORWARD=26]="FORWARD",s[s.CONTACT=27]="CONTACT",s[s.SYSTEM=80]="SYSTEM",s[s.RECALL=81]="RECALL",s[s.OTHER=99]="OTHER",s))(h||{});var G=100,M=1e11,F=new Set(Object.values(h).filter(e=>typeof e=="number")),E="SYSTEM",$=new Set([80,81]);function y(e,t){return{format:e,fileSize:t,errorCount:0,warningCount:0,issues:[],stats:{headerCount:0,memberCount:0,messageCount:0,uniqueSenderCount:0,commentLineCount:0,blankLineCount:0},memberIds:new Set,ownerIds:new Set,senderIds:new Set,messageIds:new Set,replyTargetIds:new Set}}function a(e,t,r,n,o={}){t==="error"?e.errorCount++:e.warningCount++,e.issues.length0?!0:(a(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",p(n,r)),!1)}function u(e,t,r,n,o){c(t,r)&&typeof t[r]!="string"&&a(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",p(o,n))}function x(e,t,r,n){return typeof t=="number"&&Number.isInteger(t)&&t>=0&&ttypeof o!="string"))&&a(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...n,path:`${r}.aliases`}),c(t,"roles")&&(Array.isArray(t.roles)?t.roles.forEach((o,i)=>{let g=`${r}.roles[${i}]`;if(!d(o)){a(e,"error","INVALID_ROLE","Expected a role object.",{...n,path:g});return}m(e,o.id,`${g}.id`,n),u(e,o,"name",`${g}.name`,n)}):a(e,"error","INVALID_ROLES","Expected roles to be an array.",{...n,path:`${r}.roles`}))}function N(e,t,r,n){if(e.stats.messageCount++,!d(t)){a(e,"error","INVALID_MESSAGE","Expected a message object.",p(n,r));return}let o=m(e,t.sender,`${r}.sender`,n)?t.sender:void 0;o!==void 0&&e.senderIds.add(o),m(e,t.accountName,`${r}.accountName`,n),x(e,t.timestamp,`${r}.timestamp`,n)&&(e.lastTimestamp!==void 0&&t.timestamp0?e.memberIds:t?void 0:e.senderIds;if(n)for(let i of e.ownerIds)n.has(i)||a(e,"error","UNKNOWN_OWNER","meta.ownerId must reference a member platformId.");if(e.memberIds.size>0)for(let i of e.senderIds)i!==E&&!e.memberIds.has(i)&&a(e,"error","UNKNOWN_MESSAGE_SENDER","Every message sender must reference an explicit member platformId.");for(let i of e.replyTargetIds)e.messageIds.has(i)||a(e,"warning","UNKNOWN_REPLY_TARGET","A replyToMessageId does not match any platformMessageId in this file.");e.stats.uniqueSenderCount=e.senderIds.size;let o=e.errorCount+e.warningCount;return{valid:e.errorCount===0,format:e.format,version:e.version,fileSize:e.fileSize,errorCount:e.errorCount,warningCount:e.warningCount,issues:e.issues,truncatedIssueCount:Math.max(0,o-e.issues.length),stats:e.stats}}async function U(e,t){let r=y("json",t),n;try{n=JSON.parse(await v(e,"utf8"))}catch{return a(r,"error","INVALID_JSON","The file is not valid JSON."),b(r,!0,!1)}return d(n)?(T(r,n.chatlab,n.meta,{}),Array.isArray(n.members)?n.members.forEach((o,i)=>_(r,o,`members[${i}]`,{})):a(r,"error","INVALID_MEMBERS","Expected members to be an array.",{path:"members"}),Array.isArray(n.messages)?n.messages.forEach((o,i)=>N(r,o,`messages[${i}]`,{})):a(r,"error","INVALID_MESSAGES","Expected messages to be an array.",{path:"messages"}),b(r,!0)):(a(r,"error","INVALID_ROOT","Expected a top-level JSON object."),b(r,!0,!1))}async function z(e,t){let r=y("jsonl",t),n=P(e,{encoding:"utf8"}),o=V({input:n,crlfDelay:1/0}),i=0,g=0,A=!1;for await(let w of o){i++;let I=w.trim();if(!I){r.stats.blankLineCount++;continue}if(I.startsWith("#")){r.stats.commentLineCount++;continue}g++;let l;try{l=JSON.parse(I)}catch{a(r,"error","INVALID_JSONL_LINE","The line is not valid JSON.",{line:i});continue}if(!d(l)){a(r,"error","INVALID_JSONL_VALUE","Expected a JSON object on every data line.",{line:i});continue}switch(g===1&&l._type!=="header"&&a(r,"error","HEADER_NOT_FIRST","The first data line must be a header.",{line:i,path:"_type"}),l._type){case"header":r.stats.headerCount>0&&a(r,"error","DUPLICATE_HEADER","Only one header line is allowed.",{line:i,path:"_type"}),T(r,l.chatlab,l.meta,{line:i});break;case"member":A&&a(r,"error","MEMBER_AFTER_MESSAGE","Member lines must appear before message lines.",{line:i}),_(r,l,`line[${i}]`,{line:i});break;case"message":A=!0,N(r,l,`line[${i}]`,{line:i});break;default:a(r,"error","UNKNOWN_LINE_TYPE","Expected _type to be header, member, or message.",{line:i,path:"_type"})}}return b(r,!1)}async function L(e){let t=await k(e),r=D(e).toLowerCase();if(r===".json")return U(e,t.size);if(r===".jsonl")return z(e,t.size);let n=y("unknown",t.size);return a(n,"error","UNSUPPORTED_EXTENSION","Expected a .json or .jsonl file."),b(n,!1,!1)}var O=process.argv[2];function f(e){process.stdout.write(`${JSON.stringify(e)} -`)}if(!O)f({ok:!1,command:"validate",error:{code:"MISSING_FILE",message:"Usage: node validate-chatlab.mjs "},meta:{apiVersion:1,standalone:!0}}),process.exitCode=2;else{let e=j.resolve(O);if(!H(e))f({ok:!1,command:"validate",error:{code:"FILE_NOT_FOUND",message:`File not found: ${e}`,hint:"Pass an absolute path or a path relative to the current working directory."},meta:{apiVersion:1,standalone:!0}}),process.exitCode=3;else try{let t=await L(e);f(t.valid?{ok:!0,command:"validate",data:t,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:t,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${t.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),t.valid||(process.exitCode=1)}catch(t){f({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:t instanceof Error?t.message:String(t)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}} +import{existsSync as K}from"node:fs";import q from"node:path";import{createReadStream as O}from"node:fs";import{readFile as P,stat as L}from"node:fs/promises";import{extname as k}from"node:path";import{createInterface as D}from"node:readline";var x="0.0.2",C=["0.0.1",x];function f(e){return C.includes(e)}var y=(s=>(s[s.TEXT=0]="TEXT",s[s.IMAGE=1]="IMAGE",s[s.VOICE=2]="VOICE",s[s.VIDEO=3]="VIDEO",s[s.FILE=4]="FILE",s[s.EMOJI=5]="EMOJI",s[s.LINK=7]="LINK",s[s.LOCATION=8]="LOCATION",s[s.RED_PACKET=20]="RED_PACKET",s[s.TRANSFER=21]="TRANSFER",s[s.POKE=22]="POKE",s[s.CALL=23]="CALL",s[s.SHARE=24]="SHARE",s[s.REPLY=25]="REPLY",s[s.FORWARD=26]="FORWARD",s[s.CONTACT=27]="CONTACT",s[s.SYSTEM=80]="SYSTEM",s[s.RECALL=81]="RECALL",s[s.OTHER=99]="OTHER",s))(y||{});var G=100,V=1e11,F=new Set(Object.values(y).filter(e=>typeof e=="number")),A="SYSTEM",$=new Set([80,81]);function I(e,t){return{format:e,fileSize:t,errorCount:0,warningCount:0,issues:[],stats:{headerCount:0,memberCount:0,messageCount:0,uniqueSenderCount:0,commentLineCount:0,blankLineCount:0},memberIds:new Set,ownerIds:new Set,senderIds:new Set,messageIds:new Set,replyTargetIds:new Set}}function a(e,t,r,n,o={}){t==="error"?e.errorCount++:e.warningCount++,e.issues.length0?!0:(a(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",p(n,r)),!1)}function u(e,t,r,n,o){l(t,r)&&typeof t[r]!="string"&&a(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",p(o,n))}function T(e,t,r,n){return typeof t=="number"&&Number.isInteger(t)&&t>=0&&ttypeof o!="string"))&&a(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...n,path:`${r}.aliases`}),l(t,"roles")&&(Array.isArray(t.roles)?t.roles.forEach((o,i)=>{let g=`${r}.roles[${i}]`;if(!d(o)){a(e,"error","INVALID_ROLE","Expected a role object.",{...n,path:g});return}m(e,o.id,`${g}.id`,n),u(e,o,"name",`${g}.name`,n)}):a(e,"error","INVALID_ROLES","Expected roles to be an array.",{...n,path:`${r}.roles`}))}function _(e,t,r,n){if(e.stats.messageCount++,!d(t)){a(e,"error","INVALID_MESSAGE","Expected a message object.",p(n,r));return}let o=m(e,t.sender,`${r}.sender`,n)?t.sender:void 0;o!==void 0&&e.senderIds.add(o),m(e,t.accountName,`${r}.accountName`,n),T(e,t.timestamp,`${r}.timestamp`,n)&&(e.lastTimestamp!==void 0&&t.timestamp0?e.memberIds:t?void 0:e.senderIds;if(n)for(let i of e.ownerIds)n.has(i)||a(e,"error","UNKNOWN_OWNER","meta.ownerId must reference a member platformId.");if(e.memberIds.size>0)for(let i of e.senderIds)i!==A&&!e.memberIds.has(i)&&a(e,"error","UNKNOWN_MESSAGE_SENDER","Every message sender must reference an explicit member platformId.");for(let i of e.replyTargetIds)e.messageIds.has(i)||a(e,"warning","UNKNOWN_REPLY_TARGET","A replyToMessageId does not match any platformMessageId in this file.");e.stats.uniqueSenderCount=e.senderIds.size;let o=e.errorCount+e.warningCount;return{valid:e.errorCount===0,format:e.format,version:e.version,fileSize:e.fileSize,errorCount:e.errorCount,warningCount:e.warningCount,issues:e.issues,truncatedIssueCount:Math.max(0,o-e.issues.length),stats:e.stats}}async function U(e,t){let r=I("json",t),n;try{n=JSON.parse(await P(e,"utf8"))}catch{return a(r,"error","INVALID_JSON","The file is not valid JSON."),b(r,!0,!1)}return d(n)?(v(r,n.chatlab,n.meta,{}),Array.isArray(n.members)?n.members.forEach((o,i)=>E(r,o,`members[${i}]`,{})):a(r,"error","INVALID_MEMBERS","Expected members to be an array.",{path:"members"}),Array.isArray(n.messages)?n.messages.forEach((o,i)=>_(r,o,`messages[${i}]`,{})):a(r,"error","INVALID_MESSAGES","Expected messages to be an array.",{path:"messages"}),b(r,!0)):(a(r,"error","INVALID_ROOT","Expected a top-level JSON object."),b(r,!0,!1))}async function z(e,t){let r=I("jsonl",t),n=O(e,{encoding:"utf8"}),o=D({input:n,crlfDelay:1/0}),i=0,g=0,R=!1;for await(let M of o){i++;let h=M.trim();if(!h){r.stats.blankLineCount++;continue}if(h.startsWith("#")){r.stats.commentLineCount++;continue}g++;let c;try{c=JSON.parse(h)}catch{a(r,"error","INVALID_JSONL_LINE","The line is not valid JSON.",{line:i});continue}if(!d(c)){a(r,"error","INVALID_JSONL_VALUE","Expected a JSON object on every data line.",{line:i});continue}switch(g===1&&c._type!=="header"&&a(r,"error","HEADER_NOT_FIRST","The first data line must be a header.",{line:i,path:"_type"}),c._type){case"header":r.stats.headerCount>0&&a(r,"error","DUPLICATE_HEADER","Only one header line is allowed.",{line:i,path:"_type"}),v(r,c.chatlab,c.meta,{line:i});break;case"member":R&&a(r,"error","MEMBER_AFTER_MESSAGE","Member lines must appear before message lines.",{line:i}),E(r,c,`line[${i}]`,{line:i});break;case"message":R=!0,_(r,c,`line[${i}]`,{line:i});break;default:a(r,"error","UNKNOWN_LINE_TYPE","Expected _type to be header, member, or message.",{line:i,path:"_type"})}}return b(r,!1)}async function N(e){let t=await L(e),r=k(e).toLowerCase();if(r===".json")return U(e,t.size);if(r===".jsonl")return z(e,t.size);let n=I("unknown",t.size);return a(n,"error","UNSUPPORTED_EXTENSION","Expected a .json or .jsonl file."),b(n,!1,!1)}var w=process.argv[2];function S(e){process.stdout.write(`${JSON.stringify(e)} +`)}if(!w)S({ok:!1,command:"validate",error:{code:"MISSING_FILE",message:"Usage: node validate-chatlab.mjs "},meta:{apiVersion:1,standalone:!0}}),process.exitCode=2;else{let e=q.resolve(w);if(!K(e))S({ok:!1,command:"validate",error:{code:"FILE_NOT_FOUND",message:`File not found: ${e}`,hint:"Pass an absolute path or a path relative to the current working directory."},meta:{apiVersion:1,standalone:!0}}),process.exitCode=3;else try{let t=await N(e);S(t.valid?{ok:!0,command:"validate",data:t,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:t,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${t.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),t.valid||(process.exitCode=1)}catch(t){S({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:t instanceof Error?t.message:String(t)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}} diff --git a/skills/chatlab-import-cn/SKILL.md b/skills/chatlab-import-cn/SKILL.md index a14b3cd6d..1c9a759f0 100644 --- a/skills/chatlab-import-cn/SKILL.md +++ b/skills/chatlab-import-cn/SKILL.md @@ -1,67 +1,59 @@ --- name: chatlab-import-cn -description: 通过 clb CLI 安全预览并将本地聊天导出文件导入 ChatLab。当用户要求外部 Agent 从本地 QQ、微信、Telegram、WhatsApp、LINE、Discord、Instagram、Google Chat、ChatLab JSON/JSONL 或其他受支持的聊天导出文件执行首次导入、重新导入或增量更新时使用。 +description: >- + Deprecated compatibility entry for local imports from released ChatLab clients; prefer chatlab-import for new installs. 旧版 ChatLab 导入命令的兼容入口,新安装请使用 chatlab-import。 --- -# ChatLab 聊天导入 +# ChatLab Import (Legacy Compatibility) -通过 `clb` CLI 导入本地聊天导出文件。始终先预览准确的写入计划。将用户明确提出的导入请求视为授权,预览成功后继续执行,不再请求二次确认。 +This deprecated entry preserves installation commands shipped in ChatLab v0.31.1 through v0.37.1. It includes the complete import workflow below and does not require `chatlab-import` to be installed. For new installations, use `npx skills add ChatLab/ChatLab --skill chatlab-import -g`; migrating is not required to complete an import. -## 适用场景 +Import local chat exports through the `clb` CLI. Always preview the exact write. Treat an explicit request to import as authorization to continue after a successful preview; do not ask for a second confirmation. -- 首次导入本地聊天导出文件。 -- 重新导入更新后的文件,只补充新增消息。 -- 让外部 Agent 安全处理格式识别和会话匹配。 +Follow the user's requested response language; otherwise use the conversation language, preserving the user's Chinese variant when applicable. Keep commands, field names, IDs, and evidence markers unchanged. -如果只需分析 ChatLab 中已有的聊天记录,请使用 `chatlab-analyze-cn`。 +For analysis of imported records, use `chatlab-analyze` when available. For an unsupported source, use `chatlab-convert` when available; neither skill is required for a supported import. -安装命令: +## Workflow -```bash -npx skills add ChatLab/ChatLab --skill chatlab-import-cn -g -``` - -## 工作流程 - -1. 检查 CLI,并确认唯一、准确的文件路径: +1. Check the CLI and resolve one exact file path: ```bash clb --help ``` -如果 CLI 未安装,告诉用户运行 `npm install -g chatlab-cli`。未经批准不要自行安装软件。存在多个候选文件时不要猜测,每条命令中的路径都要加引号。 +If the CLI is missing, tell the user to install it with `npm install -g chatlab-cli`. Do not install software without approval. Reuse the user's specified file and target. Ask only if the path remains ambiguous, and quote it in every command. -2. 只读预览导入计划: +2. Preview the import without writing: ```bash clb import "/absolute/path/to/chat-export.json" --dry-run --json ``` -如果用户选择了已有会话,在预览和正式导入中都加入 `--session-id `。 +If the user selected an existing session, include `--session-id ` in both preview and import. -3. 只总结新建或更新模式、目标会话 ID、扫描消息数、新增消息数、重复消息数,以及匹配方式或新建原因。不要引用消息正文。 +3. Summarize only the plan: create or update mode, target session ID, scanned messages, new messages, duplicates, and match method or create reason. Never quote message bodies. -4. 根据预览结果决定下一步: +4. Require `ok: true`, then read the plan from `data` and decide: -- 用户只要求预览或检查时,到此停止,不写入数据。 -- `importMode` 为 `incremental` 且 `newMessageCount` 为 `0` 时,告诉用户会话已经是最新状态,然后停止。 -- `importMode` 为 `incremental` 且存在新增消息时,无需再次确认,自动继续。 -- `importMode` 为 `created` 时,自动继续。如果 `createReason` 为 `ambiguous`,通过新建会话保护已有会话,并在最终结果中说明原因。 -- 结果不足以确定安全操作时,停止并说明缺少的信息。 +- If the user asked only to preview or inspect, stop without writing. +- If `importMode` is `incremental` and `newMessageCount` is `0`, report that the session is already up to date and stop. +- If `importMode` is `incremental` and there are new messages, continue automatically without another confirmation. +- If `importMode` is `created`, continue automatically. When `createReason` is `ambiguous`, preserve existing sessions by creating a new one and explain this choice in the final result. +- If the result does not identify a safe action, stop and explain what is missing. -5. 根据上一步可以写入时,使用相同文件、目标和格式移除 `--dry-run`: +5. When the decision permits writing, run the same command without `--dry-run`, keeping the file, target, and format unchanged: ```bash clb import "/absolute/path/to/chat-export.json" --json ``` -根据最终 JSON 信封报告结果会话 ID、导入模式、新增消息数和重复消息数。 +Report the resulting session ID, import mode, new-message count, and duplicate count from the final JSON envelope. -## 安全边界 +## Guardrails -- 绝不跳过预览、直接编辑 ChatLab 数据库、删除或合并会话。 -- 绝不泄露完整聊天导出文件或消息正文。 -- 绝不编造文件路径、解析格式或会话 ID。 -- 仅在修正方式明确时遵循 `error.hint`。 -- `FILE_NOT_FOUND` 要求正确路径;`UNRECOGNIZED_FORMAT` 查看 `clb formats`;`IMPORT_IN_PROGRESS` 稍后重试;`INVALID_SESSION_ID` 要求有效 ID。 -- 优先使用用户当前使用的中文变体回答,命令、参数、JSON 字段和错误码保持原样。 +- Never skip the preview, edit ChatLab databases directly, or delete or merge sessions. +- Never reveal a full chat export or message bodies. +- Never invent a file path, parser format, or session ID. +- Follow `error.hint` only when the correction is unambiguous. +- For `FILE_NOT_FOUND` or `INVALID_SESSION_ID`, resolve from available context or request the correct value. For `UNRECOGNIZED_FORMAT`, inspect `clb formats`. For `IMPORT_IN_PROGRESS`, retry once after the active import finishes; if still blocked, report it instead of polling indefinitely. diff --git a/skills/chatlab-import-cn/agents/openai.yaml b/skills/chatlab-import-cn/agents/openai.yaml deleted file mode 100644 index 60b92473e..000000000 --- a/skills/chatlab-import-cn/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: 'ChatLab 聊天导入' - short_description: '通过 ChatLab CLI 安全预览并导入本地聊天记录' - default_prompt: '使用 $chatlab-import-cn 安全预览并将这个聊天导出文件导入 ChatLab。' diff --git a/skills/chatlab-import/SKILL.md b/skills/chatlab-import/SKILL.md index 1edfdf494..7584f284f 100644 --- a/skills/chatlab-import/SKILL.md +++ b/skills/chatlab-import/SKILL.md @@ -1,25 +1,16 @@ --- name: chatlab-import -description: Safely preview and import local chat export files into ChatLab through the clb CLI. Use when a user asks an external agent to import, re-import, or incrementally update ChatLab from a local QQ, WeChat, Telegram, WhatsApp, LINE, Discord, Instagram, Google Chat, ChatLab JSON/JSONL, or other supported chat export. +description: >- + Preview and import supported local chat exports into ChatLab, including incremental updates. 预览并导入 ChatLab 支持的本地聊天导出,支持增量更新。 --- # ChatLab Import Import local chat exports through the `clb` CLI. Always preview the exact write. Treat an explicit request to import as authorization to continue after a successful preview; do not ask for a second confirmation. -## Good Fit +Follow the user's requested response language; otherwise use the conversation language, preserving the user's Chinese variant when applicable. Keep commands, field names, IDs, and evidence markers unchanged. -- Import a local chat export for the first time. -- Re-import a newer export and add only new messages. -- Let an external agent handle file detection and session matching safely. - -For read-only analysis of records already in ChatLab, use `chatlab-analyze` instead. - -Install this skill with: - -```bash -npx skills add ChatLab/ChatLab --skill chatlab-import -g -``` +For analysis of imported records, use `chatlab-analyze` when available. For an unsupported source, use `chatlab-convert` when available; neither skill is required for a supported import. ## Workflow @@ -29,7 +20,7 @@ npx skills add ChatLab/ChatLab --skill chatlab-import -g clb --help ``` -If the CLI is missing, tell the user to install it with `npm install -g chatlab-cli`. Do not install software without approval. Do not guess between multiple files, and quote the path in every command. +If the CLI is missing, tell the user to install it with `npm install -g chatlab-cli`. Do not install software without approval. Reuse the user's specified file and target. Ask only if the path remains ambiguous, and quote it in every command. 2. Preview the import without writing: @@ -41,7 +32,7 @@ If the user selected an existing session, include `--session-id ` in 3. Summarize only the plan: create or update mode, target session ID, scanned messages, new messages, duplicates, and match method or create reason. Never quote message bodies. -4. Decide from the preview: +4. Require `ok: true`, then read the plan from `data` and decide: - If the user asked only to preview or inspect, stop without writing. - If `importMode` is `incremental` and `newMessageCount` is `0`, report that the session is already up to date and stop. @@ -63,4 +54,4 @@ Report the resulting session ID, import mode, new-message count, and duplicate c - Never reveal a full chat export or message bodies. - Never invent a file path, parser format, or session ID. - Follow `error.hint` only when the correction is unambiguous. -- For `FILE_NOT_FOUND`, request the correct path. For `UNRECOGNIZED_FORMAT`, inspect `clb formats`. For `IMPORT_IN_PROGRESS`, retry later. For `INVALID_SESSION_ID`, request a valid ID. +- For `FILE_NOT_FOUND` or `INVALID_SESSION_ID`, resolve from available context or request the correct value. For `UNRECOGNIZED_FORMAT`, inspect `clb formats`. For `IMPORT_IN_PROGRESS`, retry once after the active import finishes; if still blocked, report it instead of polling indefinitely. diff --git a/skills/chatlab-import/agents/openai.yaml b/skills/chatlab-import/agents/openai.yaml index 0b5309f73..1c730735a 100644 --- a/skills/chatlab-import/agents/openai.yaml +++ b/skills/chatlab-import/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: 'ChatLab Import' - short_description: 'Preview and import chat exports with ChatLab CLI' + display_name: 'ChatLab Import · 聊天导入' + short_description: 'Preview and import chat exports · 预览并导入聊天记录' default_prompt: 'Use $chatlab-import to safely preview and import this chat export into ChatLab.' diff --git a/src/App.vue b/src/App.vue index c811f87cb..94f606289 100644 --- a/src/App.vue +++ b/src/App.vue @@ -41,12 +41,11 @@ const LockScreen = IS_ELECTRON ? defineAsyncComponent(() => import('@/components const DataDirCleanupNotice = defineAsyncComponent(() => import('@/components/common/DataDirCleanupNotice.vue')) const loadScreenCaptureModal = () => import('@/components/common/ScreenCaptureModal.vue') const loadSettingsModal = () => import('@/components/common/SettingsModal.vue') -const loadChatRecordDrawer = () => import('@/components/common/ChatRecord/ChatRecordDrawer.vue') +const loadChatRecordWorkspace = () => import('@/components/common/ChatRecord/ChatRecordWorkspace.vue') const loadGlobalTaskBar = () => import('@/components/AIChat/GlobalTaskBar.vue') const loadDebugToolsPanel = () => import('@/components/layout/DebugToolsPanel.vue') const ScreenCaptureModal = defineAsyncComponent(loadScreenCaptureModal) const SettingsModal = defineAsyncComponent(loadSettingsModal) -const ChatRecordDrawer = defineAsyncComponent(loadChatRecordDrawer) const GlobalTaskBar = defineAsyncComponent(loadGlobalTaskBar) const DebugToolsPanel = defineAsyncComponent(loadDebugToolsPanel) @@ -120,7 +119,7 @@ function scheduleNonCriticalUiPrefetch() { cancelNonCriticalUiPrefetch = null void Promise.allSettled([ loadSettingsModal(), - loadChatRecordDrawer(), + loadChatRecordWorkspace(), loadScreenCaptureModal(), loadDebugToolsPanel(), ]) @@ -359,7 +358,7 @@ useWindowsTitleBarOverlay([ () => route.fullPath, () => layoutStore.showSettings, () => layoutStore.showScreenCaptureModal, - () => layoutStore.showChatRecordDrawer, + () => layoutStore.sidePanelOpen, ]) onMounted(async () => { @@ -484,8 +483,6 @@ onUnmounted(() => { /> - - diff --git a/src/components/AIChat/ChatExplorer.vue b/src/components/AIChat/ChatExplorer.vue index 95e29c176..45f97f468 100644 --- a/src/components/AIChat/ChatExplorer.vue +++ b/src/components/AIChat/ChatExplorer.vue @@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import { useToast } from '@/composables/useToast' import ConversationList from './chat/ConversationList.vue' -import DataSourcePanel from './chat/DataSourcePanel.vue' import ChatMessage from './chat/ChatMessage.vue' import AIChatInput from './input/AIChatInput.vue' import AIThinkingIndicator from './chat/AIThinkingIndicator.vue' @@ -53,9 +52,6 @@ const initialAIChatId = typeof route.query.aiChatId === 'string' ? route.query.a const { initialization, messages, - sourceMessages, - currentKeywords, - isLoadingSource, isAIThinking, currentAIChatId, currentToolStatus, @@ -190,7 +186,6 @@ const currentPresetQuestions = computed(() => { const currentChatType = computed(() => props.chatType ?? 'group') // UI 状态 -const isSourcePanelCollapsed = ref(false) const hasLLMConfig = ref(false) const isCheckingConfig = ref(true) const configModalScrollToSection = ref(undefined) @@ -419,11 +414,6 @@ async function handleForkAIChat(messageId: string) { } } -// 切换数据源面板 -function toggleSourcePanel() { - isSourcePanelCollapsed.value = !isSourcePanelCollapsed.value -} - // 选择对话 async function handleSelectAIChat(convId: string) { await loadAIChat(convId) @@ -686,23 +676,6 @@ watch( - - - -
- -
-
@@ -754,18 +727,6 @@ watch( diff --git a/src/components/AIChat/chat/EvidenceBlock.vue b/src/components/AIChat/chat/EvidenceBlock.vue index 737e6515b..b4b0a0216 100644 --- a/src/components/AIChat/chat/EvidenceBlock.vue +++ b/src/components/AIChat/chat/EvidenceBlock.vue @@ -43,7 +43,7 @@ const statusDotClass: Record = { } function viewSource(messageId: number): void { - layoutStore.openChatRecordDrawer({ scrollToMessageId: messageId }) + layoutStore.openChatRecords({ scrollToMessageId: messageId }) } diff --git a/src/components/analysis/Overview/OverviewIdentityCard.vue b/src/components/analysis/Overview/OverviewIdentityCard.vue index edd336505..c54334395 100644 --- a/src/components/analysis/Overview/OverviewIdentityCard.vue +++ b/src/components/analysis/Overview/OverviewIdentityCard.vue @@ -273,8 +273,8 @@ onUnmounted(() => { {{ session.name }} -
-
+
+
diff --git a/src/components/analysis/SQLLab/ResultTable.vue b/src/components/analysis/SQLLab/ResultTable.vue index ae9ddc97e..f2223bfde 100644 --- a/src/components/analysis/SQLLab/ResultTable.vue +++ b/src/components/analysis/SQLLab/ResultTable.vue @@ -78,7 +78,7 @@ function getMessageIdColumnIndex(columns: string[]): number { * 查看消息上下文 */ function viewMessageContext(messageId: number) { - layoutStore.openChatRecordDrawer({ + layoutStore.openChatRecords({ scrollToMessageId: messageId, }) } diff --git a/src/components/analysis/SQLLab/SchemaPanel.vue b/src/components/analysis/SQLLab/SchemaPanel.vue index c564b795e..a7e5bc7c0 100644 --- a/src/components/analysis/SQLLab/SchemaPanel.vue +++ b/src/components/analysis/SQLLab/SchemaPanel.vue @@ -5,6 +5,7 @@ import type { TableSchema } from './types' import { useDataService } from '@/services' import { getTableLabel, getColumnLabel } from './types' import type { LocaleType } from '@/i18n/types' +import SidebarCollapseButton from '@/components/common/sidebar/SidebarCollapseButton.vue' const { t, locale } = useI18n() @@ -43,12 +44,6 @@ function toggleTable(tableName: string) { } } -// 展开表(从折叠状态点击时使用) -function expandTable(tableName: string) { - isCollapsed.value = false - expandedTables.value.add(tableName) -} - // 处理双击插入列名 function handleInsertColumn(tableName: string, columnName: string) { emit('insertColumn', tableName, columnName) @@ -67,96 +62,73 @@ onMounted(() => { diff --git a/src/components/analysis/quotes/CatchphraseTab.vue b/src/components/analysis/quotes/CatchphraseTab.vue index 170c35a39..a2e5781b2 100644 --- a/src/components/analysis/quotes/CatchphraseTab.vue +++ b/src/components/analysis/quotes/CatchphraseTab.vue @@ -53,7 +53,7 @@ async function loadCatchphraseAnalysis() { } function handlePhraseClick(content: string) { - layoutStore.openChatRecordDrawer(buildCatchphraseRecordQuery(content, props.timeFilter)) + layoutStore.openChatRecords(buildCatchphraseRecordQuery(content, props.timeFilter)) } function getOverviewPhrases(member: MemberCatchphrase) { diff --git a/src/components/analysis/quotes/HotRepeatTab.vue b/src/components/analysis/quotes/HotRepeatTab.vue index 135bca8bc..d1ad2ca18 100644 --- a/src/components/analysis/quotes/HotRepeatTab.vue +++ b/src/components/analysis/quotes/HotRepeatTab.vue @@ -48,7 +48,7 @@ async function loadRepeatAnalysis() { * 查看复读内容的聊天记录上下文 */ function viewRepeatContext(item: { content: string; firstMessageId: number }) { - layoutStore.openChatRecordDrawer({ + layoutStore.openChatRecords({ scrollToMessageId: item.firstMessageId, highlightKeywords: [item.content], }) diff --git a/src/components/analysis/quotes/LanguagePreferenceTab.vue b/src/components/analysis/quotes/LanguagePreferenceTab.vue index 3fffdacbb..4e31a0da4 100644 --- a/src/components/analysis/quotes/LanguagePreferenceTab.vue +++ b/src/components/analysis/quotes/LanguagePreferenceTab.vue @@ -43,7 +43,7 @@ async function loadData() { function handleWordClick(word: string) { if (!props.enableRecordNavigation) return - layoutStore.openChatRecordDrawer({ keywords: [word] }) + layoutStore.openChatRecords({ keywords: [word] }) } watch( diff --git a/src/components/analysis/quotes/WordcloudTab.vue b/src/components/analysis/quotes/WordcloudTab.vue index 27b792c7c..f459cbc07 100644 --- a/src/components/analysis/quotes/WordcloudTab.vue +++ b/src/components/analysis/quotes/WordcloudTab.vue @@ -430,7 +430,7 @@ watch(locale, () => { // 点击词语,打开聊天记录查看器 function handleWordClick(word: string) { if (!props.enableRecordNavigation) return - layoutStore.openChatRecordDrawer({ + layoutStore.openChatRecords({ keywords: [word], }) } diff --git a/src/components/common/ChatRecord/ChatRecordDrawer.vue b/src/components/common/ChatRecord/ChatRecordDrawer.vue deleted file mode 100644 index 78a7ff44a..000000000 --- a/src/components/common/ChatRecord/ChatRecordDrawer.vue +++ /dev/null @@ -1,146 +0,0 @@ - - - diff --git a/src/components/common/ChatRecord/ChatRecordWorkspace.vue b/src/components/common/ChatRecord/ChatRecordWorkspace.vue index 5c47e06c2..c251b563b 100644 --- a/src/components/common/ChatRecord/ChatRecordWorkspace.vue +++ b/src/components/common/ChatRecord/ChatRecordWorkspace.vue @@ -306,13 +306,14 @@ watch([() => props.active, fallbackSessionId, () => props.initialQuery], () => i />
- + + +
diff --git a/src/components/common/ChatRecord/ChatTopicsPanel.vue b/src/components/common/ChatRecord/ChatTopicsPanel.vue index 5966c5780..ec6d78439 100644 --- a/src/components/common/ChatRecord/ChatTopicsPanel.vue +++ b/src/components/common/ChatRecord/ChatTopicsPanel.vue @@ -37,7 +37,6 @@ const day = ref(null) const run = ref(null) const loading = ref(false) const actionLoading = ref(false) -const collapsed = ref(false) const showCreateModal = ref(false) const showDeleteModal = ref(false) type TopicRangeSelection = ChatTopicRangeKind | 'current' @@ -310,48 +309,30 @@ onUnmounted(() => { - - diff --git a/src/components/common/ChatRecord/FilterPanel.vue b/src/components/common/ChatRecord/FilterPanel.vue index abe04a1ae..d223b4838 100644 --- a/src/components/common/ChatRecord/FilterPanel.vue +++ b/src/components/common/ChatRecord/FilterPanel.vue @@ -124,8 +124,8 @@ function resetFilter() {