diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts index d2ebce9..506647d 100644 --- a/src/renderer/components/chat-area.ts +++ b/src/renderer/components/chat-area.ts @@ -368,6 +368,11 @@ export function appendSystemMessage(text: string, tempClass?: string): void { scrollToBottom(); } +/** v4.1: 清除所有消息 DOM 元素(用于 undo/compress 后强制重建) */ +export function clearMessagesDOM(): void { + messagesContainerEl.innerHTML = ''; +} + export function clearMessages(): void { messagesContainerEl.innerHTML = ''; currentPlaceholder = null; diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index f020dc6..7bb557e 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -6,7 +6,7 @@ import { state, KEYS } from '../state/state.js'; import { detectLanguage, truncate, escapeHtml, formatSize } from '../utils/utils.js'; import { getSelectedModel, isThinkEnabled, isVisionAvailable, isToolCallingSupported } from './model-bar.js'; import { - renderMessages, appendAssistantPlaceholder, appendSystemMessage, + renderMessages, clearMessagesDOM, appendAssistantPlaceholder, appendSystemMessage, updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll, updateTotalTokens } from './chat-area.js'; import { showToast } from './toast.js'; @@ -222,6 +222,226 @@ function renderFilePreviews(): void { `).join(''); } +// ══════════════════════════════════════════════ +// v4.1 会话管理命令:/retry, /undo, /compress +// ══════════════════════════════════════════════ + +async function handleRetry(): Promise { + const currentSession = state.get(KEYS.CURRENT_SESSION); + if (!currentSession || currentSession.messages.length < 2) { + showToast('没有可重试的消息', 'warning'); + return; + } + if (state.get(KEYS.IS_STREAMING)) { + showToast('请先停止当前生成', 'warning'); + return; + } + + // 找到最后一条 user 消息 + let lastUserIdx = -1; + for (let i = currentSession.messages.length - 1; i >= 0; i--) { + if (currentSession.messages[i].role === 'user') { lastUserIdx = i; break; } + } + if (lastUserIdx < 0) { showToast('没有找到用户消息', 'warning'); return; } + + // 删除该 user 消息之后的所有消息(assistant 回复 + 可能的 tool 调用链) + const removedCount = currentSession.messages.length - lastUserIdx - 1; + const userMsg = currentSession.messages[lastUserIdx]; + currentSession.messages = currentSession.messages.slice(0, lastUserIdx + 1); + + logInfo(`重试: 删除了 ${removedCount} 条消息,重新发送`); + + const db = state.get(KEYS.DB); + if (db) await db.saveSession(currentSession); + + clearMessagesDOM(); + renderMessages(); + + // 重新触发 Agent Loop / 普通聊天 + const toolCallingEnabled = state.get('toolCallingEnabled', false); + const model = getSelectedModel() || currentSession.model; + + appendAssistantPlaceholder(); + state.set(KEYS.IS_STREAMING, true); + updateSendButton(true); + + if (toolCallingEnabled) { + const historyMessages = currentSession.messages + .filter(m => m.role === 'user' || m.role === 'assistant') + .slice(0, -1) // 不包含刚保留的最后一条 user 消息 + .slice(-20) + .map(m => { + let content = m.content || ''; + if (m._fileContents?.length) { + const fileParts = m._fileContents.map(f => `📄 文件:\n\`\`\`${f.language}\n${f.content}\n\`\`\``); + content = content ? content + '\n\n---\n' + fileParts.join('\n\n---\n') : fileParts.join('\n\n---\n'); + } + return { role: m.role, content, ...(m.images?.length && { images: m.images }) }; + }); + + try { + await runAgentLoop(userMsg.content || '', userMsg.images || [], historyMessages, { + onThinking: (thinking) => updateLastAssistantMessage('', thinking, null), + onContent: (content) => updateLastAssistantMessage(content, null, null), + onToolCallStart: () => {}, + onToolCallResult: () => {}, + onToolCallError: () => {}, + onConfirmTool: async (call) => showToolConfirm(call), + onDone: async (finalContent, toolRecords, loopStats) => { + const assistantMsg: ChatMessage = { + role: 'assistant', content: finalContent || '', timestamp: Date.now(), + ...(toolRecords?.length && { toolCalls: toolRecords }), + ...(loopStats?.eval_count && { eval_count: loopStats.eval_count }), + ...(loopStats?.total_duration && { total_duration: loopStats.total_duration }), + }; + state.update(KEYS.CURRENT_SESSION, (s: any) => ({ + ...s, messages: [...s.messages, assistantMsg], updatedAt: Date.now() + })); + updateLastAssistantMessage(finalContent, null, loopStats || null); + renderMessages(); + await saveCurrentSession(); + updateTotalTokens(); + } + }); + } catch (err) { + logError('重试失败', (err as Error).message); + appendSystemMessage(`❌ 重试失败: ${(err as Error).message}`); + } + } else { + // 普通聊天模式重试 - 直接调用 sendMessage + chatInputEl.value = userMsg.content || ''; + state.set(KEYS.IS_STREAMING, false); + updateSendButton(false); + await sendMessage(); + return; + } + + state.set(KEYS.IS_STREAMING, false); + updateSendButton(false); +} + +async function handleUndo(): Promise { + const currentSession = state.get(KEYS.CURRENT_SESSION); + if (!currentSession || currentSession.messages.length < 2) { + showToast('没有可撤销的消息', 'warning'); + return; + } + if (state.get(KEYS.IS_STREAMING)) { + showToast('请先停止当前生成', 'warning'); + return; + } + + // 从末尾删除:先删 assistant(含可能的 tool 消息),再删 user + const msgs = currentSession.messages; + let removeEnd = msgs.length; + let removeStart = msgs.length - 1; + + // 找到最后一条 user 消息的位置 + while (removeStart >= 0 && msgs[removeStart].role !== 'user') { + removeStart--; + } + if (removeStart < 0) { showToast('没有找到用户消息', 'warning'); return; } + + // 删除 user 消息及之后的所有消息 + const removedCount = removeEnd - removeStart; + currentSession.messages = msgs.slice(0, removeStart); + + logInfo(`撤销: 删除了 ${removedCount} 条消息`); + + const db = state.get(KEYS.DB); + if (db) await db.saveSession(currentSession); + + clearMessagesDOM(); + renderMessages(); + updateTotalTokens(); + showToast('已撤销上一轮对话', 'success'); +} + +async function handleCompress(): Promise { + const currentSession = state.get(KEYS.CURRENT_SESSION); + if (!currentSession || currentSession.messages.length < 6) { + showToast('消息太少,无需压缩', 'warning'); + return; + } + if (state.get(KEYS.IS_STREAMING)) { + showToast('请先停止当前生成', 'warning'); + return; + } + + const api = state.get(KEYS.API); + const model = getSelectedModel() || currentSession.model; + if (!api || !model) { + showToast('请先选择模型', 'warning'); + return; + } + + logInfo('上下文压缩: 开始'); + + // 取中间的消息做摘要(保留首尾各 2 条) + const messages = currentSession.messages; + const keepStart = 2; + const keepEnd = 2; + const head = messages.slice(0, keepStart); + const tail = messages.slice(-keepEnd); + const middle = messages.slice(keepStart, messages.length - keepEnd); + + const conversationText = middle.map(m => { + const role = m.role === 'user' ? '用户' : 'AI'; + let content = m.content || ''; + // 截断过长内容 + if (content.length > 500) content = content.slice(0, 500) + '...'; + if (m.toolCalls?.length) content += ` [工具调用: ${m.toolCalls.map(t => t.name).join(', ')}]`; + return `${role}: ${content}`; + }).join('\n'); + + showToast('正在压缩上下文...', 'info'); + + try { + let summary = ''; + await api.chatStream({ + model, + messages: [{ + role: 'user', + content: `请将以下对话摘要为一段简洁的上下文总结。保留关键信息:讨论的主题、得出的结论、用户的偏好、未完成的任务。用中文输出,不超过 500 字。\n\n对话记录:\n${conversationText}` + }], + stream: true, + think: false, + options: { num_ctx: 8192, temperature: 0.3 } + } as any, (chunk: OllamaStreamChunk) => { + if (chunk.message?.content) { + summary += chunk.message.content; + } + }); + + if (!summary.trim()) { + showToast('压缩失败:模型未返回内容', 'error'); + return; + } + + // 构建压缩后的消息列表 + const summaryMsg: ChatMessage = { + role: 'system', + content: `📋 以下是对之前对话的摘要(已压缩 ${middle.length} 条消息):\n\n${summary}`, + timestamp: Date.now() + }; + + currentSession.messages = [...head, summaryMsg, ...tail]; + + const db = state.get(KEYS.DB); + if (db) await db.saveSession(currentSession); + + clearMessagesDOM(); + renderMessages(); + updateTotalTokens(); + + logInfo(`上下文压缩完成: ${middle.length} 条 → 1 条摘要`); + showToast(`已压缩 ${middle.length} 条消息为摘要`, 'success'); + } catch (err) { + logError('上下文压缩失败', (err as Error).message); + showToast(`压缩失败: ${(err as Error).message}`, 'error'); + } +} + function updateSendButton(streaming: boolean): void { if (streaming) { btnSendEl.innerHTML = ``; @@ -266,6 +486,24 @@ function buildApiMessages(messages: ChatMessage[]): Array<{ role: string; conten export async function sendMessage(): Promise { const text = chatInputEl.value.trim(); + + // ── v4.1 会话管理命令 ── + if (text === '/retry') { + chatInputEl.value = ''; + await handleRetry(); + return; + } + if (text === '/undo') { + chatInputEl.value = ''; + await handleUndo(); + return; + } + if (text === '/compress') { + chatInputEl.value = ''; + await handleCompress(); + return; + } + if (!text && pendingImages.length === 0 && pendingFiles.length === 0) return; if (state.get(KEYS.IS_STREAMING)) return; diff --git a/src/renderer/services/agent-engine.ts b/src/renderer/services/agent-engine.ts index 20ab478..4b70bf3 100644 --- a/src/renderer/services/agent-engine.ts +++ b/src/renderer/services/agent-engine.ts @@ -31,6 +31,9 @@ const TOOL_EXEC_TIMEOUT = 30000; // 工具执行超时 30 秒 const STREAM_TIMEOUT = 120000; // 单次流式调用超时 2 分钟 const MAX_RETRIES = 2; // 工具错误自动重试次数 +/** v4.1: 工具并行执行 — 依赖检测用 */ +const TOOLS_WITH_DATA_DEPS = new Set(['web_fetch', 'edit_file', 'append_file', 'move_file', 'delete_file']); + const AGENT_SYSTEM_PROMPT = `你是一个具备工具调用能力的 AI 助手。你有 25 个工具可以调用,包括文件操作、联网搜索、网页抓取、命令执行、记忆管理等。 ## 核心规则 @@ -562,61 +565,80 @@ export async function runAgentLoop( createdAt: Date.now() }; - for (const call of toolCalls) { - // 检查是否已中止 - if (abortController.signal.aborted) { - callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats()); - return; - } + // ── v4.1 工具并行执行:将独立工具分批并行调用 ── + // 将工具调用分成批次:同一批次内的工具互相独立,可并行执行 + // 跨批次的工具有依赖关系(如 web_fetch 依赖前面的 web_search) + const batches: ToolCall[][] = []; + let currentBatch: ToolCall[] = []; + const batchDeps = new Map(); // 记录每个工具依赖的前序工具名 - // 同一轮内重复调用检测 + for (const call of toolCalls) { + if (currentBatch.length === 0) { + currentBatch.push(call); + } else { + // 检查当前工具是否依赖当前批次中任何工具的结果 + const needsPrevResult = currentBatch.some(prev => + TOOLS_WITH_DATA_DEPS.has(call.function.name) && prev.function.name !== call.function.name + ); + if (needsPrevResult) { + // 有依赖,当前批次结束,开启新批次 + batches.push(currentBatch); + batchDeps.set(call, currentBatch[currentBatch.length - 1].function.name); + currentBatch = [call]; + } else { + currentBatch.push(call); + } + } + } + if (currentBatch.length > 0) batches.push(currentBatch); + + if (batches.length > 1) { + logInfo(`工具并行执行: ${toolCalls.length} 个工具 → ${batches.length} 批次(首批 ${batches[0].length} 个并行)`); + } + + /** 执行单个工具(含重试),返回 [ToolCallRecord, 缓存key|null] */ + const executeSingleTool = async (call: ToolCall): Promise<[ToolCallRecord, string | null]> => { + const cacheKey = getToolCacheKey(call.function.name, call.function.arguments); + + // 重复调用检测 if (isDuplicateCall(call, toolCalls)) { logWarn(`跳过重复工具调用: ${call.function.name}`); - const cachedKey = getToolCacheKey(call.function.name, call.function.arguments); - const cached = toolResultCache.get(cachedKey); + const cached = toolResultCache.get(cacheKey); if (cached) { - messages.push({ role: 'tool', tool_name: call.function.name, content: formatToolResultForModel(call.function.name, cached) }); - callbacks.onToolCallResult(call.function.name, cached, call); + return [{ + name: call.function.name, arguments: call.function.arguments, + result: cached, status: 'success' as const, timestamp: Date.now() + }, null]; } - continue; } // 跨轮次缓存命中 - const cacheKey = getToolCacheKey(call.function.name, call.function.arguments); const cachedResult = toolResultCache.get(cacheKey); if (cachedResult) { logInfo(`工具缓存命中: ${call.function.name}`); - messages.push({ role: 'tool', tool_name: call.function.name, content: formatToolResultForModel(call.function.name, cachedResult) }); - callbacks.onToolCallResult(call.function.name, cachedResult, call); - continue; + return [{ + name: call.function.name, arguments: call.function.arguments, + result: cachedResult, status: 'success' as const, timestamp: Date.now() + }, null]; } callbacks.onToolCallStart(call); logToolStart(call.function.name, JSON.stringify(call.function.arguments)); + // 需要确认的工具(目前只有 run_command 在 confirm 模式下) if (needsConfirmation(call.function.name)) { const confirmed = await callbacks.onConfirmTool(call); - if (abortController.signal.aborted) { - callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats()); - return; - } if (!confirmed) { logWarn(`工具取消: ${call.function.name}`); - const record: ToolCallRecord = { - name: call.function.name, - arguments: call.function.arguments, - result: { success: false, error: '用户取消了操作' }, - status: 'cancelled', - timestamp: Date.now() - }; - allToolRecords.push(record); - messages.push({ role: 'tool', tool_name: call.function.name, content: JSON.stringify({ success: false, error: '用户取消了操作' }) }); - callbacks.onToolCallError(call.function.name, '用户取消', call); - continue; + const cancelResult = { success: false, error: '用户取消了操作' }; + return [{ + name: call.function.name, arguments: call.function.arguments, + result: cancelResult, status: 'cancelled' as const, timestamp: Date.now() + }, null]; } } - // 工具执行 + 自动重试 + // 执行 + 自动重试 let lastError = ''; for (let retry = 0; retry <= MAX_RETRIES; retry++) { try { @@ -633,42 +655,55 @@ export async function runAgentLoop( ) ]); } - - // 成功 - const record: ToolCallRecord = { - name: call.function.name, - arguments: call.function.arguments, - result, - status: result.success ? 'success' : 'error', - timestamp: Date.now() - }; - allToolRecords.push(record); - messages.push({ role: 'tool', tool_name: call.function.name, content: formatToolResultForModel(call.function.name, result) }); - if (result.success && call.function.name !== 'run_command') { - toolResultCache.set(cacheKey, result); - } - callbacks.onToolCallResult(call.function.name, result, call); logToolResult(call.function.name, result.success, result.success ? undefined : result.error); - break; // 成功,退出重试循环 + return [{ + name: call.function.name, arguments: call.function.arguments, + result, status: result.success ? 'success' as const : 'error' as const, timestamp: Date.now() + }, result.success && call.function.name !== 'run_command' ? cacheKey : null]; } catch (err) { lastError = (err as Error).message; if (retry < MAX_RETRIES) { logWarn(`工具重试 ${retry + 1}/${MAX_RETRIES}: ${call.function.name}`, lastError); - await new Promise(r => setTimeout(r, 500)); // 短暂延迟后重试 + await new Promise(r => setTimeout(r, 500)); continue; } - // 最终失败 logError(`工具执行失败: ${call.function.name}`, lastError); - const record: ToolCallRecord = { - name: call.function.name, - arguments: call.function.arguments, + return [{ + name: call.function.name, arguments: call.function.arguments, result: { success: false, error: lastError }, - status: 'error', - timestamp: Date.now() - }; - allToolRecords.push(record); - messages.push({ role: 'tool', tool_name: call.function.name, content: JSON.stringify({ success: false, error: lastError }) }); - callbacks.onToolCallError(call.function.name, lastError, call); + status: 'error' as const, timestamp: Date.now() + }, null]; + } + } + // unreachable + return [{ + name: call.function.name, arguments: call.function.arguments, + result: { success: false, error: lastError }, status: 'error' as const, timestamp: Date.now() + }, null]; + }; + + // 按批次执行:批次内并行,批次间串行 + for (const batch of batches) { + if (abortController.signal.aborted) { + callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats()); + return; + } + + const results = await Promise.all(batch.map(call => executeSingleTool(call))); + + for (const [record, cacheKey] of results) { + allToolRecords.push(record); + messages.push({ + role: 'tool', tool_name: record.name, + content: formatToolResultForModel(record.name, record.result!) + }); + if (cacheKey) toolResultCache.set(cacheKey, record.result!); + if (record.status === 'success') { + callbacks.onToolCallResult(record.name, record.result!, batch.find(c => c.function.name === record.name)!); + } else if (record.status === 'cancelled') { + callbacks.onToolCallError(record.name, '用户取消', batch.find(c => c.function.name === record.name)!); + } else { + callbacks.onToolCallError(record.name, record.result?.error || '执行失败', batch.find(c => c.function.name === record.name)!); } } }