diff --git a/electron/main/ai/agent.ts b/electron/main/ai/agent.ts index 33d94d2..2d51041 100644 --- a/electron/main/ai/agent.ts +++ b/electron/main/ai/agent.ts @@ -9,6 +9,7 @@ import { getAllToolDefinitions, executeToolCalls } from './tools' import type { ToolContext, OwnerInfo } from './tools/types' import { aiLogger } from './logger' import { randomUUID } from 'crypto' +import { t as i18nT } from '../i18n' // 思考类标签列表(可按需扩展) const THINK_TAGS = ['think', 'analysis', 'reasoning', 'reflection', 'thought', 'thinking'] @@ -311,78 +312,11 @@ export interface PromptConfig { responseRules: string } -// 国际化内容 -const i18nContent = { - 'zh-CN': { - currentDateIs: '当前日期是', - chatTypeDesc: { private: '私聊记录', group: '群聊记录' }, - chatContext: { private: '对话', group: '群聊' }, - ownerNote: (displayName: string, platformId: string, chatContext: string) => `当前用户身份: -- 用户在${chatContext}中的身份是「${displayName}」(platformId: ${platformId}) -- 当用户提到"我"、"我的"时,指的就是「${displayName}」 -- 查询"我"的发言时,使用 sender_id 参数筛选该成员 -`, - memberNotePrivate: `成员查询策略: -- 私聊只有两个人,可以直接获取成员列表 -- 当用户提到"对方"、"他/她"时,通过 get_group_members 获取另一方信息 -`, - memberNoteGroup: `成员查询策略: -- 当用户提到特定群成员(如"张三说过什么"、"小明的发言"等)时,应先调用 get_group_members 获取成员列表 -- 群成员有三种名称:accountName(原始昵称)、groupNickname(群昵称)、aliases(用户自定义别名) -- 通过 get_group_members 的 search 参数可以模糊搜索这三种名称 -- 找到成员后,使用其 id 字段作为 search_messages 的 sender_id 参数来获取该成员的发言 -`, - timeParamsIntro: '时间参数:按用户提到的精度组合 year/month/day/hour', - timeParamExample1: (year: number) => `"10月" → year: ${year}, month: 10`, - timeParamExample2: (year: number) => `"10月1号" → year: ${year}, month: 10, day: 1`, - timeParamExample3: (year: number) => `"10月1号下午3点" → year: ${year}, month: 10, day: 1, hour: 15`, - defaultYearNote: (year: number, prevYear: number) => `未指定年份默认${year}年,若该月份未到则用${prevYear}年`, - responseInstruction: '根据用户的问题,选择合适的工具获取数据,然后基于数据给出回答。', - responseRulesTitle: '回答要求:', - // Fallback 角色定义:适中幽默,允许 B 站/网络热梗与表情 - fallbackRoleDefinition: (chatType: string) => `你是一个专业但风格轻松的${chatType}记录分析助手。 -你的任务是帮助用户理解和分析他们的${chatType}记录数据,同时可以适度使用 B 站/网络热梗和表情/颜文字活跃气氛,但不影响结论的准确性。`, - // Fallback 回答要求:强调严谨优先,适度玩梗 - fallbackResponseRules: `1. 基于工具返回的数据回答,不要编造信息 -2. 如果数据不足以回答问题,请说明 -3. 回答要简洁明了,使用 Markdown 格式 -4. 可以适度加入 B 站/网络热梗、表情/颜文字(强度适中) -5. 玩梗不得影响事实准确与结论清晰,避免低俗或冒犯性表达`, - }, - 'en-US': { - currentDateIs: 'Current date is', - chatTypeDesc: { private: 'private chat records', group: 'group chat records' }, - chatContext: { private: 'conversation', group: 'group chat' }, - ownerNote: (displayName: string, platformId: string, chatContext: string) => `Current user identity: -- The user's identity in this ${chatContext} is "${displayName}" (platformId: ${platformId}) -- When the user refers to "I" or "my", it refers to "${displayName}" -- When querying "my" messages, use the sender_id parameter to filter for this member -`, - memberNotePrivate: `Member query strategy: -- Private chats only have two participants, so the member list can be directly obtained -- When the user refers to "the other party" or "he/she", get the other participant's information via get_group_members -`, - memberNoteGroup: `Member query strategy: -- When the user refers to specific group members (e.g., "what did John say", "Mary's messages"), first call get_group_members to get the member list -- Group members have three names: accountName (original nickname), groupNickname (group nickname), aliases (user-defined aliases) -- The search parameter of get_group_members can be used for fuzzy searching these three names -- Once a member is found, use their id field as the sender_id parameter for search_messages to retrieve their messages -`, - timeParamsIntro: 'Time parameters: combine year/month/day/hour based on user mention', - timeParamExample1: (year: number) => `"October" → year: ${year}, month: 10`, - timeParamExample2: (year: number) => `"October 1st" → year: ${year}, month: 10, day: 1`, - timeParamExample3: (year: number) => `"October 1st 3 PM" → year: ${year}, month: 10, day: 1, hour: 15`, - defaultYearNote: (year: number, prevYear: number) => - `If year is not specified, defaults to ${year}. If the month has not yet occurred, ${prevYear} is used.`, - responseInstruction: - "Based on the user's question, select appropriate tools to retrieve data, then provide an answer based on the data.", - responseRulesTitle: 'Response requirements:', - fallbackRoleDefinition: (chatType: string) => `You are a professional ${chatType} analysis assistant. -Your task is to help users understand and analyze their ${chatType} data.`, - fallbackResponseRules: `1. Answer based on data returned by tools, do not fabricate information -2. If data is insufficient to answer, please state so -3. Keep answers concise and clear, use Markdown format`, - }, +// ==================== 国际化辅助(使用 i18next) ==================== + +/** 获取 Agent 翻译,根据传入的 locale 参数 */ +function agentT(key: string, locale: string, options?: Record): string { + return i18nT(key, { lng: locale, ...options }) } /** @@ -400,9 +334,8 @@ function getLockedPromptSection( ownerInfo?: OwnerInfo, locale: string = 'zh-CN' ): string { - const content = i18nContent[locale as keyof typeof i18nContent] || i18nContent['zh-CN'] const now = new Date() - const dateLocale = locale === 'zh-CN' ? 'zh-CN' : 'en-US' + const dateLocale = locale.startsWith('zh') ? 'zh-CN' : 'en-US' const currentDate = now.toLocaleDateString(dateLocale, { year: 'numeric', month: 'long', @@ -411,27 +344,35 @@ function getLockedPromptSection( }) const isPrivate = chatType === 'private' - const chatContext = content.chatContext[chatType] + const chatContext = agentT(`ai.agent.chatContext.${chatType}`, locale) // Owner 说明(当用户设置了"我是谁"时) - const ownerNote = ownerInfo ? content.ownerNote(ownerInfo.displayName, ownerInfo.platformId, chatContext) : '' + const ownerNote = ownerInfo + ? agentT('ai.agent.ownerNote', locale, { + displayName: ownerInfo.displayName, + platformId: ownerInfo.platformId, + chatContext, + }) + : '' // 成员说明(私聊只有2人) - const memberNote = isPrivate ? content.memberNotePrivate : content.memberNoteGroup + const memberNote = isPrivate + ? agentT('ai.agent.memberNotePrivate', locale) + : agentT('ai.agent.memberNoteGroup', locale) const year = now.getFullYear() const prevYear = year - 1 - return `${content.currentDateIs} ${currentDate}。 + return `${agentT('ai.agent.currentDateIs', locale)} ${currentDate}。 ${ownerNote} ${memberNote} -${content.timeParamsIntro} -- ${content.timeParamExample1(year)} -- ${content.timeParamExample2(year)} -- ${content.timeParamExample3(year)} -${content.defaultYearNote(year, prevYear)} +${agentT('ai.agent.timeParamsIntro', locale)} +- ${agentT('ai.agent.timeParamExample1', locale, { year })} +- ${agentT('ai.agent.timeParamExample2', locale, { year })} +- ${agentT('ai.agent.timeParamExample3', locale, { year })} +${agentT('ai.agent.defaultYearNote', locale, { year, prevYear })} -${content.responseInstruction}` +${agentT('ai.agent.responseInstruction', locale)}` } /** @@ -439,10 +380,7 @@ ${content.responseInstruction}` * 仅在前端未传递 promptConfig 时使用 */ function getFallbackRoleDefinition(chatType: 'group' | 'private', locale: string = 'zh-CN'): string { - const content = i18nContent[locale as keyof typeof i18nContent] || i18nContent['zh-CN'] - const chatTypeDesc = - chatType === 'private' ? (locale === 'zh-CN' ? '私聊' : 'private chat') : locale === 'zh-CN' ? '群聊' : 'group chat' - return content.fallbackRoleDefinition(chatTypeDesc) + return agentT(`ai.agent.fallbackRoleDefinition.${chatType}`, locale) } /** @@ -450,8 +388,7 @@ function getFallbackRoleDefinition(chatType: 'group' | 'private', locale: string * 仅在前端未传递 promptConfig 时使用 */ function getFallbackResponseRules(locale: string = 'zh-CN'): string { - const content = i18nContent[locale as keyof typeof i18nContent] || i18nContent['zh-CN'] - return content.fallbackResponseRules + return agentT('ai.agent.fallbackResponseRules', locale) } /** @@ -471,8 +408,6 @@ function buildSystemPrompt( ownerInfo?: OwnerInfo, locale: string = 'zh-CN' ): string { - const content = i18nContent[locale as keyof typeof i18nContent] || i18nContent['zh-CN'] - // 使用用户配置或 fallback const roleDefinition = promptConfig?.roleDefinition || getFallbackRoleDefinition(chatType, locale) const responseRules = promptConfig?.responseRules || getFallbackResponseRules(locale) @@ -485,7 +420,7 @@ function buildSystemPrompt( ${lockedSection} -${content.responseRulesTitle} +${agentT('ai.agent.responseRulesTitle', locale)} ${responseRules}` } @@ -634,7 +569,7 @@ export class Agent { aiLogger.warn('Agent', '达到最大工具调用轮数', { maxRounds: this.config.maxToolRounds }) this.messages.push({ role: 'user', - content: '请根据已获取的信息给出回答,不要再调用工具。', + content: agentT('ai.agent.answerWithoutTools', this.locale), }) const finalResponse = await chat(this.messages, this.config.llmOptions) @@ -877,7 +812,7 @@ export class Agent { this.messages.push({ role: 'user', - content: '请根据已获取的信息给出回答,不要再调用工具。', + content: agentT('ai.agent.answerWithoutTools', this.locale), }) // 最后一轮不带 tools(传入 abortSignal) @@ -993,7 +928,9 @@ export class Agent { // 添加工具结果消息 this.messages.push({ role: 'tool', - content: result.success ? JSON.stringify(result.result) : `错误: ${result.error}`, + content: result.success + ? JSON.stringify(result.result) + : agentT('ai.agent.toolError', this.locale, { error: result.error }), tool_call_id: tc.id, }) } diff --git a/electron/main/ai/llm/index.ts b/electron/main/ai/llm/index.ts index d56cc56..4bd1164 100644 --- a/electron/main/ai/llm/index.ts +++ b/electron/main/ai/llm/index.ts @@ -23,6 +23,7 @@ import { GeminiService, GEMINI_INFO } from './gemini' import { OpenAICompatibleService, OPENAI_COMPATIBLE_INFO } from './openai-compatible' import { aiLogger, extractErrorInfo, extractErrorStack } from '../logger' import { encryptApiKey, decryptApiKey, isEncrypted } from './crypto' +import { t } from '../../i18n' // 导出类型 export * from './types' @@ -299,7 +300,7 @@ export function addConfig(config: Omit= MAX_CONFIG_COUNT) { - return { success: false, error: `最多只能添加 ${MAX_CONFIG_COUNT} 个配置` } + return { success: false, error: t('llm.maxConfigs', { count: MAX_CONFIG_COUNT }) } } const now = Date.now() @@ -332,7 +333,7 @@ export function updateConfig( const index = store.configs.findIndex((c) => c.id === id) if (index === -1) { - return { success: false, error: '配置不存在' } + return { success: false, error: t('llm.configNotFound') } } store.configs[index] = { @@ -353,7 +354,7 @@ export function deleteConfig(id: string): { success: boolean; error?: string } { const index = store.configs.findIndex((c) => c.id === id) if (index === -1) { - return { success: false, error: '配置不存在' } + return { success: false, error: t('llm.configNotFound') } } store.configs.splice(index, 1) @@ -375,7 +376,7 @@ export function setActiveConfig(id: string): { success: boolean; error?: string const config = store.configs.find((c) => c.id === id) if (!config) { - return { success: false, error: '配置不存在' } + return { success: false, error: t('llm.configNotFound') } } store.activeConfigId = id @@ -542,7 +543,7 @@ export async function chat( const service = getCurrentLLMService() if (!service) { aiLogger.error('LLM', '服务未配置') - throw new Error('LLM 服务未配置,请先在设置中配置 API Key') + throw new Error(t('llm.notConfigured')) } try { @@ -597,7 +598,7 @@ export async function* chatStream(messages: ChatMessage[], options?: ChatOptions const service = getCurrentLLMService() if (!service) { aiLogger.error('LLM', '服务未配置(流式)') - throw new Error('LLM 服务未配置,请先在设置中配置 API Key') + throw new Error(t('llm.notConfigured')) } let chunkCount = 0 diff --git a/electron/main/ai/summary/index.ts b/electron/main/ai/summary/index.ts index c9b2f03..b8ff5d9 100644 --- a/electron/main/ai/summary/index.ts +++ b/electron/main/ai/summary/index.ts @@ -11,6 +11,7 @@ import Database from 'better-sqlite3' import { chat } from '../llm' import { getDbPath, openDatabase } from '../../database/core' import { aiLogger } from '../logger' +import { t } from '../../i18n' /** 最小消息数阈值(少于此数量不生成摘要) */ const MIN_MESSAGE_COUNT = 3 @@ -122,6 +123,7 @@ function getSummaryLengthLimit(messageCount: number): number { /** * 判断消息是否有意义(用于过滤) + * 支持中英文内容过滤 */ function isValidMessage(content: string): boolean { const trimmed = content.trim() @@ -129,19 +131,45 @@ function isValidMessage(content: string): boolean { // 过滤空内容 if (!trimmed) return false - // 过滤单字/双字无意义回复 + // 过滤单字/双字无意义回复(中文) if (trimmed.length <= 2) { - // 允许一些有意义的短词 - const meaningfulShort = ['好的', '不是', '是的', '可以', '不行', '好吧', '明白', '知道', '同意'] - if (!meaningfulShort.includes(trimmed)) return false + const meaningfulShortZh = ['好的', '不是', '是的', '可以', '不行', '好吧', '明白', '知道', '同意'] + if (!meaningfulShortZh.includes(trimmed)) return false } + // 过滤短无意义回复(英文,不区分大小写) + const lowerTrimmed = trimmed.toLowerCase() + const meaninglessShortEn = [ + 'ok', + 'k', + 'yes', + 'no', + 'ya', + 'yep', + 'nope', + 'lol', + 'haha', + 'hehe', + 'hmm', + 'ah', + 'oh', + 'wow', + 'thx', + 'ty', + 'np', + 'gg', + 'brb', + 'idk', + ] + if (meaninglessShortEn.includes(lowerTrimmed)) return false + // 过滤纯表情消息 const emojiOnlyPattern = /^[\p{Emoji}\s[\]()()]+$/u if (emojiOnlyPattern.test(trimmed)) return false - // 过滤占位符文本 + // 过滤占位符文本(中文 + 英文) const placeholders = [ + // 中文占位符(QQ/微信导出格式) '[图片]', '[语音]', '[视频]', @@ -153,12 +181,38 @@ function isValidMessage(content: string): boolean { '[红包]', '[转账]', '[撤回消息]', + // 英文占位符 + '[image]', + '[voice]', + '[video]', + '[file]', + '[sticker]', + '[animated sticker]', + '[location]', + '[contact]', + '[red packet]', + '[transfer]', + '[recalled message]', + '[photo]', + '[audio]', + '[gif]', ] - if (placeholders.some((p) => trimmed === p)) return false + if (placeholders.some((p) => lowerTrimmed === p.toLowerCase())) return false - // 过滤系统消息(入群、退群等) - const systemPatterns = [/^.*邀请.*加入了群聊$/, /^.*退出了群聊$/, /^.*撤回了一条消息$/, /^你撤回了一条消息$/] - if (systemPatterns.some((p) => p.test(trimmed))) return false + // 过滤系统消息(中文:入群、退群等) + const systemPatternsZh = [/^.*邀请.*加入了群聊$/, /^.*退出了群聊$/, /^.*撤回了一条消息$/, /^你撤回了一条消息$/] + if (systemPatternsZh.some((p) => p.test(trimmed))) return false + + // 过滤系统消息(英文) + const systemPatternsEn = [ + /^.*invited.*to the group$/i, + /^.*left the group$/i, + /^.*recalled a message$/i, + /^you recalled a message$/i, + /^.*joined the group$/i, + /^.*has been removed$/i, + ] + if (systemPatternsEn.some((p) => p.test(trimmed))) return false return true } @@ -282,17 +336,14 @@ export async function generateSessionSummary( // 2. 获取会话消息 const sessionData = getSessionMessagesForSummary(dbSessionId, chatSessionId) if (!sessionData) { - return { success: false, error: '会话不存在或数据库打开失败' } + return { success: false, error: t('summary.sessionNotFound') } } // 3. 检查消息数量 if (sessionData.messageCount < MIN_MESSAGE_COUNT) { return { success: false, - error: - locale === 'zh-CN' - ? `消息数量少于${MIN_MESSAGE_COUNT}条,无需生成摘要` - : `Message count less than ${MIN_MESSAGE_COUNT}, no need to generate summary`, + error: t('summary.tooFewMessages', { count: MIN_MESSAGE_COUNT }), } } @@ -301,10 +352,7 @@ export async function generateSessionSummary( if (validMessages.length < MIN_MESSAGE_COUNT) { return { success: false, - error: - locale === 'zh-CN' - ? `有效消息数量少于${MIN_MESSAGE_COUNT}条,无需生成摘要` - : `Valid message count less than ${MIN_MESSAGE_COUNT}, no need to generate summary`, + error: t('summary.tooFewValidMessages', { count: MIN_MESSAGE_COUNT }), } } @@ -362,7 +410,7 @@ export async function generateSessionSummary( async function generateDirectSummary(content: string, lengthLimit: number, locale: string): Promise { const response = await chat( [ - { role: 'system', content: '你是一个对话摘要专家,擅长用简洁的语言总结对话内容。' }, + { role: 'system', content: t('summary.systemPromptDirect') }, { role: 'user', content: buildSummaryPrompt(content, lengthLimit, locale) }, ], { @@ -391,7 +439,7 @@ async function generateMapReduceSummary( const segmentContent = formatMessages(segments[i]) const response = await chat( [ - { role: 'system', content: '你是一个对话摘要专家,擅长用简洁的语言总结对话内容。' }, + { role: 'system', content: t('summary.systemPromptDirect') }, { role: 'user', content: buildSubSummaryPrompt(segmentContent, locale) }, ], { @@ -409,7 +457,7 @@ async function generateMapReduceSummary( const mergeResponse = await chat( [ - { role: 'system', content: '你是一个对话摘要专家,擅长将多个摘要合并成一个连贯的总结。' }, + { role: 'system', content: t('summary.systemPromptMerge') }, { role: 'user', content: buildMergePrompt(subSummaries, lengthLimit, locale) }, ], { @@ -447,7 +495,7 @@ export async function generateSessionSummaries( if (result.success) { success++ - } else if (result.error?.includes('少于') || result.error?.includes('less than')) { + } else if (result.error?.includes('少于') || result.error?.includes('less than') || result.error?.includes('few')) { skipped++ } else { failed++ @@ -479,20 +527,20 @@ export function checkSessionsCanGenerateSummary( const sessionData = getSessionMessagesForSummary(dbSessionId, chatSessionId) if (!sessionData) { - results.set(chatSessionId, { canGenerate: false, reason: '会话不存在' }) + results.set(chatSessionId, { canGenerate: false, reason: t('summary.sessionNotExist') }) continue } // 检查原始消息数量 if (sessionData.messageCount < MIN_MESSAGE_COUNT) { - results.set(chatSessionId, { canGenerate: false, reason: '消息太少' }) + results.set(chatSessionId, { canGenerate: false, reason: t('summary.messagesTooFew') }) continue } // 预处理:过滤无意义消息 const validMessages = preprocessMessages(sessionData.messages) if (validMessages.length < MIN_MESSAGE_COUNT) { - results.set(chatSessionId, { canGenerate: false, reason: '有效消息太少' }) + results.set(chatSessionId, { canGenerate: false, reason: t('summary.validMessagesTooFew') }) continue } diff --git a/electron/main/ai/tools/index.ts b/electron/main/ai/tools/index.ts index 722f257..3b6c31b 100644 --- a/electron/main/ai/tools/index.ts +++ b/electron/main/ai/tools/index.ts @@ -6,6 +6,7 @@ import type { ToolDefinition, ToolCall } from '../llm/types' import type { ToolRegistry, RegisteredTool, ToolContext, ToolExecutionResult, ToolExecutor } from './types' import { isEmbeddingEnabled } from '../rag' +import { t as i18nT } from '../../i18n' // 导出类型 export * from './types' @@ -44,23 +45,65 @@ export async function ensureToolsInitialized(): Promise { return initPromise } +/** + * 翻译工具定义的 description 和参数 description + * 使用 i18next 查找翻译,如果未找到则保留原始文本(中文) + * + * i18n 键命名规则: + * - 工具描述:ai.tools.{toolName}.desc + * - 参数描述:ai.tools.{toolName}.params.{paramName} + */ +function translateToolDefinition(tool: ToolDefinition): ToolDefinition { + const name = tool.function.name + const descKey = `ai.tools.${name}.desc` + const translatedDesc = i18nT(descKey) + + // 深拷贝并翻译参数描述 + const translatedProperties: typeof tool.function.parameters.properties = {} + for (const [paramName, param] of Object.entries(tool.function.parameters.properties)) { + const paramKey = `ai.tools.${name}.params.${paramName}` + const translatedParamDesc = i18nT(paramKey) + translatedProperties[paramName] = { + ...param, + // 如果 i18next 返回的是 key 本身,说明没有找到翻译,保留原始文本 + description: translatedParamDesc !== paramKey ? translatedParamDesc : param.description, + } + } + + return { + type: tool.type, + function: { + name: tool.function.name, + // 如果 i18next 返回的是 key 本身,说明没有找到翻译,保留原始文本 + description: translatedDesc !== descKey ? translatedDesc : tool.function.description, + parameters: { + type: tool.function.parameters.type, + properties: translatedProperties, + required: tool.function.parameters.required, + }, + }, + } +} + /** * 获取所有已注册的工具定义 * 根据配置动态过滤工具(如:语义搜索工具仅在启用 Embedding 时可用) + * 根据当前 locale 动态翻译工具描述(解决"响应式"陷阱:每次调用时实时翻译) * @returns 工具定义数组(用于传给 LLM) */ export async function getAllToolDefinitions(): Promise { await ensureToolsInitialized() - const allTools = Array.from(toolRegistry.values()).map((t) => t.definition) + const allTools = Array.from(toolRegistry.values()).map((reg) => reg.definition) // 根据 Embedding 配置决定是否包含语义搜索工具 const embeddingEnabled = isEmbeddingEnabled() - if (!embeddingEnabled) { - return allTools.filter((t) => t.function.name !== 'semantic_search_messages') - } + const filteredTools = embeddingEnabled + ? allTools + : allTools.filter((tool) => tool.function.name !== 'semantic_search_messages') - return allTools + // 所有 locale 统一走翻译层,确保 locale 文件同构 + return filteredTools.map(translateToolDefinition) } /** @@ -87,7 +130,7 @@ export async function executeToolCall(toolCall: ToolCall, context: ToolContext): return { toolName, success: false, - error: `工具 "${toolName}" 未注册`, + error: i18nT('tools.notRegistered', { toolName }), } } diff --git a/electron/main/database/migrations.ts b/electron/main/database/migrations.ts index 7f741a4..8653a12 100644 --- a/electron/main/database/migrations.ts +++ b/electron/main/database/migrations.ts @@ -11,15 +11,16 @@ */ import type Database from 'better-sqlite3' +import { t } from '../i18n' /** 迁移脚本接口 */ interface Migration { /** 版本号(必须递增) */ version: number - /** 迁移描述(技术说明) */ - description: string - /** 用户可读的升级原因(显示在弹窗中) */ - userMessage: string + /** 迁移描述 i18n key(技术说明) */ + descriptionKey: string + /** 用户可读的升级原因 i18n key(显示在弹窗中) */ + userMessageKey: string /** 迁移执行函数 */ up: (db: Database.Database) => void } @@ -43,8 +44,8 @@ export const CURRENT_SCHEMA_VERSION = 3 const migrations: Migration[] = [ { version: 1, - description: '添加 owner_id 字段到 meta 表', - userMessage: '支持「Owner」功能,可在成员列表中设置自己的身份', + descriptionKey: 'database.migrationV1Desc', + userMessageKey: 'database.migrationV1Message', up: (db) => { // 检查 owner_id 列是否已存在(防止重复执行) const tableInfo = db.prepare('PRAGMA table_info(meta)').all() as Array<{ name: string }> @@ -56,8 +57,8 @@ const migrations: Migration[] = [ }, { version: 2, - description: '添加 roles、reply_to_message_id、platform_message_id 字段', - userMessage: '支持成员角色、消息回复关系和回复内容预览', + descriptionKey: 'database.migrationV2Desc', + userMessageKey: 'database.migrationV2Message', up: (db) => { // 检查 roles 列是否已存在(防止重复执行) const memberTableInfo = db.prepare('PRAGMA table_info(member)').all() as Array<{ name: string }> @@ -91,8 +92,8 @@ const migrations: Migration[] = [ }, { version: 3, - description: '添加会话索引相关表(chat_session、message_context)和 session_gap_threshold 字段', - userMessage: '支持会话时间轴浏览和 AI 增强分析功能', + descriptionKey: 'database.migrationV3Desc', + userMessageKey: 'database.migrationV3Message', up: (db) => { // 创建 chat_session 会话表 db.exec(` @@ -192,14 +193,14 @@ function checkDatabaseIntegrity(db: Database.Database): { valid: boolean; error? if (tables.length === 0) { return { valid: false, - error: '数据库结构不完整:缺少 meta 表。建议删除此数据库文件后重新导入。', + error: t('database.integrityError'), } } return { valid: true } } catch (error) { return { valid: false, - error: `数据库检查失败: ${error instanceof Error ? error.message : String(error)}`, + error: t('database.checkFailed', { error: error instanceof Error ? error.message : String(error) }), } } } @@ -264,7 +265,7 @@ export function getPendingMigrationInfos(fromVersion = 0): MigrationInfo[] { .filter((m) => m.version > fromVersion) .map((m) => ({ version: m.version, - description: m.description, - userMessage: m.userMessage, + description: t(m.descriptionKey), + userMessage: t(m.userMessageKey), })) } diff --git a/electron/main/i18n/index.ts b/electron/main/i18n/index.ts new file mode 100644 index 0000000..2d701a7 --- /dev/null +++ b/electron/main/i18n/index.ts @@ -0,0 +1,99 @@ +/** + * 主进程国际化模块 + * + * 基于 i18next,提供主进程的多语言支持。 + * 语言设置持久化在 settings/locale.json 中, + * 并通过 IPC 'locale:change' 与渲染进程同步。 + */ + +import i18next from 'i18next' +import { app, ipcMain } from 'electron' +import * as fs from 'fs' +import * as path from 'path' +import { getSettingsDir, ensureDir } from '../paths' +import zhCN from './locales/zh-CN' +import enUS from './locales/en-US' + +const LOCALE_FILE = 'locale.json' + +/** + * 获取 locale 配置文件路径 + */ +function getLocaleFilePath(): string { + return path.join(getSettingsDir(), LOCALE_FILE) +} + +/** + * 保存语言设置到文件 + */ +function saveLocale(lng: string): void { + try { + ensureDir(getSettingsDir()) + fs.writeFileSync(getLocaleFilePath(), JSON.stringify({ locale: lng }, null, 2), 'utf-8') + } catch (err) { + console.error('[i18n] Failed to save locale:', err) + } +} + +/** + * 初始化主进程国际化 + * + * 优先级:settings/locale.json > app.getLocale() 系统检测 > en-US 默认 + * 同时注册 IPC 监听器接收渲染进程的语言切换请求 + */ +export async function initLocale(): Promise { + let lng = 'en-US' // 默认回退 + + try { + const filePath = getLocaleFilePath() + if (fs.existsSync(filePath)) { + // 读取用户保存的语言偏好 + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) + if (data.locale) lng = data.locale + } else { + // 无配置文件,探测系统语言 + const sysLocale = app.getLocale() + lng = sysLocale.startsWith('zh') ? 'zh-CN' : 'en-US' + } + } catch (e) { + console.error('[i18n] Error loading locale config:', e) + } + + await i18next.init({ + lng, + fallbackLng: 'en-US', + resources: { + 'zh-CN': { translation: zhCN }, + 'en-US': { translation: enUS }, + }, + interpolation: { escapeValue: false }, // Node 环境不需要防 XSS + }) + + console.log(`[i18n] Initialized with locale: ${lng}`) + + // 监听渲染进程的语言切换请求(补全半完成的 IPC 机制) + ipcMain.on('locale:change', async (_event, newLocale: string) => { + if (newLocale !== i18next.language) { + await i18next.changeLanguage(newLocale) + saveLocale(newLocale) + console.log(`[i18n] Locale changed to: ${newLocale}`) + } + }) +} + +/** + * 翻译函数 + * @param key 翻译 key,如 'update.newVersionTitle' + * @param options 插值参数,如 { version: '1.0.0' } + */ +export const t = (key: string, options?: Record): string => i18next.t(key, options) + +/** + * 获取当前 locale + */ +export const getLocale = (): string => i18next.language + +/** + * 判断当前是否为中文环境(兼容现有 isChineseLocale 模式) + */ +export const isChineseLocale = (): boolean => i18next.language.startsWith('zh') diff --git a/electron/main/i18n/locales/en-US.ts b/electron/main/i18n/locales/en-US.ts new file mode 100644 index 0000000..3e75b64 --- /dev/null +++ b/electron/main/i18n/locales/en-US.ts @@ -0,0 +1,266 @@ +/** + * 主进程英文翻译 + */ +export default { + // ===== Common ===== + common: { + error: 'Error', + }, + + // ===== P0: Update dialogs ===== + update: { + newVersionTitle: 'New version v{{version}} available', + newVersionMessage: 'New version v{{version}} available', + newVersionDetail: 'Would you like to download and install the new version?', + downloadNow: 'Download Now', + cancel: 'Cancel', + downloadComplete: 'Download Complete', + readyToInstall: 'The new version is ready. Install now?', + install: 'Install', + remindLater: 'Remind Later', + installOnQuit: 'Later (auto-install on quit)', + upToDate: 'You are up to date', + }, + + // ===== P0: File/directory dialogs ===== + dialog: { + selectChatFile: 'Select Chat Record File', + chatRecords: 'Chat Records', + allFiles: 'All Files', + import: 'Import', + selectDirectory: 'Select Directory', + selectFolder: 'Select Folder', + selectFolderError: 'Error selecting folder: ', + }, + + // ===== P1: Database migrations ===== + database: { + migrationV1Desc: 'Add owner_id field to meta table', + migrationV1Message: 'Support "Owner" feature to set your identity in the member list', + migrationV2Desc: 'Add roles, reply_to_message_id, platform_message_id fields', + migrationV2Message: 'Support member roles, message reply relationships and reply preview', + migrationV3Desc: 'Add session index tables (chat_session, message_context) and session_gap_threshold field', + migrationV3Message: 'Support session timeline browsing and AI-enhanced analysis', + integrityError: + 'Database structure is incomplete: missing meta table. Please delete this database file and re-import.', + checkFailed: 'Database check failed: {{error}}', + }, + + // ===== Tool system ===== + tools: { + notRegistered: 'Tool "{{toolName}}" is not registered', + }, + + // ===== P2: AI Tool definitions (Function Calling) ===== + ai: { + tools: { + search_messages: { + desc: 'Search group chat records by keywords. Suitable for finding specific topics or keyword-related chat content. Can specify time range and sender to filter messages. Supports minute-level time queries.', + params: { + keywords: + 'List of search keywords, using OR logic to match messages containing any keyword. Pass an empty array [] to filter by sender only', + sender_id: + 'Sender member ID, used to filter messages from a specific member. Can be obtained via the get_group_members tool', + limit: 'Message count limit, default 1000, max 50000', + year: 'Filter messages by year, e.g. 2024', + month: 'Filter messages by month (1-12), use with year', + day: 'Filter messages by day (1-31), use with year and month', + hour: 'Filter messages by hour (0-23), use with year, month, and day', + start_time: + 'Start time, format "YYYY-MM-DD HH:mm", e.g. "2024-03-15 14:00". Overrides year/month/day/hour when specified', + end_time: + 'End time, format "YYYY-MM-DD HH:mm", e.g. "2024-03-15 18:30". Overrides year/month/day/hour when specified', + }, + }, + get_recent_messages: { + desc: 'Get chat messages within a specified time period. Suitable for overview questions like "what has everyone been chatting about recently" or "what was discussed in month X". Supports minute-level time queries.', + params: { + limit: 'Message count limit, default 100 (saves tokens, can be increased as needed)', + year: 'Filter messages by year, e.g. 2024', + month: 'Filter messages by month (1-12), use with year', + day: 'Filter messages by day (1-31), use with year and month', + hour: 'Filter messages by hour (0-23), use with year, month, and day', + start_time: + 'Start time, format "YYYY-MM-DD HH:mm", e.g. "2024-03-15 14:00". Overrides year/month/day/hour when specified', + end_time: + 'End time, format "YYYY-MM-DD HH:mm", e.g. "2024-03-15 18:30". Overrides year/month/day/hour when specified', + }, + }, + get_member_stats: { + desc: 'Get member activity statistics. Suitable for questions like "who is the most active" or "who sends the most messages".', + params: { + top_n: 'Return top N members, default 10', + }, + }, + get_time_stats: { + desc: 'Get time distribution statistics of chat activity. Suitable for questions like "when is the group most active" or "what time do people usually chat".', + params: { + type: 'Statistics type: hourly (by hour), weekday (by day of week), daily (by date)', + }, + }, + get_group_members: { + desc: 'Get group member list, including basic info, aliases, and message statistics. Suitable for queries like "who is in the group", "what is someone\'s alias", or "whose ID is xxx".', + params: { + search: 'Optional search keyword to filter by member nickname, alias, or platform ID', + limit: 'Member count limit, returns all by default', + }, + }, + get_member_name_history: { + desc: 'Get member name change history. Suitable for questions like "what was someone\'s previous name", "name changes", or "former names". Requires member ID from get_group_members tool first.', + params: { + member_id: 'Member database ID, can be obtained via get_group_members tool', + }, + }, + get_conversation_between: { + desc: 'Get conversation records between two group members. Suitable for questions like "what did A and B talk about" or "view the conversation between two people". Requires member IDs from get_group_members first. Supports minute-level time queries.', + params: { + member_id_1: 'Database ID of the first member', + member_id_2: 'Database ID of the second member', + limit: 'Message count limit, default 100', + year: 'Filter messages by year', + month: 'Filter messages by month (1-12), use with year', + day: 'Filter messages by day (1-31), use with year and month', + hour: 'Filter messages by hour (0-23), use with year, month, and day', + start_time: + 'Start time, format "YYYY-MM-DD HH:mm", e.g. "2024-03-15 14:00". Overrides year/month/day/hour when specified', + end_time: + 'End time, format "YYYY-MM-DD HH:mm", e.g. "2024-03-15 18:30". Overrides year/month/day/hour when specified', + }, + }, + get_message_context: { + desc: 'Get surrounding context messages for a given message ID. Suitable for viewing what was discussed before and after a specific message. Supports single or batch message IDs.', + params: { + message_ids: + 'List of message IDs to query context for. Can be single or multiple IDs. Message IDs can be obtained from search_messages and other tool results', + context_size: 'Context size, i.e. how many messages before and after to retrieve, default 20', + }, + }, + search_sessions: { + desc: 'Search chat sessions (conversation segments). Sessions are conversation units automatically split by message time intervals. Suitable for finding discussions on specific topics or understanding how many conversations occurred in a time period. Returns matching sessions with a 5-message preview each.', + params: { + keywords: 'Optional keyword list, only returns sessions containing these keywords (OR logic)', + limit: 'Session count limit, default 20', + year: 'Filter sessions by year, e.g. 2024', + month: 'Filter sessions by month (1-12), use with year', + day: 'Filter sessions by day (1-31), use with year and month', + start_time: 'Start time, format "YYYY-MM-DD HH:mm", e.g. "2024-03-15 14:00"', + end_time: 'End time, format "YYYY-MM-DD HH:mm", e.g. "2024-03-15 18:30"', + }, + }, + get_session_messages: { + desc: 'Get the complete message list for a specific session. Used to get full context after finding a relevant session via search_sessions. Returns all messages and participant information.', + params: { + session_id: 'Session ID, can be obtained from search_sessions results', + limit: 'Message count limit, default 1000. Can be limited for very long sessions to save tokens', + }, + }, + get_session_summaries: { + desc: `Get session summary list to quickly understand discussion topics in chat history. + +Use cases: +1. Understand what topics have been discussed recently +2. Search for discussed topics by keyword +3. Overview questions like "has the group discussed travel" + +Returned summaries are brief descriptions of each session, helping quickly locate sessions of interest. Use get_session_messages for details.`, + params: { + keywords: 'Keyword list to search within summaries (OR logic)', + limit: 'Session count limit, default 20', + year: 'Filter sessions by year', + month: 'Filter sessions by month (1-12)', + day: 'Filter sessions by day (1-31)', + start_time: 'Start time, format "YYYY-MM-DD HH:mm"', + end_time: 'End time, format "YYYY-MM-DD HH:mm"', + }, + }, + semantic_search_messages: { + desc: `Search historical conversations using Embedding vector similarity, understanding semantics rather than keyword matching. + +⚠️ Use cases (prefer search_messages for keyword search, consider this tool for the following): +1. Finding "similar expressions": e.g. "has anyone said something like 'I miss you'" +2. Insufficient keyword results: when search_messages returns too few or irrelevant results +3. Vague sentiment/relationship analysis: e.g. "how does the other person feel about me" + +❌ Not suitable for (use search_messages): +- Searches with specific keywords (e.g. "travel", "birthday") +- Finding specific person's messages +- Finding messages in specific time periods`, + params: { + query: 'Semantic search query, describe in natural language what type of content you are looking for', + top_k: 'Number of results to return, default 10 (recommended 5-20)', + candidate_limit: 'Number of candidate sessions, default 50 (larger is slower but potentially more accurate)', + year: 'Filter sessions by year', + month: 'Filter sessions by month (1-12)', + day: 'Filter sessions by day (1-31)', + start_time: 'Start time, format "YYYY-MM-DD HH:mm"', + end_time: 'End time, format "YYYY-MM-DD HH:mm"', + }, + }, + }, + + // ===== AI Agent system prompts ===== + agent: { + answerWithoutTools: 'Please answer based on the information already retrieved, do not call any more tools.', + toolError: 'Error: {{error}}', + currentDateIs: 'Current date is', + chatContext: { + private: 'conversation', + group: 'group chat', + }, + ownerNote: `Current user identity: +- The user's identity in this {{chatContext}} is "{{displayName}}" (platformId: {{platformId}}) +- When the user refers to "I" or "my", it refers to "{{displayName}}" +- When querying "my" messages, use the sender_id parameter to filter for this member +`, + memberNotePrivate: `Member query strategy: +- Private chats only have two participants, so the member list can be directly obtained +- When the user refers to "the other party" or "he/she", get the other participant's information via get_group_members +`, + memberNoteGroup: `Member query strategy: +- When the user refers to specific group members (e.g., "what did John say", "Mary's messages"), first call get_group_members to get the member list +- Group members have three names: accountName (original nickname), groupNickname (group nickname), aliases (user-defined aliases) +- The search parameter of get_group_members can be used for fuzzy searching these three names +- Once a member is found, use their id field as the sender_id parameter for search_messages to retrieve their messages +`, + timeParamsIntro: 'Time parameters: combine year/month/day/hour based on user mention', + timeParamExample1: '"October" → year: {{year}}, month: 10', + timeParamExample2: '"October 1st" → year: {{year}}, month: 10, day: 1', + timeParamExample3: '"October 1st 3 PM" → year: {{year}}, month: 10, day: 1, hour: 15', + defaultYearNote: + 'If year is not specified, defaults to {{year}}. If the month has not yet occurred, {{prevYear}} is used.', + responseInstruction: + "Based on the user's question, select appropriate tools to retrieve data, then provide an answer based on the data.", + responseRulesTitle: 'Response requirements:', + fallbackRoleDefinition: { + group: `You are a professional group chat analysis assistant. +Your task is to help users understand and analyze their group chat data.`, + private: `You are a professional private chat analysis assistant. +Your task is to help users understand and analyze their private chat data.`, + }, + fallbackResponseRules: `1. Answer based on data returned by tools, do not fabricate information +2. If data is insufficient to answer, please state so +3. Keep answers concise and clear, use Markdown format`, + }, + }, + + // ===== P3: LLM config ===== + llm: { + notConfigured: 'LLM service not configured. Please set up an API Key in settings first.', + maxConfigs: 'Maximum of {{count}} configurations allowed', + configNotFound: 'Configuration not found', + noActiveConfig: 'No active configuration', + }, + + // ===== P4: Summary generation ===== + summary: { + sessionNotFound: 'Session not found or database could not be opened', + tooFewMessages: 'Message count less than {{count}}, no need to generate summary', + tooFewValidMessages: 'Valid message count less than {{count}}, no need to generate summary', + sessionNotExist: 'Session not found', + messagesTooFew: 'Too few messages', + validMessagesTooFew: 'Too few valid messages', + systemPromptDirect: 'You are a conversation summarization expert. Summarize conversations concisely.', + systemPromptMerge: + 'You are a conversation summarization expert skilled at merging multiple summaries into a coherent overview.', + }, +} diff --git a/electron/main/i18n/locales/zh-CN.ts b/electron/main/i18n/locales/zh-CN.ts new file mode 100644 index 0000000..5420823 --- /dev/null +++ b/electron/main/i18n/locales/zh-CN.ts @@ -0,0 +1,262 @@ +/** + * 主进程中文翻译 + */ +export default { + // ===== 通用 ===== + common: { + error: '错误', + }, + + // ===== P0: 更新弹窗 ===== + update: { + newVersionTitle: '发现新版本 v{{version}}', + newVersionMessage: '发现新版本 v{{version}}', + newVersionDetail: '是否立即下载并安装新版本?', + downloadNow: '立即下载', + cancel: '取消', + downloadComplete: '下载完成', + readyToInstall: '新版本已准备就绪,是否现在安装?', + install: '安装', + remindLater: '之后提醒', + installOnQuit: '稍后(应用退出后自动安装)', + upToDate: '已是最新版本', + }, + + // ===== P0: 文件/目录对话框 ===== + dialog: { + selectChatFile: '选择聊天记录文件', + chatRecords: '聊天记录', + allFiles: '所有文件', + import: '导入', + selectDirectory: '选择目录', + selectFolder: '选择文件夹', + selectFolderError: '选择文件夹时发生错误:', + }, + + // ===== P1: 数据库迁移 ===== + database: { + migrationV1Desc: '添加 owner_id 字段到 meta 表', + migrationV1Message: '支持「Owner」功能,可在成员列表中设置自己的身份', + migrationV2Desc: '添加 roles、reply_to_message_id、platform_message_id 字段', + migrationV2Message: '支持成员角色、消息回复关系和回复内容预览', + migrationV3Desc: '添加会话索引相关表(chat_session、message_context)和 session_gap_threshold 字段', + migrationV3Message: '支持会话时间轴浏览和 AI 增强分析功能', + integrityError: '数据库结构不完整:缺少 meta 表。建议删除此数据库文件后重新导入。', + checkFailed: '数据库检查失败: {{error}}', + }, + + // ===== 工具系统 ===== + tools: { + notRegistered: '工具 "{{toolName}}" 未注册', + }, + + // ===== P2: AI 工具描述(Function Calling) ===== + ai: { + tools: { + search_messages: { + desc: '根据关键词搜索群聊记录。适用于用户想要查找特定话题、关键词相关的聊天内容。可以指定时间范围和发送者来筛选消息。支持精确到分钟级别的时间查询。', + params: { + keywords: '搜索关键词列表,会用 OR 逻辑匹配包含任一关键词的消息。如果只需要按发送者筛选,可以传空数组 []', + sender_id: '发送者的成员 ID,用于筛选特定成员发送的消息。可以通过 get_group_members 工具获取成员 ID', + limit: '返回消息数量限制,默认 1000,最大 50000', + year: '筛选指定年份的消息,如 2024', + month: '筛选指定月份的消息(1-12),需要配合 year 使用', + day: '筛选指定日期的消息(1-31),需要配合 year 和 month 使用', + hour: '筛选指定小时的消息(0-23),需要配合 year、month 和 day 使用', + start_time: + '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后会覆盖 year/month/day/hour 参数', + end_time: + '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后会覆盖 year/month/day/hour 参数', + }, + }, + get_recent_messages: { + desc: '获取指定时间段内的群聊消息。适用于回答"最近大家聊了什么"、"X月群里聊了什么"等概览性问题。支持精确到分钟级别的时间查询。', + params: { + limit: '返回消息数量限制,默认 100(节省 token,可根据需要增加)', + year: '筛选指定年份的消息,如 2024', + month: '筛选指定月份的消息(1-12),需要配合 year 使用', + day: '筛选指定日期的消息(1-31),需要配合 year 和 month 使用', + hour: '筛选指定小时的消息(0-23),需要配合 year、month 和 day 使用', + start_time: + '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后会覆盖 year/month/day/hour 参数', + end_time: + '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后会覆盖 year/month/day/hour 参数', + }, + }, + get_member_stats: { + desc: '获取群成员的活跃度统计数据。适用于回答"谁最活跃"、"发言最多的是谁"等问题。', + params: { + top_n: '返回前 N 名成员,默认 10', + }, + }, + get_time_stats: { + desc: '获取群聊的时间分布统计。适用于回答"什么时候最活跃"、"大家一般几点聊天"等问题。', + params: { + type: '统计类型:hourly(按小时)、weekday(按星期)、daily(按日期)', + }, + }, + get_group_members: { + desc: '获取群成员列表,包括成员的基本信息、别名和消息统计。适用于查询"群里有哪些人"、"某人的别名是什么"、"谁的QQ号是xxx"等问题。', + params: { + search: '可选的搜索关键词,用于筛选成员昵称、别名或QQ号', + limit: '返回成员数量限制,默认返回全部', + }, + }, + get_member_name_history: { + desc: '获取成员的昵称变更历史记录。适用于回答"某人以前叫什么名字"、"某人的昵称变化"、"某人曾用名"等问题。需要先通过 get_group_members 工具获取成员 ID。', + params: { + member_id: '成员的数据库 ID,可以通过 get_group_members 工具获取', + }, + }, + get_conversation_between: { + desc: '获取两个群成员之间的对话记录。适用于回答"A和B之间聊了什么"、"查看两人的对话"等问题。需要先通过 get_group_members 获取成员 ID。支持精确到分钟级别的时间查询。', + params: { + member_id_1: '第一个成员的数据库 ID', + member_id_2: '第二个成员的数据库 ID', + limit: '返回消息数量限制,默认 100', + year: '筛选指定年份的消息', + month: '筛选指定月份的消息(1-12),需要配合 year 使用', + day: '筛选指定日期的消息(1-31),需要配合 year 和 month 使用', + hour: '筛选指定小时的消息(0-23),需要配合 year、month 和 day 使用', + start_time: + '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后会覆盖 year/month/day/hour 参数', + end_time: + '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后会覆盖 year/month/day/hour 参数', + }, + }, + get_message_context: { + desc: '根据消息 ID 获取前后的上下文消息。适用于需要查看某条消息前后聊天内容的场景,比如"这条消息的前后在聊什么"、"查看某条消息的上下文"等。支持单个或批量消息 ID。', + params: { + message_ids: + '要查询上下文的消息 ID 列表,可以是单个 ID 或多个 ID。消息 ID 可以从 search_messages 等工具的返回结果中获取', + context_size: '上下文大小,即获取前后各多少条消息,默认 20', + }, + }, + search_sessions: { + desc: '搜索聊天会话(对话段落)。会话是根据消息时间间隔自动切分的对话单元。适用于查找特定话题的讨论、了解某个时间段内发生了几次对话等场景。返回匹配的会话列表及每个会话的前5条消息预览。', + params: { + keywords: '可选的搜索关键词列表,只返回包含这些关键词的会话(OR 逻辑匹配)', + limit: '返回会话数量限制,默认 20', + year: '筛选指定年份的会话,如 2024', + month: '筛选指定月份的会话(1-12),需要配合 year 使用', + day: '筛选指定日期的会话(1-31),需要配合 year 和 month 使用', + start_time: '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"', + end_time: '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"', + }, + }, + get_session_messages: { + desc: '获取指定会话的完整消息列表。用于在 search_sessions 找到相关会话后,获取该会话的完整上下文。返回会话的所有消息及参与者信息。', + params: { + session_id: '会话 ID,可以从 search_sessions 的返回结果中获取', + limit: '返回消息数量限制,默认 1000。对于超长会话可以限制返回数量以节省 token', + }, + }, + get_session_summaries: { + desc: `获取会话摘要列表,快速了解群聊历史讨论的主题。 + +适用场景: +1. 了解群里最近在聊什么话题 +2. 按关键词搜索讨论过的话题 +3. 概览性问题如"群里有没有讨论过旅游" + +返回的摘要是对每个会话的简短总结,可以帮助快速定位感兴趣的会话,然后用 get_session_messages 获取详情。`, + params: { + keywords: '在摘要中搜索的关键词列表(OR 逻辑匹配)', + limit: '返回会话数量限制,默认 20', + year: '筛选指定年份的会话', + month: '筛选指定月份的会话(1-12)', + day: '筛选指定日期的会话(1-31)', + start_time: '开始时间,格式 "YYYY-MM-DD HH:mm"', + end_time: '结束时间,格式 "YYYY-MM-DD HH:mm"', + }, + }, + semantic_search_messages: { + desc: `使用 Embedding 向量相似度搜索历史对话,理解语义而非关键词匹配。 + +⚠️ 使用场景(优先使用 search_messages 关键词搜索,以下场景再考虑本工具): +1. 找"类似的话"或"类似的表达":如"有没有说过类似'我想你了'这样的话" +2. 关键词搜索结果不足:当 search_messages 返回结果太少或不相关时,可用本工具补充 +3. 模糊的情感/关系分析:如"对方对我的态度是怎样的"、"我们之间的氛围" + +❌ 不适合的场景(请用 search_messages): +- 有明确关键词的搜索(如"旅游"、"生日"、"加班") +- 查找特定人物的发言 +- 查找特定时间段的消息`, + params: { + query: '语义检索查询,用自然语言描述你想要找的内容类型', + top_k: '返回结果数量,默认 10(建议 5-20)', + candidate_limit: '候选会话数量,默认 50(越大越慢但可能更准确)', + year: '筛选指定年份的会话', + month: '筛选指定月份的会话(1-12)', + day: '筛选指定日期的会话(1-31)', + start_time: '开始时间,格式 "YYYY-MM-DD HH:mm"', + end_time: '结束时间,格式 "YYYY-MM-DD HH:mm"', + }, + }, + }, + + // ===== AI Agent 系统提示词 ===== + agent: { + answerWithoutTools: '请根据已获取的信息给出回答,不要再调用工具。', + toolError: '错误: {{error}}', + currentDateIs: '当前日期是', + chatContext: { + private: '对话', + group: '群聊', + }, + ownerNote: `当前用户身份: +- 用户在{{chatContext}}中的身份是「{{displayName}}」(platformId: {{platformId}}) +- 当用户提到"我"、"我的"时,指的就是「{{displayName}}」 +- 查询"我"的发言时,使用 sender_id 参数筛选该成员 +`, + memberNotePrivate: `成员查询策略: +- 私聊只有两个人,可以直接获取成员列表 +- 当用户提到"对方"、"他/她"时,通过 get_group_members 获取另一方信息 +`, + memberNoteGroup: `成员查询策略: +- 当用户提到特定群成员(如"张三说过什么"、"小明的发言"等)时,应先调用 get_group_members 获取成员列表 +- 群成员有三种名称:accountName(原始昵称)、groupNickname(群昵称)、aliases(用户自定义别名) +- 通过 get_group_members 的 search 参数可以模糊搜索这三种名称 +- 找到成员后,使用其 id 字段作为 search_messages 的 sender_id 参数来获取该成员的发言 +`, + timeParamsIntro: '时间参数:按用户提到的精度组合 year/month/day/hour', + timeParamExample1: '"10月" → year: {{year}}, month: 10', + timeParamExample2: '"10月1号" → year: {{year}}, month: 10, day: 1', + timeParamExample3: '"10月1号下午3点" → year: {{year}}, month: 10, day: 1, hour: 15', + defaultYearNote: '未指定年份默认{{year}}年,若该月份未到则用{{prevYear}}年', + responseInstruction: '根据用户的问题,选择合适的工具获取数据,然后基于数据给出回答。', + responseRulesTitle: '回答要求:', + fallbackRoleDefinition: { + group: `你是一个专业但风格轻松的群聊记录分析助手。 +你的任务是帮助用户理解和分析他们的群聊记录数据,同时可以适度使用 B 站/网络热梗和表情/颜文字活跃气氛,但不影响结论的准确性。`, + private: `你是一个专业但风格轻松的私聊记录分析助手。 +你的任务是帮助用户理解和分析他们的私聊记录数据,同时可以适度使用 B 站/网络热梗和表情/颜文字活跃气氛,但不影响结论的准确性。`, + }, + fallbackResponseRules: `1. 基于工具返回的数据回答,不要编造信息 +2. 如果数据不足以回答问题,请说明 +3. 回答要简洁明了,使用 Markdown 格式 +4. 可以适度加入 B 站/网络热梗、表情/颜文字(强度适中) +5. 玩梗不得影响事实准确与结论清晰,避免低俗或冒犯性表达`, + }, + }, + + // ===== P3: LLM 配置 ===== + llm: { + notConfigured: 'LLM 服务未配置,请先在设置中配置 API Key', + maxConfigs: '最多只能添加 {{count}} 个配置', + configNotFound: '配置不存在', + noActiveConfig: '没有激活的配置', + }, + + // ===== P4: 摘要生成 ===== + summary: { + sessionNotFound: '会话不存在或数据库打开失败', + tooFewMessages: '消息数量少于{{count}}条,无需生成摘要', + tooFewValidMessages: '有效消息数量少于{{count}}条,无需生成摘要', + sessionNotExist: '会话不存在', + messagesTooFew: '消息太少', + validMessagesTooFew: '有效消息太少', + systemPromptDirect: '你是一个对话摘要专家,擅长用简洁的语言总结对话内容。', + systemPromptMerge: '你是一个对话摘要专家,擅长将多个摘要合并成一个连贯的总结。', + }, +} diff --git a/electron/main/index.ts b/electron/main/index.ts index a05a04a..f9dafeb 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -7,6 +7,7 @@ import { initAnalytics, trackDailyActive } from './analytics' import { initProxy } from './network/proxy' import { needsLegacyMigration, migrateFromLegacyDir, ensureAppDirs, cleanupPendingDeleteDir } from './paths' import { migrateAllDatabases, checkMigrationNeeded } from './database/core' +import { initLocale } from './i18n' class MainProcess { mainWindow: BrowserWindow | null @@ -58,6 +59,9 @@ class MainProcess { // 确保应用目录存在 ensureAppDirs() + // 初始化主进程国际化(在 ensureAppDirs 之后,确保 settings 目录存在) + await initLocale() + // 执行数据库 schema 迁移(确保所有数据库在 Worker 查询前已是最新 schema) this.migrateDatabasesIfNeeded() diff --git a/electron/main/ipc/ai.ts b/electron/main/ipc/ai.ts index c6703f7..0513ab8 100644 --- a/electron/main/ipc/ai.ts +++ b/electron/main/ipc/ai.ts @@ -8,6 +8,7 @@ import * as rag from '../ai/rag' import { aiLogger } from '../ai/logger' import { getLogsDir } from '../paths' import { Agent, type AgentStreamChunk, type PromptConfig } from '../ai/agent' +import { t } from '../i18n' import type { ToolContext } from '../ai/tools/types' import type { IpcContext } from './types' @@ -374,7 +375,7 @@ export function registerAIHandlers({ win }: IpcContext): void { if (activeConfig) { return llm.deleteConfig(activeConfig.id) } - return { success: false, error: '没有激活的配置' } + return { success: false, error: t('llm.noActiveConfig') } } return llm.deleteConfig(id) } catch (error) { diff --git a/electron/main/ipc/chat.ts b/electron/main/ipc/chat.ts index 114ed18..68a3af1 100644 --- a/electron/main/ipc/chat.ts +++ b/electron/main/ipc/chat.ts @@ -10,6 +10,7 @@ import { detectFormat, diagnoseFormat, scanMultiChatFile, type ParseProgress } f import type { IpcContext } from './types' import { CURRENT_SCHEMA_VERSION, getPendingMigrationInfos, type MigrationInfo } from '../database/migrations' import { exportSessionToTempFile, cleanupTempExportFiles } from '../merger' +import { t } from '../i18n' /** * 注册聊天记录相关 IPC 处理器 @@ -60,14 +61,14 @@ export function registerChatHandlers(ctx: IpcContext): void { ipcMain.handle('chat:selectFile', async () => { try { const { canceled, filePaths } = await dialog.showOpenDialog({ - title: '选择聊天记录文件', + title: t('dialog.selectChatFile'), defaultPath: app.getPath('documents'), properties: ['openFile'], filters: [ - { name: '聊天记录', extensions: ['json', 'jsonl', 'txt'] }, - { name: '所有文件', extensions: ['*'] }, + { name: t('dialog.chatRecords'), extensions: ['json', 'jsonl', 'txt'] }, + { name: t('dialog.allFiles'), extensions: ['*'] }, ], - buttonLabel: '导入', + buttonLabel: t('dialog.import'), }) if (canceled || filePaths.length === 0) { diff --git a/electron/main/ipc/window.ts b/electron/main/ipc/window.ts index d3e4052..f63ced5 100644 --- a/electron/main/ipc/window.ts +++ b/electron/main/ipc/window.ts @@ -6,6 +6,7 @@ import { ipcMain, app, dialog, clipboard, shell } from 'electron' import * as fs from 'fs/promises' import type { IpcContext } from './types' import { simulateUpdateDialog, manualCheckForUpdates } from '../update' +import { t } from '../i18n' /** * 注册窗口和文件系统操作 IPC 处理器 @@ -153,17 +154,17 @@ export function registerWindowHandlers(ctx: IpcContext): void { ipcMain.handle('selectDir', async (_, defaultPath = '') => { try { const { canceled, filePaths } = await dialog.showOpenDialog({ - title: '选择目录', + title: t('dialog.selectDirectory'), defaultPath: defaultPath || app.getPath('documents'), properties: ['openDirectory', 'createDirectory'], - buttonLabel: '选择文件夹', + buttonLabel: t('dialog.selectFolder'), }) if (!canceled) { return filePaths[0] } return null } catch (err) { - console.error('选择文件夹时发生错误:', err) + console.error(t('dialog.selectFolderError'), err) throw err } }) diff --git a/electron/main/update.ts b/electron/main/update.ts index 1d8c9f4..3bf23a3 100644 --- a/electron/main/update.ts +++ b/electron/main/update.ts @@ -4,6 +4,7 @@ import { platform } from '@electron-toolkit/utils' import { logger } from './logger' import { getActiveProxyUrl } from './network/proxy' import { closeWorkerAsync } from './worker/workerManager' +import { t } from './i18n' // R2 镜像源 URL(速度更快,作为主要更新源) const R2_MIRROR_URL = 'https://chatlab.1app.top/releases/download' @@ -143,10 +144,10 @@ const checkUpdate = (win) => { dialog .showMessageBox({ - title: '发现新版本 v' + info.version, - message: '发现新版本 v' + info.version, - detail: '是否立即下载并安装新版本?', - buttons: ['立即下载', '取消'], + title: t('update.newVersionTitle', { version: info.version }), + message: t('update.newVersionMessage', { version: info.version }), + detail: t('update.newVersionDetail'), + buttons: [t('update.downloadNow'), t('update.cancel')], defaultId: 0, cancelId: 1, type: 'question', @@ -178,9 +179,9 @@ const checkUpdate = (win) => { autoUpdater.on('update-downloaded', () => { dialog .showMessageBox({ - title: '下载完成', - message: '新版本已准备就绪,是否现在安装?', - buttons: ['安装', platform.isMacOS ? '之后提醒' : '稍后(应用退出后自动安装)'], + title: t('update.downloadComplete'), + message: t('update.readyToInstall'), + buttons: [t('update.install'), platform.isMacOS ? t('update.remindLater') : t('update.installOnQuit')], defaultId: 1, cancelId: 2, type: 'question', @@ -219,7 +220,7 @@ const checkUpdate = (win) => { } else { win.webContents.send('show-message', { type: 'success', - message: '已是最新版本', + message: t('update.upToDate'), }) } }) @@ -279,10 +280,10 @@ const manualCheckForUpdates = () => { */ const simulateUpdateDialog = (win) => { dialog.showMessageBox({ - title: '发现新版本 v9.9.9', - message: '发现新版本 v9.9.9', - detail: '是否立即下载并安装新版本?', - buttons: ['立即下载', '取消'], + title: t('update.newVersionTitle', { version: '9.9.9' }), + message: t('update.newVersionMessage', { version: '9.9.9' }), + detail: t('update.newVersionDetail'), + buttons: [t('update.downloadNow'), t('update.cancel')], defaultId: 0, cancelId: 1, type: 'question', diff --git a/package.json b/package.json index a9f73e8..715488c 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "echarts": "^6.0.0", "echarts-wordcloud": "^2.1.0", "electron-updater": "^6.6.2", + "i18next": "^25.8.5", "markdown-it": "^14.1.0", "node-machine-id": "^1.1.12", "stream-json": "^1.9.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a90b7b1..9b5af01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: electron-updater: specifier: ^6.6.2 version: 6.7.3 + i18next: + specifier: ^25.8.5 + version: 25.8.5(typescript@5.9.3) markdown-it: specifier: ^14.1.0 version: 14.1.0 @@ -258,6 +261,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -2578,6 +2585,14 @@ packages: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} + i18next@25.8.5: + resolution: {integrity: sha512-TApjhgqQU6P7BQlpCTv6zQuXrYAP9rjYWgx2Nm8dsq+Zg9yJlAz+iR16/w7uVtTlSoULbqPTfqYjMK/DAQI+Ng==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + iconv-corefoundation@1.1.7: resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} engines: {node: ^8.11.2 || >=10} @@ -4304,6 +4319,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.28.6': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -7018,6 +7035,12 @@ snapshots: human-signals@5.0.0: {} + i18next@25.8.5(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + optionalDependencies: + typescript: 5.9.3 + iconv-corefoundation@1.1.7: dependencies: cli-truncate: 2.1.0