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:
@@ -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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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) => ` ${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) => ` ${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: 用户批准计划');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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) : ['执行任务计划(详见上方描述)'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user