chore: 版本号 0.14.3 → 0.14.4
refactor: Plan Mode 进度仅变化时注入(避免每轮冗余) refactor: Plan Mode 执行规则精简 20行→3行 refactor: 断点续传提示词合并 refactor: PlanAutoTrack Hook 去除模糊匹配,仅 AI 手动 plan_track 精确标记 refactor: buildHistoryMessages 取 user+assistant,不取 system refactor: 附件传递改为 JSON 结构化(文件/视频 Base64,图片仅 images[]) refactor: 用户文字始终排在附件 JSON 之后 fix: onDone 双重渲染导致 AI 消息卡片重复(正常路径+重试路径)
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.14.3-E8734A?style=flat-square" alt="version">
|
<img src="https://img.shields.io/badge/version-v0.14.4-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">
|
||||||
@@ -253,7 +253,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.14.3.exe`
|
产出:`release/Metona Ollama Setup v0.14.4.exe`
|
||||||
|
|
||||||
## 🛠️ 常用命令
|
## 🛠️ 常用命令
|
||||||
|
|
||||||
@@ -501,7 +501,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.14.3.exe`
|
Output: `release/Metona Ollama Setup v0.14.4.exe`
|
||||||
|
|
||||||
## 🛠️ Common Commands
|
## 🛠️ Common Commands
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.14.3",
|
"version": "0.14.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.14.3",
|
"version": "0.14.4",
|
||||||
"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.14.3",
|
"version": "0.14.4",
|
||||||
"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.14.3',
|
message: 'Metona Ollama Desktop v0.14.4',
|
||||||
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()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -909,55 +909,50 @@ function buildPlanConfirmHtml(plan: string, steps: string[]): string {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Base64 编码(支持 UTF-8) */
|
||||||
|
function base64Encode(str: string): string {
|
||||||
|
const bytes = new TextEncoder().encode(str);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
function buildFileContentParts(fileContents: Array<{ language: string; content: string }>): string[] {
|
function buildFileContentParts(fileContents: Array<{ language: string; content: string }>): string[] {
|
||||||
const numCtx = state.get<number>(KEYS.NUM_CTX, 24576);
|
const numCtx = state.get<number>(KEYS.NUM_CTX, 24576);
|
||||||
const fileBudget = Math.floor(numCtx * FILE_TOKEN_BUDGET_RATIO);
|
const fileBudget = Math.floor(numCtx * FILE_TOKEN_BUDGET_RATIO);
|
||||||
let usedTokens = 0;
|
let usedTokens = 0;
|
||||||
const maxSingleFileTokens = Math.floor(fileBudget * 0.5); // 单文件不超过预算的50%
|
const maxSingleFileTokens = Math.floor(fileBudget * 0.5);
|
||||||
|
|
||||||
return fileContents.map((f, i) => {
|
return fileContents.map((f, i) => {
|
||||||
const lang = f.language || '';
|
const lang = f.language || 'text';
|
||||||
const rawTokens = estimateTokens(f.content);
|
let text = f.content;
|
||||||
const total = fileContents.length;
|
const rawTokens = estimateTokens(text);
|
||||||
|
|
||||||
// 文件足够小,直接全量(不剥离注释,保持完整性)
|
// 小文件直接使用,大文件剥离注释后截断
|
||||||
if (rawTokens <= 500 && usedTokens + rawTokens <= fileBudget) {
|
if (rawTokens > 500) {
|
||||||
usedTokens += rawTokens;
|
const stripped = stripComments(text, lang);
|
||||||
return `📄 文件 ${i + 1}/${total}:\n\n\`\`\`${lang}\n${f.content}\n\`\`\``;
|
const strippedTokens = estimateTokens(stripped);
|
||||||
}
|
|
||||||
|
|
||||||
// 大文件:剥离注释 + token 预算截断
|
|
||||||
const remaining = fileBudget - usedTokens;
|
|
||||||
if (remaining <= 0) {
|
|
||||||
return `⚠️ 文件 ${i + 1}/${total} 因 token 预算耗尽被跳过 (${f.content.length} 字符)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = stripComments(f.content, lang);
|
|
||||||
let truncated = false;
|
|
||||||
|
|
||||||
// 如果剥离注释后仍然超预算,截断到剩余预算
|
|
||||||
const strippedTokens = estimateTokens(text);
|
|
||||||
if (strippedTokens > maxSingleFileTokens) {
|
if (strippedTokens > maxSingleFileTokens) {
|
||||||
// 按比例截取字符:预算 / 估算tokens * 长度
|
|
||||||
const ratio = Math.min(1, maxSingleFileTokens / strippedTokens);
|
const ratio = Math.min(1, maxSingleFileTokens / strippedTokens);
|
||||||
const cutAt = Math.floor(text.length * ratio);
|
text = stripped.slice(0, Math.floor(stripped.length * ratio));
|
||||||
text = text.slice(0, cutAt);
|
} else {
|
||||||
truncated = true;
|
text = stripped;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalTokens = estimateTokens(text);
|
const finalTokens = estimateTokens(text);
|
||||||
if (usedTokens + finalTokens > fileBudget) {
|
if (usedTokens + finalTokens > fileBudget) return '';
|
||||||
const ratio = Math.min(1, remaining / finalTokens);
|
usedTokens += finalTokens;
|
||||||
const cutAt = Math.floor(text.length * ratio);
|
|
||||||
text = text.slice(0, cutAt);
|
|
||||||
truncated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
usedTokens += estimateTokens(text);
|
return JSON.stringify({
|
||||||
const label = strippedTokens !== rawTokens ? ' (已剥离注释)' : '';
|
file_name: `文件 ${i + 1}/${fileContents.length}`,
|
||||||
const truncLabel = truncated ? ' [已截断]' : '';
|
file_type: lang,
|
||||||
return `📄 文件 ${i + 1}/${total}${label}${truncLabel}:\n\n\`\`\`${lang}\n${text}\n\`\`\``;
|
context_encode: 'base64',
|
||||||
|
context: base64Encode(text),
|
||||||
});
|
});
|
||||||
|
}).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildApiMessages(messages: ChatMessage[]): Array<{ role: string; content: string; images?: string[] }> {
|
function buildApiMessages(messages: ChatMessage[]): Array<{ role: string; content: string; images?: string[] }> {
|
||||||
@@ -982,27 +977,25 @@ function buildApiMessages(messages: ChatMessage[]): Array<{ role: string; conten
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 从会话消息构建 Ollama 格式的历史消息列表。
|
* 从会话消息构建 Ollama 格式的历史消息列表。
|
||||||
* 正确注入 tool_calls 和 role:'tool' 结果消息,
|
* 注入 assistant + user(含 _apiContent)+ tool_calls + role:'tool' 结果。
|
||||||
* 确保 AI 在新 Agent Loop 中能看到上一轮已执行的工具及其结果。
|
* 不注入 system 消息——保证传给 API 时始终只有 1 条 system 且排在首位。
|
||||||
*/
|
*/
|
||||||
function buildHistoryMessages(msgs: ChatMessage[], maxCount = 20): OllamaMessage[] {
|
function buildHistoryMessages(msgs: ChatMessage[], maxCount = 20): OllamaMessage[] {
|
||||||
const result: OllamaMessage[] = [];
|
const result: OllamaMessage[] = [];
|
||||||
|
|
||||||
for (const msg of msgs) {
|
for (const msg of msgs) {
|
||||||
// 只注入 assistant 和 system 消息(用户消息仅当前发送的一条,由 runAgentLoop userContent 单独传入)
|
if (msg.role !== 'assistant' && msg.role !== 'user') continue;
|
||||||
if (msg.role !== 'assistant' && msg.role !== 'system') continue;
|
|
||||||
if (result.length >= maxCount) break;
|
if (result.length >= maxCount) break;
|
||||||
|
|
||||||
let content = msg.content || '';
|
if (msg.role === 'user') {
|
||||||
|
// 用 _apiContent(含附件 JSON 结构化数据),没有则回退 content
|
||||||
if (msg.role === 'system') {
|
const content = (msg as any)._apiContent || msg.content || '';
|
||||||
// 系统消息(Plan Mode 进度状态等)
|
result.push({ role: 'user', content, ...(msg.images?.length && { images: msg.images }) });
|
||||||
result.push({ role: 'system', content });
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.role === 'assistant') {
|
if (msg.role === 'assistant') {
|
||||||
// 构建 assistant 消息
|
const content = msg.content || '';
|
||||||
const assistantMsg: OllamaMessage = {
|
const assistantMsg: OllamaMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content,
|
content,
|
||||||
@@ -1027,7 +1020,7 @@ function buildHistoryMessages(msgs: ChatMessage[], maxCount = 20): OllamaMessage
|
|||||||
if (msg.toolCalls?.length) {
|
if (msg.toolCalls?.length) {
|
||||||
for (const tc of msg.toolCalls) {
|
for (const tc of msg.toolCalls) {
|
||||||
if (!tc.result) continue;
|
if (!tc.result) continue;
|
||||||
if (result.length >= maxCount) break; // 避免截断处产生孤立 tool 消息
|
if (result.length >= maxCount) break;
|
||||||
const resultContent = tc.status === 'success'
|
const resultContent = tc.status === 'success'
|
||||||
? JSON.stringify(tc.result)
|
? JSON.stringify(tc.result)
|
||||||
: JSON.stringify({ success: false, error: tc.result.error || '工具执行失败' });
|
: JSON.stringify({ success: false, error: tc.result.error || '工具执行失败' });
|
||||||
@@ -1041,7 +1034,7 @@ function buildHistoryMessages(msgs: ChatMessage[], maxCount = 20): OllamaMessage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果超过 maxCount,从尾部截取(保留最新消息)
|
// 超过 maxCount 从尾部截取
|
||||||
if (result.length > maxCount) {
|
if (result.length > maxCount) {
|
||||||
return result.slice(-maxCount);
|
return result.slice(-maxCount);
|
||||||
}
|
}
|
||||||
@@ -1138,20 +1131,15 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
// ── 聊天卡片:只显示用户自己的文字 + 附件视觉预览(缩略图/文件卡片/视频帧),不含代码生成的文本标记 ──
|
// ── 聊天卡片:只显示用户自己的文字 + 附件视觉预览(缩略图/文件卡片/视频帧),不含代码生成的文本标记 ──
|
||||||
const displayContent = text || '';
|
const displayContent = text || '';
|
||||||
|
|
||||||
// ── Ollama API 用:完整描述(文字 + 文件内容 + 视频帧序列 + 图片提示)──
|
// ── Ollama API 用:附件 JSON 结构化数据在前,用户文字消息始终在最后 ──
|
||||||
const apiParts: string[] = [];
|
const apiParts: string[] = [];
|
||||||
if (text) apiParts.push(text);
|
|
||||||
if (userFiles.length > 0) {
|
if (userFiles.length > 0) {
|
||||||
const fileContents = pendingFiles.map(f => ({ language: f.language, content: f.content }));
|
const fileContents = pendingFiles.map(f => ({ language: f.language, content: f.content }));
|
||||||
const fileParts = buildFileContentParts(fileContents);
|
const fileParts = buildFileContentParts(fileContents);
|
||||||
apiParts.push(...fileParts);
|
apiParts.push(...fileParts);
|
||||||
logInfo('📄 文件内容已注入', `${fileParts.length} 段, 共 ${fileContents.reduce((s, f) => s + f.content.length, 0)} 字符`);
|
logInfo('📄 文件内容已注入', `${fileParts.length} 段, 共 ${fileContents.reduce((s, f) => s + f.content.length, 0)} 字符`);
|
||||||
}
|
}
|
||||||
if (pendingImages.length === 1) {
|
// 视频:帧图片通过 images[] 传递,元数据用 JSON 结构化(与文件格式一致)
|
||||||
apiParts.push(`[已上传图片: ${pendingImages[0].name}]`);
|
|
||||||
} else if (pendingImages.length > 1) {
|
|
||||||
apiParts.push(`[已上传 ${pendingImages.length} 张图片: ${pendingImages.map(img => img.name).join(', ')}]`);
|
|
||||||
}
|
|
||||||
let allVids: Array<{ name: string; count: number; dur: number; frames: Array<{ timestampSeconds: number; name: string; base64: string }> }> = [];
|
let allVids: Array<{ name: string; count: number; dur: number; frames: Array<{ timestampSeconds: number; name: string; base64: string }> }> = [];
|
||||||
if (hasVideos) {
|
if (hasVideos) {
|
||||||
if (pendingVideoMeta && pendingVideoFrames.length > 0) {
|
if (pendingVideoMeta && pendingVideoFrames.length > 0) {
|
||||||
@@ -1160,14 +1148,30 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
for (const v of completedVideos) {
|
for (const v of completedVideos) {
|
||||||
allVids.push({ name: v.fileName, count: v.frames.length, dur: v.duration, frames: v.frames });
|
allVids.push({ name: v.fileName, count: v.frames.length, dur: v.duration, frames: v.frames });
|
||||||
}
|
}
|
||||||
const parts: string[] = [];
|
// 计算每个视频在全局 images[] 中的起始索引(跳过用户上传的图片)
|
||||||
|
let imageStart = pendingImages.length;
|
||||||
for (const v of allVids) {
|
for (const v of allVids) {
|
||||||
const fl = v.frames.map(f => ` ${String(f.timestampSeconds).padStart(4, ' ')}s — ${f.name}`).join('\n');
|
apiParts.push(JSON.stringify({
|
||||||
parts.push(`[视频帧序列 · ${v.name} · ${v.count}帧 · 1fps · ${v.dur.toFixed(0)}s]\n${fl}`);
|
file_name: v.name,
|
||||||
|
file_type: v.name.split('.').pop() || 'video',
|
||||||
|
context_encode: 'base64',
|
||||||
|
context: base64Encode(JSON.stringify({
|
||||||
|
frame_count: v.count,
|
||||||
|
fps: 1,
|
||||||
|
duration: Math.round(v.dur),
|
||||||
|
image_start: imageStart,
|
||||||
|
image_end: imageStart + v.count - 1,
|
||||||
|
frames: v.frames.map((f, fi) => ({
|
||||||
|
timestamp: f.timestampSeconds,
|
||||||
|
image_index: imageStart + fi,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
imageStart += v.count;
|
||||||
}
|
}
|
||||||
apiParts.push(parts.join('\n\n'));
|
|
||||||
apiParts.push('以上为视频帧序列,按时间顺序排列。');
|
|
||||||
}
|
}
|
||||||
|
// 用户文字始终放最后
|
||||||
|
if (text) apiParts.push(text);
|
||||||
apiContentForModel = apiParts.join('\n\n');
|
apiContentForModel = apiParts.join('\n\n');
|
||||||
|
|
||||||
const msg: ChatMessage = {
|
const msg: ChatMessage = {
|
||||||
@@ -1371,12 +1375,11 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
// 如果有多个迭代,最后一条消息已由 onNewIteration 保存,这里创建新消息保存最终回复
|
// 如果有多个迭代,最后一条消息已由 onNewIteration 保存,这里创建新消息保存最终回复
|
||||||
if (agentModeIterations > 0) {
|
if (agentModeIterations > 0) {
|
||||||
if (assistantContent) {
|
if (assistantContent) {
|
||||||
// 最后一轮迭代的 stats(loopStats.eval_count 即为最后一轮的 token 数)
|
|
||||||
const lastIterDuration = (Date.now() - lastIterationStartTime) * 1e6;
|
const lastIterDuration = (Date.now() - lastIterationStartTime) * 1e6;
|
||||||
|
|
||||||
const lastMsg: ChatMessage = {
|
const lastMsg: ChatMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: assistantContent,
|
content: assistantContent,
|
||||||
|
model: getSelectedModel(),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
...(thinkContent && { think: thinkContent }),
|
...(thinkContent && { think: thinkContent }),
|
||||||
...(toolRecords?.length && { toolCalls: toolRecords }),
|
...(toolRecords?.length && { toolCalls: toolRecords }),
|
||||||
@@ -1390,12 +1393,12 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
updateLastAssistantMessage(assistantContent, thinkContent || null, loopStats || null, getSelectedModel(), Date.now(), toolRecords?.length ? toolRecords : undefined);
|
|
||||||
} else {
|
} else {
|
||||||
// 单迭代模式:正常保存
|
// 单迭代模式:正常保存
|
||||||
const assistantMsg: ChatMessage = {
|
const assistantMsg: ChatMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: assistantContent || '',
|
content: assistantContent || '',
|
||||||
|
model: getSelectedModel(),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
...(thinkContent && { think: thinkContent }),
|
...(thinkContent && { think: thinkContent }),
|
||||||
...(toolRecords?.length && { toolCalls: toolRecords }),
|
...(toolRecords?.length && { toolCalls: toolRecords }),
|
||||||
@@ -1408,9 +1411,11 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
messages: [...session.messages, assistantMsg],
|
messages: [...session.messages, assistantMsg],
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
}));
|
}));
|
||||||
updateLastAssistantMessage(assistantContent, thinkContent || null, loopStats || null, getSelectedModel(), Date.now(), toolRecords?.length ? toolRecords : undefined);
|
|
||||||
}
|
}
|
||||||
|
// 清掉流式 placeholder,统一从 session 渲染(单条消息,Markdown 格式)
|
||||||
|
clearMessagesDOM();
|
||||||
renderMessages();
|
renderMessages();
|
||||||
|
|
||||||
// ── 保存 Plan Mode 最终进度到会话(供下一轮 Loop 注入)──
|
// ── 保存 Plan Mode 最终进度到会话(供下一轮 Loop 注入)──
|
||||||
const lastPlanStatus2 = state.get<string>('_lastPlanStatus', '');
|
const lastPlanStatus2 = state.get<string>('_lastPlanStatus', '');
|
||||||
if (lastPlanStatus2) {
|
if (lastPlanStatus2) {
|
||||||
|
|||||||
@@ -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.14.3</span>
|
<span class="app-version">v0.14.4</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"/>
|
||||||
|
|||||||
@@ -773,11 +773,8 @@ async function handleInit(
|
|||||||
savePlanTracker(tracker);
|
savePlanTracker(tracker);
|
||||||
// 清空恢复数据,避免重复恢复
|
// 清空恢复数据,避免重复恢复
|
||||||
state.set('_planResumeData', null);
|
state.set('_planResumeData', null);
|
||||||
// 注入恢复状态提示
|
systemPromptParts.push(`[Plan Mode 断点续传] 上一轮计划未完成已自动恢复,当前进度 ${tracker.done}/${tracker.total}。直接继续执行剩余 ${tracker.total - tracker.done} 步,跳过已完成步骤,不要重新输出计划。`);
|
||||||
systemPromptParts.push(`[Plan Mode 断点续传] 上一轮计划未完成,已自动恢复。当前进度: ${tracker.done}/${tracker.total}。已完成步骤将继续保持完成状态。继续执行剩余步骤,跳过已完成的步骤。`);
|
logInfo(`Plan Mode 断点续传: ${tracker.done}/${tracker.total}`);
|
||||||
logInfo(`Plan Mode 断点续传: ${tracker.done}/${tracker.total} 步骤已恢复,继续执行剩余 ${tracker.total - tracker.done} 步`);
|
|
||||||
// 跳过"输出计划"约束,直接告诉 AI 继续执行
|
|
||||||
systemPromptParts.push(`⚠️ 计划已在上一轮生成并批准,现在直接继续执行剩余步骤。不需要重新输出计划。`);
|
|
||||||
} else {
|
} else {
|
||||||
// 清除可能的残留
|
// 清除可能的残留
|
||||||
state.set('_planResumeData', null);
|
state.set('_planResumeData', null);
|
||||||
@@ -787,35 +784,11 @@ async function handleInit(
|
|||||||
// ── Plan Mode 系统提示词 — 告知 AI plan_track 工具和追踪规则 ──
|
// ── Plan Mode 系统提示词 — 告知 AI plan_track 工具和追踪规则 ──
|
||||||
if (ctx.mode === 'plan') {
|
if (ctx.mode === 'plan') {
|
||||||
systemPromptParts.push(`[Plan Mode 执行规则]
|
systemPromptParts.push(`[Plan Mode 执行规则]
|
||||||
你当前处于 Plan Mode(先规划后执行)。重要规则:
|
当前处于先规划后执行模式。核心规则:
|
||||||
|
|
||||||
**输出格式(必须严格遵守)**:
|
1. 首轮必须先输出执行计划(Markdown 格式,每步标注是否需要工具),等待用户批准,**不要**同时调用工具。
|
||||||
你的第一轮回复必须是一个清晰的执行计划,使用以下格式。不要输出其他无关内容。
|
2. 批准后按计划逐步执行,每完成一步可调用 plan_track mark_done 标记进度。
|
||||||
|
3. 全部完成后输出最终回答。`);
|
||||||
## 任务分析
|
|
||||||
[1-2 句话概述任务,展示你的理解]
|
|
||||||
|
|
||||||
## 执行计划
|
|
||||||
1. **步骤名称** — 工具: tool_name — 简要描述这一步做什么,完成后预期结果是什么
|
|
||||||
2. **步骤名称** — 无需工具 — 如果这一步只需推理/分析/总结,标注"无需工具"并描述分析要点
|
|
||||||
...(根据任务复杂度,通常 2-6 步)
|
|
||||||
|
|
||||||
## 预期结果
|
|
||||||
[完成所有步骤后的最终产出]
|
|
||||||
|
|
||||||
**关键规则**:
|
|
||||||
- 需要调用工具的步骤:必须写"工具: xxx"(如 工具: web_search、工具: write_file)
|
|
||||||
- 纯分析/推理/总结的步骤:写"无需工具",描述你要分析和思考的要点
|
|
||||||
- 禁止模糊描述如"分析需求"——即使是分析步骤,也要写清楚分析什么、关注什么
|
|
||||||
- 如果用户已上传图片或文件,第一步必须是分析附件(写"无需工具"即可,图片你直接能看到)
|
|
||||||
- 如果整个任务完全不需要工具(如纯翻译、润色、总结对话),可以全部步骤都是"无需工具"
|
|
||||||
- 输出计划后等待用户批准,**不要**在输出计划的同时调用工具
|
|
||||||
|
|
||||||
**计划批准后**:
|
|
||||||
- 需要工具的步骤:调用对应工具完成任务
|
|
||||||
- 无需工具的步骤:直接基于已有信息给出分析结论
|
|
||||||
- 系统会自动追踪工具执行进度
|
|
||||||
- 所有步骤完成后直接给出最终回答。`);
|
|
||||||
|
|
||||||
// 将用户原始任务描述固化到系统提示词中(不会被压缩或清理)
|
// 将用户原始任务描述固化到系统提示词中(不会被压缩或清理)
|
||||||
const taskDesc = userContent?.slice(0, 200) || '';
|
const taskDesc = userContent?.slice(0, 200) || '';
|
||||||
@@ -1145,13 +1118,17 @@ async function handleThinking(
|
|||||||
ctx.thinking = '';
|
ctx.thinking = '';
|
||||||
ctx.toolCalls.length = 0;
|
ctx.toolCalls.length = 0;
|
||||||
|
|
||||||
// ── Plan Mode 执行进度注入 — 每次思考前展示当前状态 ──
|
// ── Plan Mode 执行进度注入 — 仅在进度变化时注入,避免每轮重复 ──
|
||||||
if (ctx.mode === 'plan' && ctx.loopCount >= 1) {
|
if (ctx.mode === 'plan' && ctx.loopCount >= 1) {
|
||||||
const tracker = getPlanTracker();
|
const tracker = getPlanTracker();
|
||||||
if (tracker.active && tracker.steps.length > 0) {
|
if (tracker.active && tracker.steps.length > 0) {
|
||||||
|
const lastDone = state.get<number>('_planLastInjectedDone', -1);
|
||||||
|
if (tracker.done !== lastDone) {
|
||||||
const status = formatPlanStatus();
|
const status = formatPlanStatus();
|
||||||
if (status) {
|
if (status) {
|
||||||
ctx.messages.push({ role: 'user', content: status, ephemeral: true });
|
ctx.messages.push({ role: 'user', content: status, ephemeral: true });
|
||||||
|
state.set('_planLastInjectedDone', tracker.done);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,9 +247,9 @@ export const fileWriteDedupHook: HarnessHook = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PlanAutoTrackHook — Plan Mode 步骤自动追踪(post_tool 非阻断级)
|
* PlanAutoTrackHook — Plan Mode 步骤追踪(post_tool 非阻断级)
|
||||||
* 工具执行成功后自动匹配 Plan 步骤标记完成,不再依赖模型手动调用 plan_track。
|
* 不自动标记步骤(避免模糊匹配误标),仅由 AI 通过 plan_track 工具精确标记。
|
||||||
* write_file 成功后还会自动标记所有"设计/构建/编码"类步骤。
|
* 此处只维护 write_file 路径记录供 FileWriteDedupHook 使用。
|
||||||
*/
|
*/
|
||||||
export const planAutoTrackHook: HarnessHook = {
|
export const planAutoTrackHook: HarnessHook = {
|
||||||
name: 'PlanAutoTrack',
|
name: 'PlanAutoTrack',
|
||||||
@@ -260,63 +260,9 @@ export const planAutoTrackHook: HarnessHook = {
|
|||||||
if (ctx.mode !== 'plan') return { passed: true, message: '' };
|
if (ctx.mode !== 'plan') return { passed: true, message: '' };
|
||||||
const toolName = data.toolName || '';
|
const toolName = data.toolName || '';
|
||||||
const toolResult = data.toolResult as Record<string, unknown> | undefined;
|
const toolResult = data.toolResult as Record<string, unknown> | undefined;
|
||||||
if (!toolResult?.success) return { passed: true, message: '' };
|
|
||||||
|
|
||||||
// 延迟导入避免循环依赖
|
|
||||||
const { getPlanTracker, savePlanTracker } = await import('./tool-registry.js');
|
|
||||||
const tracker = getPlanTracker();
|
|
||||||
if (!tracker.active || tracker.steps.length === 0) return { passed: true, message: '' };
|
|
||||||
|
|
||||||
// 工具名 → 计划步骤关键词映射
|
|
||||||
const TOOL_STEP_KEYWORDS: Record<string, string[]> = {
|
|
||||||
web_search: ['搜索', '查找', '检索', 'search', '查询'],
|
|
||||||
web_fetch: ['抓取', 'fetch', '获取', '浏览', '提取内容'],
|
|
||||||
read_file: ['读取', 'read', '查看', '检查'],
|
|
||||||
write_file: ['写入', 'write', '生成.*文件', '创建.*文件', '文件.*生成', '保存.*文件', '输出.*文件'],
|
|
||||||
edit_file: ['编辑', '修改', 'edit'],
|
|
||||||
list_directory: ['列出', 'list', '浏览.*目录', '查看.*结构'],
|
|
||||||
tree: ['目录树', 'tree', '结构'],
|
|
||||||
run_command: ['运行', '执行', 'run', '命令', '编译', '测试'],
|
|
||||||
git: ['git', '提交', '推送', '暂存', 'commit'],
|
|
||||||
browser_open: ['打开.*网页', '浏览器', 'browser'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const keywords = TOOL_STEP_KEYWORDS[toolName] || [];
|
|
||||||
let matched = false;
|
|
||||||
|
|
||||||
if (keywords.length > 0) {
|
|
||||||
for (const step of tracker.steps) {
|
|
||||||
if (step.done) continue;
|
|
||||||
for (const kw of keywords) {
|
|
||||||
if (new RegExp(kw, 'i').test(step.label)) {
|
|
||||||
step.done = true;
|
|
||||||
tracker.done = tracker.steps.filter(s => s.done).length;
|
|
||||||
savePlanTracker(tracker);
|
|
||||||
logInfo(`Plan 自动追踪: ${toolName} → 步骤${step.index} "${step.label.slice(0, 40)}" (${tracker.done}/${tracker.total})`);
|
|
||||||
matched = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (matched) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// write_file 成功后,自动标记所有"设计/构建/编码"类步骤
|
|
||||||
if (toolName === 'write_file') {
|
|
||||||
const DESIGN_KEYWORDS = /HTML|CSS|构建|编码|设计|代码|前端|布局|排版|样式|视觉|coding|design|style/i;
|
|
||||||
for (const step of tracker.steps) {
|
|
||||||
if (step.done) continue;
|
|
||||||
if (DESIGN_KEYWORDS.test(step.label)) {
|
|
||||||
step.done = true;
|
|
||||||
tracker.done = tracker.steps.filter(s => s.done).length;
|
|
||||||
savePlanTracker(tracker);
|
|
||||||
logInfo(`Plan 自动追踪: write_file → 设计步骤${step.index} "${step.label.slice(0, 40)}" (${tracker.done}/${tracker.total})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// write_file 成功后记录文件路径,供 FileWriteDedupHook 拦截
|
// write_file 成功后记录文件路径,供 FileWriteDedupHook 拦截
|
||||||
if (toolName === 'write_file' && data.toolArgs?.path) {
|
if (toolName === 'write_file' && toolResult?.success && data.toolArgs?.path) {
|
||||||
_writtenFilesThisSession.add(String(data.toolArgs.path));
|
_writtenFilesThisSession.add(String(data.toolArgs.path));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user