feat: 主进程配置国际化

This commit is contained in:
digua
2026-02-12 23:12:32 +08:00
committed by digua
parent 83c0641bf3
commit 1b8bbb6e49
15 changed files with 856 additions and 167 deletions

View File

@@ -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, unknown>): 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,
})
}

View File

@@ -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<AIServiceConfig, 'id' | 'createdAt' | 'up
const store = loadConfigStore()
if (store.configs.length >= 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

View File

@@ -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<string> {
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
}

View File

@@ -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<void> {
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<ToolDefinition[]> {
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 }),
}
}