From 47ef10f7802b4e5768bbbe57e9721061af2d1a4e Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 30 Apr 2026 22:08:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=8A=E4=B8=8B=E6=96=87=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E9=80=BB=E8=BE=91=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ai/agent/index.ts | 2 +- electron/main/ai/agent/types.ts | 9 ++- electron/main/ai/compression/index.ts | 41 ++++++++---- electron/main/ai/conversations.ts | 37 +++++------ electron/main/ipc/ai.ts | 14 ++-- electron/preload/apis/ai.ts | 10 ++- electron/preload/index.d.ts | 10 ++- electron/shared/types.ts | 2 +- src/components/AIChat/ChatExplorer.vue | 11 +++- .../AIChat/chat/AIThinkingIndicator.vue | 20 +++++- src/components/AIChat/chat/ChatMessage.vue | 30 ++++----- src/components/AIChat/chat/ChatStatusBar.vue | 64 +------------------ src/components/AIChat/utils/chatMessages.ts | 16 +++-- src/i18n/locales/en-US/ai.json | 5 +- src/i18n/locales/ja-JP/ai.json | 5 +- src/i18n/locales/zh-CN/ai.json | 5 +- src/i18n/locales/zh-TW/ai.json | 5 +- src/stores/aiChat.ts | 18 +++++- 18 files changed, 168 insertions(+), 136 deletions(-) diff --git a/electron/main/ai/agent/index.ts b/electron/main/ai/agent/index.ts index 4e2745a0..0f21d034 100644 --- a/electron/main/ai/agent/index.ts +++ b/electron/main/ai/agent/index.ts @@ -23,7 +23,7 @@ import { buildSystemPrompt } from './prompt-builder' import { extractThinkingContent, stripToolCallTags } from './content-parser' import { AgentEventHandler } from './event-handler' -type SimpleHistoryMessage = { role: 'user' | 'assistant' | 'summary'; content: string } +type SimpleHistoryMessage = { role: 'user' | 'assistant' | 'system'; content: string } // Re-export types for external consumers export type { AgentConfig, AgentStreamChunk, AgentResult, TokenUsage, AgentRuntimeStatus, SkillContext } from './types' diff --git a/electron/main/ai/agent/types.ts b/electron/main/ai/agent/types.ts index effa5c6f..79c949c6 100644 --- a/electron/main/ai/agent/types.ts +++ b/electron/main/ai/agent/types.ts @@ -21,7 +21,7 @@ export interface AgentConfig { */ export interface AgentStreamChunk { /** chunk 类型 */ - type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'done' | 'error' + type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'compression_done' | 'done' | 'error' /** 文本内容(type=content 时) */ content?: string /** 思考标签名称(type=think 时) */ @@ -42,6 +42,13 @@ export interface AgentStreamChunk { usage?: TokenUsage /** 运行状态(type=status 时返回) */ status?: AgentRuntimeStatus + /** 压缩结果(type=compression_done 时) */ + compressionResult?: { + summaryContent: string + tokensBefore: number + tokensAfter: number + timestamp: number + } } /** diff --git a/electron/main/ai/compression/index.ts b/electron/main/ai/compression/index.ts index b3036d4f..97321bcc 100644 --- a/electron/main/ai/compression/index.ts +++ b/electron/main/ai/compression/index.ts @@ -5,8 +5,8 @@ * 核心流程: * 1. 计算当前上下文总 token → 未超阈值则跳过 * 2. 确定缓冲区:最近 bufferSizePercent% context window 的消息原文 - * 3. 缓冲区之前的消息(含旧 summary)→ LLM 压缩为新摘要 - * 4. 写入 ai_message(role='summary'),替换旧 summary + * 3. 缓冲区之前的消息(含旧 system 摘要)→ LLM 压缩为新摘要 + * 4. 写入 ai_message(role='system'),替换旧摘要 * 5. Thrashing 检查 */ @@ -49,18 +49,21 @@ export interface CompressionResult { | 'error' tokensBefore?: number tokensAfter?: number + summaryContent?: string error?: string } -const DEFAULT_COMPRESSION_PROMPT = `Please compress the following conversation history into a concise summary, preserving key information, decisions, and context. -Requirements: -- Preserve key facts, data, names, and conclusions -- Preserve user preferences and important instructions -- Preserve time points and important events -- Output in the same language as the conversation -- Keep it within {maxTokens} tokens +const DEFAULT_COMPRESSION_PROMPT = `You are a context compression assistant. Compress the conversation below into a structured summary. -Conversation history: +STRICT RULES: +- Output ONLY the summary content. No greetings, no preamble, no meta-commentary, no word/token counts. +- Use the same language as the conversation. +- Maximum output length: {maxTokens} tokens. Be concise. +- Organize by topic/thread when possible. +- Preserve: key facts, decisions, user preferences, data, names, timestamps, action items. +- Omit: pleasantries, filler, redundant back-and-forth. + +CONVERSATION: {messages}` const DEFAULT_CONTEXT_WINDOW = 128000 @@ -121,7 +124,7 @@ export async function checkAndCompress( // 构建压缩输入文本 const compressInput = buildCompressionInput(messagesToCompress, summary) - const targetTokens = Math.floor(contextWindow * 0.1) + const targetTokens = Math.min(Math.floor(contextWindow * 0.1), 16384) // 三级降级:独立模型 → 当前模型 → 强制截断 let summaryText: string | null = null @@ -158,11 +161,23 @@ export async function checkAndCompress( 'Compression', `Thrashing detected: ${tokensAfter} tokens after compression still >= ${thresholdTokens}` ) - return { compressed: true, reason: 'thrashing', tokensBefore: currentTokens, tokensAfter } + return { + compressed: true, + reason: 'thrashing', + tokensBefore: currentTokens, + tokensAfter, + summaryContent: summaryText, + } } aiLogger.info('Compression', `Compressed: ${currentTokens} → ${tokensAfter} tokens`) - return { compressed: true, reason: 'success', tokensBefore: currentTokens, tokensAfter } + return { + compressed: true, + reason: 'success', + tokensBefore: currentTokens, + tokensAfter, + summaryContent: summaryText, + } } catch (error) { aiLogger.error('Compression', 'Compression failed', { error: String(error) }) return { compressed: false, reason: 'error', error: String(error) } diff --git a/electron/main/ai/conversations.ts b/electron/main/ai/conversations.ts index 94473a36..6eaaa330 100644 --- a/electron/main/ai/conversations.ts +++ b/electron/main/ai/conversations.ts @@ -224,7 +224,7 @@ export type ContentBlock = /** * AI 消息类型 */ -export type AIMessageRole = 'user' | 'assistant' | 'summary' +export type AIMessageRole = 'user' | 'assistant' | 'system' export interface TokenUsageData { promptTokens: number @@ -509,38 +509,36 @@ export function getConversationTokenUsage(conversationId: string): TokenUsageDat * 以避免重复加载已被压缩的旧消息。 * * @param conversationId 对话 ID - * @param maxMessages 最大返回条数(取最近 N 条,仅对 summary 之后的消息生效) + * @param maxMessages 最大返回条数(取最近 N 条,仅对 system 摘要之后的消息生效) */ export function getHistoryForAgent( conversationId: string, maxMessages?: number -): Array<{ role: 'user' | 'assistant' | 'summary'; content: string }> { +): Array<{ role: 'user' | 'assistant' | 'system'; content: string }> { const messages = getMessages(conversationId) const validMessages = messages.filter( - (m) => (m.role === 'user' || m.role === 'assistant' || m.role === 'summary') && m.content?.trim() + (m) => (m.role === 'user' || m.role === 'assistant' || m.role === 'system') && m.content?.trim() ) - // 查找最新的 summary 消息位置 - let summaryIndex = -1 + // 查找最新的 system 消息位置(压缩摘要) + let systemIndex = -1 for (let i = validMessages.length - 1; i >= 0; i--) { - if (validMessages[i].role === 'summary') { - summaryIndex = i + if (validMessages[i].role === 'system') { + systemIndex = i break } } - let result: Array<{ role: 'user' | 'assistant' | 'summary'; content: string }> + let result: Array<{ role: 'user' | 'assistant' | 'system'; content: string }> - if (summaryIndex >= 0) { - // 返回 summary + summary 之后的消息 - result = validMessages.slice(summaryIndex).map((m) => ({ role: m.role, content: m.content })) + if (systemIndex >= 0) { + result = validMessages.slice(systemIndex).map((m) => ({ role: m.role, content: m.content })) } else { result = validMessages.map((m) => ({ role: m.role, content: m.content })) } if (maxMessages && result.length > maxMessages) { - // 如果有 summary 且它是第一条,保留它再截取后面的 - if (result.length > 0 && result[0].role === 'summary') { + if (result.length > 0 && result[0].role === 'system') { const rest = result.slice(1) const truncated = rest.slice(-(maxMessages - 1)) return [result[0], ...truncated] @@ -553,15 +551,14 @@ export function getHistoryForAgent( // ==================== Summary / 压缩专用 ==================== /** - * 添加 summary 消息并替换旧的 summary(每个对话只保留一条最新 summary) + * 添加 system 消息并替换旧的 system(每个对话只保留一条最新压缩摘要) */ export function addSummaryMessage(conversationId: string, content: string): AIMessage { const db = getAiDb() - // 删除该对话中所有旧的 summary 消息 - db.prepare("DELETE FROM ai_message WHERE conversation_id = ? AND role = 'summary'").run(conversationId) + db.prepare("DELETE FROM ai_message WHERE conversation_id = ? AND role = 'system'").run(conversationId) - return addMessage(conversationId, 'summary', content) + return addMessage(conversationId, 'system', content) } /** @@ -575,7 +572,7 @@ export function getLatestSummary(conversationId: string): AIMessage | null { SELECT id, conversation_id as conversationId, role, content, timestamp, data_keywords as dataKeywords, data_message_count as dataMessageCount, content_blocks as contentBlocks FROM ai_message - WHERE conversation_id = ? AND role = 'summary' + WHERE conversation_id = ? AND role = 'system' ORDER BY timestamp DESC LIMIT 1 ` @@ -607,7 +604,7 @@ export function getLatestSummary(conversationId: string): AIMessage | null { } /** - * 获取 summary 之后的所有 user/assistant 消息(用于压缩计算) + * 获取 system(压缩摘要)之后的所有 user/assistant 消息(用于压缩计算) */ export function getMessagesAfterSummary( conversationId: string, diff --git a/electron/main/ipc/ai.ts b/electron/main/ipc/ai.ts index 53574161..38ec29e8 100644 --- a/electron/main/ipc/ai.ts +++ b/electron/main/ipc/ai.ts @@ -1093,7 +1093,7 @@ export function registerAIHandlers({ win }: IpcContext): void { chunk: { type: 'status', status: { - phase: 'preparing', + phase: 'compressing', round: 0, toolsUsed: 0, contextTokens: 0, @@ -1118,13 +1118,17 @@ export function registerAIHandlers({ win }: IpcContext): void { aiLogger.info('IPC', `Compression result for ${requestId}`, compressionResult) - if (compressionResult.compressed) { + if (compressionResult.compressed && compressionResult.summaryContent) { win.webContents.send('agent:streamChunk', { requestId, chunk: { - type: 'status', - status: 'compressed', - content: `Context compressed: ${compressionResult.tokensBefore} → ${compressionResult.tokensAfter} tokens`, + type: 'compression_done', + compressionResult: { + summaryContent: compressionResult.summaryContent, + tokensBefore: compressionResult.tokensBefore ?? 0, + tokensAfter: compressionResult.tokensAfter ?? 0, + timestamp: Date.now(), + }, }, }) } diff --git a/electron/preload/apis/ai.ts b/electron/preload/apis/ai.ts index 1482aa1e..6eaa392b 100644 --- a/electron/preload/apis/ai.ts +++ b/electron/preload/apis/ai.ts @@ -41,7 +41,7 @@ export type ContentBlock = } | { type: 'skill'; skillId: string; skillName: string } -export type AIMessageRole = 'user' | 'assistant' | 'summary' +export type AIMessageRole = 'user' | 'assistant' | 'system' export interface TokenUsageData { promptTokens: number @@ -93,7 +93,7 @@ import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '../../ export type { SerializedErrorInfo } from '../../shared/types' export interface AgentStreamChunk { - type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'done' | 'error' + type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'compression_done' | 'done' | 'error' content?: string thinkTag?: string thinkDurationMs?: number @@ -103,6 +103,12 @@ export interface AgentStreamChunk { status?: AgentRuntimeStatus error?: SerializedErrorInfo isFinished?: boolean + compressionResult?: { + summaryContent: string + tokensBefore: number + tokensAfter: number + timestamp: number + } /** Token 使用量(type=done 时返回累计值) */ usage?: TokenUsage } diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 6cfccc36..54337f11 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -305,7 +305,7 @@ type AIContentBlock = } | { type: 'skill'; skillId: string; skillName: string } -type AIMessageRole = 'user' | 'assistant' | 'summary' +type AIMessageRole = 'user' | 'assistant' | 'system' interface AITokenUsageData { promptTokens: number @@ -720,7 +720,7 @@ interface RAGConfig { // Agent 相关类型 interface AgentStreamChunk { - type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'done' | 'error' + type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'compression_done' | 'done' | 'error' content?: string thinkTag?: string thinkDurationMs?: number @@ -730,6 +730,12 @@ interface AgentStreamChunk { status?: AgentRuntimeStatus error?: SerializedErrorInfo isFinished?: boolean + compressionResult?: { + summaryContent: string + tokensBefore: number + tokensAfter: number + timestamp: number + } /** Token 使用量(type=done 时返回累计值) */ usage?: TokenUsage } diff --git a/electron/shared/types.ts b/electron/shared/types.ts index b53e2572..b48676c1 100644 --- a/electron/shared/types.ts +++ b/electron/shared/types.ts @@ -31,7 +31,7 @@ export interface TokenUsage { } export interface AgentRuntimeStatus { - phase: 'preparing' | 'thinking' | 'tool_running' | 'responding' | 'completed' | 'aborted' | 'error' + phase: 'compressing' | 'preparing' | 'thinking' | 'tool_running' | 'responding' | 'completed' | 'aborted' | 'error' round: number toolsUsed: number currentTool?: string diff --git a/src/components/AIChat/ChatExplorer.vue b/src/components/AIChat/ChatExplorer.vue index 1c47cc99..918bd71d 100644 --- a/src/components/AIChat/ChatExplorer.vue +++ b/src/components/AIChat/ChatExplorer.vue @@ -398,7 +398,15 @@ watch(