/** * Title Generator — 会话标题 LLM 自动生成(v0.7.3 P4-1) * * 背景:首轮消息后前端仅以"用户首条消息截前 30 字符"作为会话标题 * (agent-store.sendMessage),中文长句体验差且语义压缩生硬。 * * 本服务在会话首个完成的 run 之后(terminationReason === 'completed')用主 * Provider adapter 发起一次极小的非流式请求(maxTokens 32 / temperature 0.3 / * thinking 关闭),生成 ≤16 字的精炼标题并写回 sessions 表。生成失败(网络 / * 配额 / 解析失败)静默回退——前端首 30 字符截断标题保持有效,无损可用性。 * * 契约: * - 每个会话生命周期内仅生成一次(内存 Set 幂等,进程重启后自然重置—— * 已有非默认标题的会话通过 hasCustomTitle 判定跳过,不会重复生成); * - 标题经 sanitizeTitle 清洗:剥离 markdown/引号/换行/前后缀冒号, * 折叠空白,超长截断,空结果返回 null(调用方保持原标题不变)。 */ import { nanoid } from 'nanoid'; import log from 'electron-log'; import type { IMetonaProviderAdapter, MetonaRequest } from '../harness/types'; import type { SessionService } from './session.service'; /** 生成标题的最大长度(字符)——超过即截断 */ const MAX_TITLE_LENGTH = 40; /** 传给 LLM 的用户消息 / 回答摘录长度 */ const EXCERPT_LENGTH = 600; /** LLM 调用超时(标题生成不应阻塞任何主流程) */ const TITLE_TIMEOUT_MS = 15_000; /** * 清洗 LLM 返回的标题文本。 * * 规则(按序应用): * 1. 剥离 markdown 代码围栏与首尾 `#`/`-`/`*` 列表标记; * 2. 剥离成对包裹引号(中英文单双引号); * 3. 剥离 "标题:"/"Title:" 这类自述前缀; * 4. 折叠全部空白(含换行)为单个空格并 trim; * 5. 超过 maxLen 截断; * 6. 空结果返回 null(调用方保持原标题)。 */ export function sanitizeTitle(raw: string, maxLen: number = MAX_TITLE_LENGTH): string | null { if (!raw || typeof raw !== 'string') return null; let title = raw.trim(); // 1/2. markdown 围栏、列表标记与包裹引号 —— 循环应用直至稳定(处理嵌套包装 // 如 ```"标题"``` / 多层引号;剥除全部首尾引号字符而非仅成对项, // 使 '"""' 这类纯符号输入收敛为空 → null) for (let i = 0; i < 3; i++) { const before = title; title = title .replace(/^```(?:[a-z]*)\s*/i, '') .replace(/\s*```$/i, '') .replace(/^[#\-*>]+\s*/, '') .replace(/^["'“”『』「"]+/, '') .replace(/["'“”『』「"]+$/, ''); if (title === before) break; } // 3. 自述前缀(标题:/Title:/会话标题: 等) title = title.replace(/^(?:标题|会话标题|题目|title)\s*[::]\s*/i, ''); // 4. 折叠空白(换行合并——LLM 偶发多行输出时取首行语义) title = title.replace(/\s+/g, ' ').trim(); // 5. 截断 if (title.length > maxLen) title = title.slice(0, maxLen).trimEnd(); // 6. 空结果 return title.length > 0 ? title : null; } export class TitleGenerator { /** 已生成过标题的会话(进程级幂等) */ private generated = new Set(); /** 进行中的生成任务(防并发重复调用) */ private running = new Map>(); constructor( private getAdapter: () => IMetonaProviderAdapter, private sessionService: SessionService, ) {} /** * 为会话生成标题(fire-and-forget 调用;失败静默)。 * * @param sessionId 会话 ID * @param userMessage 用户原始消息(干净版本,不含注入前缀) * @param assistantAnswer Agent 最终回答 * @returns 生成的标题(未生成/失败返回 null) */ async maybeGenerateTitle( sessionId: string, userMessage: string, assistantAnswer: string, ): Promise { if (!sessionId || typeof sessionId !== 'string') return null; // 输入门控:双向内容均为空无生成意义 if (!userMessage?.trim() && !assistantAnswer?.trim()) return null; // 并发去重先于幂等短路 —— 同一会话进行中的生成必须复用同一 Promise, // 而不能被"已占位"的幂等判断吞掉(否则并发第二调用拿到 null) const existing = this.running.get(sessionId); if (existing) return existing; // 幂等:每会话仅一次(占位同步完成,先于任何 await) if (this.generated.has(sessionId)) return null; this.generated.add(sessionId); const task = this.generate(sessionId, userMessage, assistantAnswer).finally(() => { this.running.delete(sessionId); }); this.running.set(sessionId, task); return task; } private async generate( sessionId: string, userMessage: string, assistantAnswer: string, ): Promise { try { const adapter = this.getAdapter(); if (!adapter) return null; // 前端 agent-store.sendMessage 会在首条消息后把会话标题设为 // 「用户消息前 30 字符 + '...'」的截断标题(即时反馈,非用户主动命名)。 // 该截断标题必须被 LLM 标题覆盖 —— 旧实现只判断 `title !== '新会话'`, // 把截断标题误判为"自定义标题"而跳过,导致 LLM 标题永不生成(v0.7.4 修复)。 // 仅当标题是"用户手动重命名"(非新会话且非自动截断标题)时才跳过。 // 用 getSession(含归档会话)替代 list()(默认排除归档)—— // 避免归档会话的标题判断查不到 current 而误覆盖。 const current = this.sessionService.getSession(sessionId); const autoFallbackTitle = userMessage.trim().length > 30 ? userMessage.trim().slice(0, 30) + '...' : userMessage.trim(); if (current && current.title !== '新会话' && current.title !== autoFallbackTitle) { log.debug( `[TitleGenerator] session ${sessionId} has custom title "${current.title}" — skip`, ); return null; } const request: MetonaRequest = { meta: { sessionId: 'title-generation', iteration: 0, requestId: `tg_${nanoid(12)}`, timestamp: Date.now(), agentVersion: '1.0.0', }, systemPrompt: { roleDefinition: 'You generate concise conversation titles for an AI assistant desktop app.', outputConstraints: 'Given the first user message and the assistant reply, output ONE title of at most 16 characters ' + 'in the same language as the user message. The title must capture the core topic or task. ' + 'No quotes, no markdown, no ending punctuation, no explanations — output the title text ONLY.', safetyGuidelines: 'Do not include sensitive data (passwords, keys, personal info) in the title.', }, messages: [ { role: 'user', content: `User message: ${userMessage.slice(0, EXCERPT_LENGTH)}\n\n` + `Assistant reply: ${assistantAnswer.slice(0, EXCERPT_LENGTH)}\n\n` + `Output the title only.`, timestamp: Date.now(), }, ], params: { maxTokens: 32, temperature: 0.3, stream: false, thinkingEnabled: false, thinkingEffort: 'low', }, }; // 超时保护:标题生成绝不能拖慢会话收尾 let timer: ReturnType | undefined; try { const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('title generation timeout')), TITLE_TIMEOUT_MS); }); const response = await Promise.race([adapter.send(request), timeoutPromise]); const title = sanitizeTitle(response.content); if (!title) { log.debug( `[TitleGenerator] session ${sessionId}: empty/unsanitizable title, keeping fallback`, ); return null; } const renamed = this.sessionService.rename(sessionId, title); if (renamed) { log.info(`[TitleGenerator] session ${sessionId} titled: "${title}"`); } return renamed ? title : null; } finally { if (timer) clearTimeout(timer); } } catch (err) { // 静默回退:标题失败不影响会话可用性(前端首条截断标题仍在) log.debug( `[TitleGenerator] session ${sessionId} title generation failed:`, (err as Error).message, ); return null; } } }