fix: 跨Agent Loop工具上下文断裂修复 — 历史消息注入tool_calls+tool结果+Plan进度
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/version-v0.13.1-E8734A?style=flat-square" alt="version">
|
<img src="https://img.shields.io/badge/version-v0.13.2-E8734A?style=flat-square" alt="version">
|
||||||
<img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron">
|
<img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron">
|
||||||
<img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript">
|
<img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
|
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
|
||||||
@@ -252,7 +252,7 @@ npm start
|
|||||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
||||||
```
|
```
|
||||||
|
|
||||||
产出:`release/Metona Ollama Setup v0.13.1.exe`
|
产出:`release/Metona Ollama Setup v0.13.2.exe`
|
||||||
|
|
||||||
## 🛠️ 常用命令
|
## 🛠️ 常用命令
|
||||||
|
|
||||||
@@ -499,7 +499,7 @@ npm start
|
|||||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
||||||
```
|
```
|
||||||
|
|
||||||
Output: `release/Metona Ollama Setup v0.13.1.exe`
|
Output: `release/Metona Ollama Setup v0.13.2.exe`
|
||||||
|
|
||||||
## 🛠️ Common Commands
|
## 🛠️ Common Commands
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.13.1",
|
"version": "0.13.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.13.1",
|
"version": "0.13.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.2.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.13.1",
|
"version": "0.13.2",
|
||||||
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
||||||
"main": "dist/main/main.js",
|
"main": "dist/main/main.js",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
|
|||||||
+1
-1
@@ -101,7 +101,7 @@ export function createMenu(): void {
|
|||||||
dialog.showMessageBox(mainWindow!, {
|
dialog.showMessageBox(mainWindow!, {
|
||||||
type: 'info',
|
type: 'info',
|
||||||
title: '关于 Metona Ollama',
|
title: '关于 Metona Ollama',
|
||||||
message: 'Metona Ollama Desktop v0.13.1',
|
message: 'Metona Ollama Desktop v0.13.2',
|
||||||
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
|
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
|
||||||
icon: getIconPath()
|
icon: getIconPath()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { runAgentLoop } from '../services/agent-engine.js';
|
|||||||
import { estimateTokens } from '../services/context-manager.js';
|
import { estimateTokens } from '../services/context-manager.js';
|
||||||
import { showToolConfirm } from './tool-confirm-modal.js';
|
import { showToolConfirm } from './tool-confirm-modal.js';
|
||||||
import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js';
|
import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js';
|
||||||
import type { ChatSession, ChatMessage, OllamaStreamChunk, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
|
import type { ChatSession, ChatMessage, OllamaStreamChunk, OllamaMessage, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
|
||||||
|
|
||||||
let chatInputEl: HTMLTextAreaElement;
|
let chatInputEl: HTMLTextAreaElement;
|
||||||
let btnSendEl: HTMLButtonElement;
|
let btnSendEl: HTMLButtonElement;
|
||||||
@@ -489,18 +489,13 @@ async function handleRetry(): Promise<void> {
|
|||||||
updateSendButton(true);
|
updateSendButton(true);
|
||||||
|
|
||||||
// 始终走 Agent Loop
|
// 始终走 Agent Loop
|
||||||
const historyMessages = currentSession.messages
|
// ── 构建历史消息(不含最后一条用户消息,因为它是重试目标)──
|
||||||
|
const historyMessages = buildHistoryMessages(
|
||||||
|
currentSession.messages
|
||||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||||
.slice(0, -1) // 不包含刚保留的最后一条 user 消息
|
.slice(0, -1), // 不包含刚保留的最后一条 user 消息
|
||||||
.slice(-20)
|
30
|
||||||
.map(m => {
|
);
|
||||||
let content = m.content || '';
|
|
||||||
if (m._fileContents?.length) {
|
|
||||||
const fileParts = buildFileContentParts(m._fileContents);
|
|
||||||
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 }), ...((m as any)._videoFrames?.length && { images: [...(m.images || []), ...(m as any)._videoFrames.map((f: any) => f.base64)] }) };
|
|
||||||
});
|
|
||||||
|
|
||||||
let retryMonitor: ReturnType<typeof setInterval> | null = null;
|
let retryMonitor: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
@@ -620,6 +615,16 @@ async function handleRetry(): Promise<void> {
|
|||||||
updateLastAssistantMessage(finalContent, retryThinkContent || null, loopStats || null, getSelectedModel(), Date.now(), toolRecords?.length ? toolRecords : undefined);
|
updateLastAssistantMessage(finalContent, retryThinkContent || null, loopStats || null, getSelectedModel(), Date.now(), toolRecords?.length ? toolRecords : undefined);
|
||||||
}
|
}
|
||||||
renderMessages();
|
renderMessages();
|
||||||
|
// ── 保存 Plan Mode 最终进度到会话(供下一轮 Loop 注入)──
|
||||||
|
const lastPlanStatus = state.get<string>('_lastPlanStatus', '');
|
||||||
|
if (lastPlanStatus) {
|
||||||
|
state.set('_lastPlanStatus', '');
|
||||||
|
state.update(KEYS.CURRENT_SESSION, (s: any) => ({
|
||||||
|
...s,
|
||||||
|
messages: [...s.messages, { role: 'system', content: lastPlanStatus, timestamp: Date.now() }],
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}));
|
||||||
|
}
|
||||||
await saveCurrentSession();
|
await saveCurrentSession();
|
||||||
if (loopStats?.prompt_eval_count) updateCtxRemain(loopStats.prompt_eval_count);
|
if (loopStats?.prompt_eval_count) updateCtxRemain(loopStats.prompt_eval_count);
|
||||||
}
|
}
|
||||||
@@ -974,6 +979,86 @@ function buildApiMessages(messages: ChatMessage[]): Array<{ role: string; conten
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从会话消息构建 Ollama 格式的历史消息列表。
|
||||||
|
* 正确注入 tool_calls 和 role:'tool' 结果消息,
|
||||||
|
* 确保 AI 在新 Agent Loop 中能看到上一轮已执行的工具及其结果。
|
||||||
|
*/
|
||||||
|
function buildHistoryMessages(msgs: ChatMessage[], maxCount = 20): OllamaMessage[] {
|
||||||
|
const result: OllamaMessage[] = [];
|
||||||
|
|
||||||
|
for (const msg of msgs) {
|
||||||
|
if (msg.role !== 'user' && msg.role !== 'assistant' && msg.role !== 'system') continue;
|
||||||
|
if (result.length >= maxCount) break;
|
||||||
|
|
||||||
|
let content = msg.content || '';
|
||||||
|
|
||||||
|
if (msg.role === 'system') {
|
||||||
|
// 系统消息(Plan Mode 进度状态等)
|
||||||
|
result.push({ role: 'system', content });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
// 用户消息:注入文件内容
|
||||||
|
if (msg._fileContents?.length) {
|
||||||
|
const fileParts = buildFileContentParts(msg._fileContents);
|
||||||
|
content = content ? content + '\n\n---\n' + fileParts.join('\n\n---\n') : fileParts.join('\n\n---\n');
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
...(msg.images?.length && { images: msg.images }),
|
||||||
|
...((msg as any)._videoFrames?.length && { images: [...(msg.images || []), ...(msg as any)._videoFrames.map((f: any) => f.base64)] }),
|
||||||
|
});
|
||||||
|
} else if (msg.role === 'assistant') {
|
||||||
|
// 构建 assistant 消息
|
||||||
|
const assistantMsg: OllamaMessage = {
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
...(msg.think && { thinking: msg.think }),
|
||||||
|
...(msg.images?.length && { images: msg.images }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 注入 tool_calls(Ollama 格式)──
|
||||||
|
if (msg.toolCalls?.length) {
|
||||||
|
assistantMsg.tool_calls = msg.toolCalls.map(tc => ({
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name: tc.name,
|
||||||
|
arguments: tc.arguments,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(assistantMsg);
|
||||||
|
|
||||||
|
// ── 注入 tool 结果消息(role: 'tool')──
|
||||||
|
if (msg.toolCalls?.length) {
|
||||||
|
for (const tc of msg.toolCalls) {
|
||||||
|
if (!tc.result) continue;
|
||||||
|
if (result.length >= maxCount) break; // 避免截断处产生孤立 tool 消息
|
||||||
|
const resultContent = tc.status === 'success'
|
||||||
|
? JSON.stringify(tc.result)
|
||||||
|
: JSON.stringify({ success: false, error: tc.result.error || '工具执行失败' });
|
||||||
|
result.push({
|
||||||
|
role: 'tool',
|
||||||
|
tool_name: tc.name,
|
||||||
|
content: resultContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果超过 maxCount,从尾部截取(保留最新消息)
|
||||||
|
if (result.length > maxCount) {
|
||||||
|
return result.slice(-maxCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendMessage(): Promise<void> {
|
export async function sendMessage(): Promise<void> {
|
||||||
const text = chatInputEl.value.trim();
|
const text = chatInputEl.value.trim();
|
||||||
|
|
||||||
@@ -1182,22 +1267,11 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
updateSendButton(true);
|
updateSendButton(true);
|
||||||
|
|
||||||
// 构建历史消息
|
// 构建历史消息
|
||||||
const historyMessages = freshSession.messages
|
// ── 构建历史消息(含工具调用和结果,消除跨 Loop 上下文断裂)──
|
||||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
const historyMessages = buildHistoryMessages(
|
||||||
.slice(-20)
|
freshSession.messages.filter(m => m.role === 'user' || m.role === 'assistant'),
|
||||||
.map(m => {
|
30 // 预留空间给 tool 结果消息,实际有效轮次仍约 20 轮
|
||||||
let content = m.content || '';
|
);
|
||||||
if (m._fileContents && m._fileContents.length > 0) {
|
|
||||||
const fileParts = buildFileContentParts(m._fileContents);
|
|
||||||
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 }),
|
|
||||||
...((m as any)._videoFrames?.length && { images: [...(m.images || []), ...(m as any)._videoFrames.map((f: any) => f.base64)] })
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
let assistantContent = '';
|
let assistantContent = '';
|
||||||
let thinkContent = '';
|
let thinkContent = '';
|
||||||
@@ -1362,6 +1436,16 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
updateLastAssistantMessage(assistantContent, thinkContent || null, loopStats || null, getSelectedModel(), Date.now(), toolRecords?.length ? toolRecords : undefined);
|
updateLastAssistantMessage(assistantContent, thinkContent || null, loopStats || null, getSelectedModel(), Date.now(), toolRecords?.length ? toolRecords : undefined);
|
||||||
}
|
}
|
||||||
renderMessages();
|
renderMessages();
|
||||||
|
// ── 保存 Plan Mode 最终进度到会话(供下一轮 Loop 注入)──
|
||||||
|
const lastPlanStatus2 = state.get<string>('_lastPlanStatus', '');
|
||||||
|
if (lastPlanStatus2) {
|
||||||
|
state.set('_lastPlanStatus', '');
|
||||||
|
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||||
|
...session,
|
||||||
|
messages: [...session.messages, { role: 'system', content: lastPlanStatus2, timestamp: Date.now() }],
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}));
|
||||||
|
}
|
||||||
await saveCurrentSession();
|
await saveCurrentSession();
|
||||||
// 更新剩余上下文
|
// 更新剩余上下文
|
||||||
if (loopStats?.prompt_eval_count) updateCtxRemain(loopStats.prompt_eval_count);
|
if (loopStats?.prompt_eval_count) updateCtxRemain(loopStats.prompt_eval_count);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<span class="logo">🦙</span>
|
<span class="logo">🦙</span>
|
||||||
<span class="app-title">Metona Ollama</span>
|
<span class="app-title">Metona Ollama</span>
|
||||||
<span class="app-version">v0.13.1</span>
|
<span class="app-version">v0.13.2</span>
|
||||||
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ async function handleInit(
|
|||||||
callbacks: AgentCallbacks,
|
callbacks: AgentCallbacks,
|
||||||
userContent: string,
|
userContent: string,
|
||||||
images: string[],
|
images: string[],
|
||||||
historyMessages: Array<{ role: string; content: string; images?: string[] }>,
|
historyMessages: OllamaMessage[],
|
||||||
currentSession: ChatSession | null,
|
currentSession: ChatSession | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// 新一轮对话,清空工具缓存
|
// 新一轮对话,清空工具缓存
|
||||||
@@ -646,13 +646,18 @@ Shell: ${osInfo.shell}
|
|||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
state.set('_lastSystemPrompt', allSystemContent || fullSystemPrompt);
|
state.set('_lastSystemPrompt', allSystemContent || fullSystemPrompt);
|
||||||
|
|
||||||
// 添加历史消息
|
// 添加历史消息(含工具调用和结果)
|
||||||
for (const msg of historyMessages) {
|
for (const msg of historyMessages) {
|
||||||
ctx.messages.push({
|
const ollamaMsg: OllamaMessage = {
|
||||||
role: msg.role as 'user' | 'assistant',
|
role: msg.role as 'user' | 'assistant' | 'system' | 'tool',
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
...(msg.images?.length && { images: msg.images })
|
...(msg.images?.length && { images: msg.images }),
|
||||||
});
|
...(msg.thinking && { thinking: msg.thinking }),
|
||||||
|
...(msg.tool_calls?.length && { tool_calls: msg.tool_calls }),
|
||||||
|
...(msg.tool_name && { tool_name: msg.tool_name }),
|
||||||
|
...(msg.compressed && { compressed: msg.compressed }),
|
||||||
|
};
|
||||||
|
ctx.messages.push(ollamaMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户消息
|
// 用户消息
|
||||||
@@ -1681,6 +1686,13 @@ async function handleReflecting(
|
|||||||
} catch { /* Gate 异常不影响主流程 */ }
|
} catch { /* Gate 异常不影响主流程 */ }
|
||||||
|
|
||||||
logInfo('模型停止工具调用 → ReAct Agent Loop 结束');
|
logInfo('模型停止工具调用 → ReAct Agent Loop 结束');
|
||||||
|
// ── 保存 Plan Mode 最终进度到状态,供下一轮 Loop 注入历史 ──
|
||||||
|
if (ctx.mode === 'plan') {
|
||||||
|
const tracker = getPlanTracker();
|
||||||
|
if (tracker.active && tracker.steps.length > 0) {
|
||||||
|
state.set('_lastPlanStatus', formatPlanStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
clearPlanTracker();
|
clearPlanTracker();
|
||||||
|
|
||||||
// ── 自动记忆提取(异步,不阻塞用户看到回复)──
|
// ── 自动记忆提取(异步,不阻塞用户看到回复)──
|
||||||
@@ -1745,7 +1757,7 @@ function makeStats(ctx: LoopContext) {
|
|||||||
export async function runAgentLoop(
|
export async function runAgentLoop(
|
||||||
userContent: string,
|
userContent: string,
|
||||||
images: string[],
|
images: string[],
|
||||||
historyMessages: Array<{ role: string; content: string; images?: string[] }>,
|
historyMessages: OllamaMessage[],
|
||||||
callbacks: AgentCallbacks
|
callbacks: AgentCallbacks
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const api = state.get<OllamaAPI>(KEYS.API);
|
const api = state.get<OllamaAPI>(KEYS.API);
|
||||||
@@ -1893,6 +1905,13 @@ export async function runAgentLoop(
|
|||||||
}
|
}
|
||||||
transition(ctx, S.TERMINATED);
|
transition(ctx, S.TERMINATED);
|
||||||
} finally {
|
} finally {
|
||||||
|
// ── 保存 Plan Mode 进度(中止/异常路径也保留)──
|
||||||
|
if (ctx.mode === 'plan') {
|
||||||
|
const tracker = getPlanTracker();
|
||||||
|
if (tracker.active && tracker.steps.length > 0) {
|
||||||
|
state.set('_lastPlanStatus', formatPlanStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
clearPlanTracker();
|
clearPlanTracker();
|
||||||
endSessionMetrics();
|
endSessionMetrics();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user