v0.12.11: Plan Mode 格式标准化 + 图片附件感知 + 弹窗UI优化

feat: Plan Mode 强制输出格式模板(任务分析/执行计划/预期结果)
feat: 支持无需工具步骤,纯分析推理任务不再强制工具调用
feat: 反幻觉铁律新增3条(已上传附件不查找、read_file不能看图、Plan不规划查找步骤)
fix: 上传图片后消息文本明确提示AI图片已随消息提供
fix: Plan Mode确认弹窗UI优化(步骤卡片样式/宽度/滚动)
style: extractPlanSteps 双策略解析,兼容多种模型输出格式
This commit is contained in:
紫影233
2026-06-24 11:40:18 +08:00
parent d7275b08e8
commit c7aa5df81c
9 changed files with 143 additions and 52 deletions
+3 -3
View File
@@ -14,7 +14,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-v0.12.10-E8734A?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/version-v0.12.11-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/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">
@@ -256,7 +256,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
产出:`release/Metona Ollama Setup v0.12.10.exe`
产出:`release/Metona Ollama Setup v0.12.11.exe`
## 🛠️ 常用命令
@@ -507,7 +507,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
Output: `release/Metona Ollama Setup v0.12.10.exe`
Output: `release/Metona Ollama Setup v0.12.11.exe`
## 🛠️ Common Commands
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
"version": "0.12.10",
"version": "0.12.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
"version": "0.12.10",
"version": "0.12.11",
"license": "MIT",
"dependencies": {
"ffmpeg-static": "^5.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
"version": "0.12.10",
"version": "0.12.11",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js",
"author": "thzxx",
+1 -1
View File
@@ -101,7 +101,7 @@ export function createMenu(): void {
dialog.showMessageBox(mainWindow!, {
type: 'info',
title: '关于 Metona Ollama',
message: 'Metona Ollama Desktop v0.12.10',
message: 'Metona Ollama Desktop v0.12.11',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
+44 -13
View File
@@ -582,14 +582,11 @@ async function handleRetry(): Promise<void> {
onPlanReady: async (plan: string, steps: string[]) => {
try {
const { showHtmlConfirm } = await import('./prompt-modal.js');
const stepsHtml = steps.length > 0
? '<br><br><strong>📋 执行步骤:</strong><br>' + steps.map((s: string, i: number) => `&nbsp;&nbsp;${i + 1}. ${s}`).join('<br>')
: '';
const html = `<div>${safeMarkdown(plan.slice(0, 1500))}${plan.length > 1500 ? '<br>…' : ''}${stepsHtml}</div>`;
return showHtmlConfirm(html, '📋 Plan Mode — 确认执行计划', true);
const html = buildPlanConfirmHtml(plan, steps);
return showHtmlConfirm(html, '📋 执行计划确认', true);
} catch (err) {
logWarn('Plan Mode: 弹窗加载失败,自动批准', (err as Error).message);
return true; // 加载失败时自动批准,不阻断执行
return true;
}
},
onDone: async (finalContent, toolRecords, loopStats) => {
@@ -870,6 +867,43 @@ function stripComments(content: string, language: string): string {
* - 小文件直接嵌入,大文件按预算截断
* - 代码文件自动剥离注释
*/
/** 构建 Plan Mode 确认弹窗 HTML */
function buildPlanConfirmHtml(plan: string, steps: string[]): string {
const renderedPlan = safeMarkdown(plan.slice(0, 2000));
const truncated = plan.length > 2000;
const stepsHtml = steps.length > 0
? `<div style="margin-top:16px;padding-top:14px;border-top:1px solid var(--border-subtle);">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);margin-bottom:10px;text-transform:uppercase;letter-spacing:0.5px;">
📌 执行步骤(共 ${steps.length} 步)
</div>
<div style="display:flex;flex-direction:column;gap:6px;">
${steps.map((s, i) => `
<div style="display:flex;align-items:flex-start;gap:10px;padding:8px 12px;background:var(--bg-layer);border-radius:var(--radius-control);border-left:3px solid var(--accent);">
<span style="flex-shrink:0;width:24px;height:24px;display:flex;align-items:center;justify-content:center;background:var(--accent-subtle);border-radius:50%;font-size:13px;font-weight:700;color:var(--accent);">${i + 1}</span>
<span style="font-size:13px;line-height:1.6;color:var(--text-primary);padding-top:2px;">${escapeHtml(s)}</span>
</div>
`).join('')}
</div>
</div>`
: '';
return `
<div style="font-size:13px;line-height:1.7;">
<div style="margin-bottom:12px;padding:10px 14px;background:var(--accent-subtle);border-radius:var(--radius-control);border:1px solid rgba(232,115,74,0.12);">
<span style="font-size:13px;font-weight:600;color:var(--accent);">🤖 AI 执行计划</span>
<span style="font-size:11px;color:var(--text-tertiary);margin-left:8px;">批准后 AI 将按步骤依次执行,每完成一步自动标记进度</span>
</div>
<div style="padding:4px 0;color:var(--text-primary);">
${renderedPlan}
${truncated ? '<div style="margin-top:8px;font-size:11px;color:var(--text-tertiary);">…(计划内容已截断)</div>' : ''}
</div>
${stepsHtml}
</div>
`;
}
function buildFileContentParts(fileContents: Array<{ language: string; content: string }>): string[] {
const numCtx = state.get<number>(KEYS.NUM_CTX, 24576);
const fileBudget = Math.floor(numCtx * FILE_TOKEN_BUDGET_RATIO);
@@ -1056,9 +1090,9 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
logInfo('📄 文件内容已注入', `${fileParts.length} 段, 共 ${fileContents.reduce((s, f) => s + f.content.length, 0)} 字符`);
}
if (pendingImages.length === 1) {
apiParts.push(`[图片: ${pendingImages[0].name}]`);
apiParts.push(`[已上传图片: ${pendingImages[0].name} — 此图片已随消息提供,你可以直接分析,无需去磁盘查找]`);
} else if (pendingImages.length > 1) {
apiParts.push(`[${pendingImages.length} 张图片: ${pendingImages.map(img => img.name).join(', ')}]`);
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 }> }> = [];
if (hasVideos) {
@@ -1272,11 +1306,8 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
onPlanReady: async (plan: string, steps: string[]) => {
try {
const { showHtmlConfirm } = await import('./prompt-modal.js');
const stepsHtml = steps.length > 0
? '<br><br><strong>📋 执行步骤:</strong><br>' + steps.map((s, i) => `&nbsp;&nbsp;${i + 1}. ${s}`).join('<br>')
: '';
const html = `<div>${safeMarkdown(plan.slice(0, 1500))}${plan.length > 1500 ? '<br>…' : ''}${stepsHtml}</div>`;
const approved = await showHtmlConfirm(html, '📋 Plan Mode — 确认执行计划', true);
const html = buildPlanConfirmHtml(plan, steps);
const approved = await showHtmlConfirm(html, '📋 执行计划确认', true);
if (approved) {
logInfo('Plan Mode: 用户批准计划');
}
+13 -7
View File
@@ -25,11 +25,13 @@ function ensureModal(): void {
<div class="modal-header">
<h3 id="promptTitle">输入</h3>
</div>
<div class="modal-body" id="promptBody"></div>
<div class="modal-actions" style="padding:12px 20px 16px;">
<div style="flex:1;"></div>
<button class="btn btn-outline" id="promptCancel">取消</button>
<button class="btn btn-primary" id="promptOk">确定</button>
<div class="modal-body" id="promptBody" style="max-height:60vh;overflow-y:auto;"></div>
<div class="modal-actions" style="padding:12px 20px 16px;border-top:1px solid var(--border-subtle);flex-shrink:0;">
<button class="btn btn-outline" id="promptCancel" style="padding:8px 20px;font-size:13px;">取消</button>
<button class="btn btn-primary" id="promptOk" style="padding:8px 20px;font-size:13px;">确定</button>
</div>
</div>
`;
@@ -131,12 +133,16 @@ export function showHtmlConfirm(html: string, title = '确认', wide = false): P
return new Promise((resolve) => {
resolveFn = (val) => resolve(val !== null);
titleEl!.textContent = title;
bodyEl!.innerHTML = `<div style="color:var(--text-secondary);font-size:13px;line-height:1.6;">${html}</div>`;
bodyEl!.innerHTML = html;
inputEl = null;
overlayEl!.style.display = '';
// 宽屏模式:Plan Mode 确认框需要更大空间
const modalEl = overlayEl!.querySelector('.modal') as HTMLElement;
if (modalEl) modalEl.style.maxWidth = wide ? '700px' : '';
if (modalEl) {
modalEl.style.maxWidth = wide ? '640px' : '420px';
modalEl.style.maxHeight = wide ? '85vh' : '';
}
okBtn!.textContent = wide ? '✅ 批准执行' : '确定';
okBtn!.focus();
});
}
+1 -1
View File
@@ -28,7 +28,7 @@
<div class="header-left">
<span class="logo">🦙</span>
<span class="app-title">Metona Ollama</span>
<span class="app-version">v0.12.10</span>
<span class="app-version">v0.12.11</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<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"/>
+2 -1
View File
@@ -79,7 +79,8 @@ memory_search(查找相关记忆)
## 重要约束
- **严禁编造 URL**:所有 URL 必须来自搜索结果、用户提供或从已有文件中获取。
- **上传文件已在对话中**:用户上传的文件内容已在当前消息中,无需再用 read_file 读取
- **上传文件和图片已在对话中**:用户上传的文件内容已在当前消息文本中,图片已作为消息附件在 images 数组中提供。**严禁用 read_file / search_files 去磁盘查找用户已上传的图片或文件**——你直接就能"看到"它们。如果消息中包含 `[N 张图片: xxx]` 描述,说明这些图片已经上传了,直接分析即可
- **read_file 二进制模式不能用于"看"图片**read_file mode=binary 返回的是 base64 文本字符串,视觉模型无法将其作为图像处理。如果你需要分析图片内容,必须由用户通过上传功能提供。不要试图用 read_file 去"读取"图片文件。
- **永远不要只输出"我将执行xxx"然后结束**。说出计划后立即执行。
- **搜索结果的 snippet 不可信**:它只是摘要,可能不完整或过时,必须 web_fetch 获取完整内容后确认。
- **文件操作前先读**:修改文件前先用 read_file 确认当前内容,不要在不知道文件内容的情况下直接 edit_file。
+64 -11
View File
@@ -537,10 +537,34 @@ async function handleInit(
if (ctx.mode === 'plan') {
systemPromptParts.push(`[Plan Mode 执行规则]
你当前处于 Plan Mode(先规划后执行)。重要规则:
1. 第一轮回复必须是执行计划,不要直接调用工具。列出步骤、涉及工具和预期结果后,等待用户批准。
2. 计划批准后,系统会自动追踪工具执行进度——你每完成一个步骤对应的工具调用,系统会自动标记该步骤为完成。
3. 你也可以手动调用 plan_track(action='mark_done', step_index=N) 来更新进度
4. 所有步骤完成后直接给出最终回答。不需要手动调用 plan_track 来标记全部完成——系统会自动检测。`);
**输出格式(必须严格遵守)**
你的第一轮回复必须是一个清晰的执行计划,使用以下格式。不要输出其他无关内容
## 任务分析
[1-2 句话概述任务,展示你的理解]
## 执行计划
1. **步骤名称** — 工具: tool_name — 简要描述这一步做什么,完成后预期结果是什么
2. **步骤名称** — 无需工具 — 如果这一步只需推理/分析/总结,标注"无需工具"并描述分析要点
...(根据任务复杂度,通常 2-6 步)
## 预期结果
[完成所有步骤后的最终产出]
**关键规则**
- 需要调用工具的步骤:必须写"工具: xxx"(如 工具: web_search、工具: write_file
- 纯分析/推理/总结的步骤:写"无需工具",描述你要分析和思考的要点
- 禁止模糊描述如"分析需求"——即使是分析步骤,也要写清楚分析什么、关注什么
- 如果用户已上传图片或文件,第一步必须是分析附件(写"无需工具"即可,图片你直接能看到)
- 如果整个任务完全不需要工具(如纯翻译、润色、总结对话),可以全部步骤都是"无需工具"
- 输出计划后等待用户批准,**不要**在输出计划的同时调用工具
**计划批准后**
- 需要工具的步骤:调用对应工具完成任务
- 无需工具的步骤:直接基于已有信息给出分析结论
- 系统会自动追踪工具执行进度
- 所有步骤完成后直接给出最终回答。`);
// 将用户原始任务描述固化到系统提示词中(不会被压缩或清理)
const taskDesc = userContent?.slice(0, 200) || '';
@@ -589,6 +613,9 @@ Shell: ${osInfo.shell}
3. **搜索必须实际执行**:说"搜索结果显示"、"根据搜索结果"之前,必须先调用 web_search。搜索结果的 snippet 不可信,必须用 web_fetch 获取完整内容后才能引用。
4. **信息不足时如实报告**:如果工具调用失败或返回了意外的结果,必须如实报告,不能编造替代信息。
5. **完成标志**:当所有必需的工具调用已经实际执行完毕,且获得了足够的信息后,才能给出最终回答。最终回答中不要编造"文件已生成"等声明——除非你真的调用了对应工具。
6. **用户上传的图片和文件已在当前消息中**:如果用户消息中包含图片附件标记(如 [已上传 N 张图片: ...])或文件标记(如 [文件: xxx]),说明这些资源已随消息作为附件提供,你可以直接"看到"和分析它们。**严禁再用 read_file / search_files 等工具去磁盘上查找这些已上传的图片和文件**——它们不在磁盘上,就在当前消息里。不要浪费轮次去做无意义的文件搜索。
7. **read_file 不能看图片**read_file 返回的是文本或 base64 编码字符串,视觉模型无法处理。如果你需要分析图片内容,只有用户通过上传功能提供的图片才是可见的。不要尝试用 read_file mode=binary 去"读取"图片文件。
8. **禁止在 Plan 模式下为已上传的附件规划文件查找步骤**:如果用户消息中已经有图片或文件附件,执行计划的第一步应该是直接分析这些附件,而不是"搜索工作空间中的图片文件"或"查找相关文件"。
违反以上任何一条都是不可接受的错误。请逐条对照检查你的每一次回复。`);
@@ -656,7 +683,7 @@ Shell: ${osInfo.shell}
if (ctx.mode === 'plan' && ctx.loopCount === 0) {
ctx.messages.push({
role: 'user' as const,
content: `[Plan Mode] 请先分析任务,列出执行计划(包括需要的工具、步骤和预期结果),然后等待我的确认。不要直接执行工具调用`,
content: `[Plan Mode] 请按照 Plan Mode 执行规则中指定的格式输出执行计划。不要直接执行工具调用,先输出计划等待用户批准`,
ephemeral: true,
});
}
@@ -674,23 +701,49 @@ function truncateByTokenBudget(text: string, maxTokens: number): string {
return text.slice(0, cutAt) + '\n\n... (已截断以控制 Token 预算)';
}
/** 从 Plan Mode 输出中提取步骤列表 */
/** 从 Plan Mode 输出中提取步骤列表(兼容多种模型格式) */
function extractPlanSteps(content: string): string[] {
const steps: string[] = [];
// 策略1: 精确匹配 "## 执行计划" 区块中的编号行
const planSection = content.match(/##\s*执行计划\s*\n([\s\S]*?)(?=\n##|\n---|\n\*\*关键|$)/);
if (planSection) {
const lines = planSection[1].split('\n');
for (const line of lines) {
// 匹配: 1. **步骤名** — 工具: xxx — 描述
const match = line.match(/^\d+[\.\)、]\s*(?:\*\*)?(.+?)(?:\*\*)?(?:\s*[—\-]\s*)/);
if (match) {
const step = match[0].trim();
if (step.length >= 5 && step.length <= 200) {
steps.push(step);
continue;
}
}
// 回退: 匹配任何编号行
const looseMatch = line.match(/^\d+[\.\)、]\s+(.+)/);
if (looseMatch) {
const step = looseMatch[1].trim();
if (step.length >= 5 && step.length <= 200) {
steps.push(step);
}
}
}
}
// 策略2: 回退 — 全局匹配编号行(松动匹配,接受"无需工具"的步骤)
if (steps.length === 0) {
const stepRegex = /(?:^|\n)\s*(?:\d+[\.\)、]\s*|[-*]\s+)(.+)/g;
// 过滤项:阶段标题、HTML 标记、纯格式描述
const filterPatterns = [/^\*\*.*阶段.*\*\*/, /\*\*第[一二三]|\*\*Phase/, /^```/, /^<\/?/, /^Stage/i];
// 动作动词:步骤必须包含至少一个表示"要做什么"的词
const ACTION_VERBS = /搜索|查找|检索|抓取|fetch|写入|write|创建|生成|运行|执行|打开|浏览|读取|下载|提交|推送|编译|压缩|解压|截图|提取|设计|构建|编码|HTML|CSS|组织|整理|归类|搜集|汇总/;
let match;
while ((match = stepRegex.exec(content)) !== null) {
const step = match[1].trim();
if (step.length < 5 || step.length > 200) continue;
if (filterPatterns.some(p => p.test(step))) continue;
if (!ACTION_VERBS.test(step)) continue; // 纯描述无动词,跳过
steps.push(step);
}
return steps.length > 0 ? steps.slice(0, 6) : ['执行任务计划(详见上方描述)'];
}
return steps.length > 0 ? steps.slice(0, 8) : ['执行任务计划(详见上方描述)'];
}
/**