From 0f25921fb90faf6df6e6e8ea633184ff6b5ed423 Mon Sep 17 00:00:00 2001 From: Metona Fix Date: Mon, 6 Apr 2026 19:02:34 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E7=A1=AE=E8=AE=A4=E5=90=8E=E5=8D=A1=E6=AD=BB?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根本原因: 1. 模型能力检测缺少 tools 检查 — gemma4 等模型不支持 Tool Calling 但代码允许启用 2. 流式调用无超时保护 — 模型无限思考时无法自动恢复 3. AbortController 竞态 — 确认对话框后状态同步问题 修复内容: - 模型能力检测增加 tools 字段检查,模型栏显示 🔧 Tools 标签 - Agent Loop 流式调用增加 2 分钟单次超时保护 - 开启 Tool Calling 时自动检测模型兼容性并警告 - 设置面板开启 Tool Calling 时检查并提示不兼容模型 - 输入区域 Agent Loop 分支增加模型支持检测和用户提示 --- package-lock.json | 4 +- src/renderer/components/input-area.ts | 6 +- src/renderer/components/model-bar.ts | 24 ++++- src/renderer/components/settings-modal.ts | 11 +- src/renderer/index.html | 1 + src/renderer/services/agent-engine.ts | 116 ++++++++++++++-------- src/renderer/styles/style.css | 6 ++ src/renderer/types.d.ts | 7 ++ 8 files changed, 123 insertions(+), 52 deletions(-) diff --git a/package-lock.json b/package-lock.json index d011096..b91394f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "2.0.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "2.0.0", + "version": "3.0.0", "license": "MIT", "devDependencies": { "@types/node": "^20.17.0", diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index d137f73..99be6fb 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -4,7 +4,7 @@ import { state, KEYS } from '../state/state.js'; import { fileToBase64, fileToText, detectLanguage, truncate, escapeHtml, formatSize } from '../utils/utils.js'; -import { getSelectedModel, isThinkEnabled, isVisionAvailable } from './model-bar.js'; +import { getSelectedModel, isThinkEnabled, isVisionAvailable, isToolCallingSupported } from './model-bar.js'; import { renderMessages, appendAssistantPlaceholder, appendSystemMessage, updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll @@ -260,6 +260,10 @@ export async function sendMessage(): Promise { // ── Tool Calling Agent Loop 分支 ── const toolCallingEnabled = state.get('toolCallingEnabled', false); if (toolCallingEnabled) { + // 检查模型是否支持 Tool Calling + if (!isToolCallingSupported()) { + appendSystemMessage('⚠️ 当前模型未声明支持 Tool Calling,Agent Loop 可能不稳定。建议使用 Qwen3、Llama 3.1+ 或 Mistral 等支持工具调用的模型。'); + } await sendMessageWithAgentLoop(text, currentSession, model); return; } diff --git a/src/renderer/components/model-bar.ts b/src/renderer/components/model-bar.ts index 04ee151..69ba749 100644 --- a/src/renderer/components/model-bar.ts +++ b/src/renderer/components/model-bar.ts @@ -6,21 +6,23 @@ import { state, KEYS } from '../state/state.js'; import { formatSize } from '../utils/utils.js'; import { OllamaAPI } from '../api/ollama.js'; import { ChatDB } from '../db/chat-db.js'; -import type { OllamaModelDetail } from '../types.js'; +import type { OllamaModelDetail, ModelCaps } from '../types.js'; let modelSelectEl: HTMLSelectElement; let badgeThinkEl: HTMLElement; let badgeVisionEl: HTMLElement; +let badgeToolsEl: HTMLElement; let thinkCheckbox: HTMLInputElement; let thinkWrap: HTMLElement; let btnAttachImg: HTMLButtonElement; -const modelCapabilityCache = new Map(); +const modelCapabilityCache = new Map(); export function initModelBar(): void { modelSelectEl = document.querySelector('#modelSelect')!; badgeThinkEl = document.querySelector('#badgeThink')!; badgeVisionEl = document.querySelector('#badgeVision')!; + badgeToolsEl = document.querySelector('#badgeTools')!; thinkCheckbox = document.querySelector('#toggleThink')!; thinkWrap = document.querySelector('#thinkToggleWrap')!; btnAttachImg = document.querySelector('#btnAttachImg')!; @@ -38,6 +40,8 @@ export function initModelBar(): void { setVisionAvailable(false); badgeThinkEl.style.display = 'none'; badgeVisionEl.style.display = 'none'; + badgeToolsEl.style.display = 'none'; + state.set('modelSupportsTools', false); } }); } @@ -99,6 +103,7 @@ async function filterEmbedModels(models: Array<{ name: string }>): Promise modelCapabilityCache.set(m.name, { think: caps.includes('thinking'), vision: caps.includes('vision'), + tools: caps.includes('tools'), completion: isCompletion, }); if (!isCompletion) { @@ -132,23 +137,26 @@ async function checkModelCapability(modelName: string): Promise { const caps = { think: capabilities.includes('thinking'), vision: capabilities.includes('vision'), + tools: capabilities.includes('tools'), completion: capabilities.includes('completion'), }; modelCapabilityCache.set(modelName, caps); applyCapability(modelName, caps); } catch (err) { console.warn('[ModelBar] 获取模型能力失败:', err); - const fallback = { think: false, vision: false, completion: true }; + const fallback = { think: false, vision: false, tools: false, completion: true }; modelCapabilityCache.set(modelName, fallback); applyCapability(modelName, fallback); } } -function applyCapability(_modelName: string, caps: { think: boolean; vision: boolean }): void { +function applyCapability(_modelName: string, caps: { think: boolean; vision: boolean; tools: boolean }): void { setThinkAvailable(caps.think); setVisionAvailable(caps.vision); badgeThinkEl.style.display = caps.think ? '' : 'none'; badgeVisionEl.style.display = caps.vision ? '' : 'none'; + badgeToolsEl.style.display = caps.tools ? '' : 'none'; + state.set('modelSupportsTools', caps.tools); } function setThinkAvailable(available: boolean): void { @@ -193,3 +201,11 @@ export function setSelectedModel(modelName: string): void { export function isThinkEnabled(): boolean { return thinkCheckbox.checked && !thinkCheckbox.disabled; } + +/** 检查当前选中的模型是否支持 Tool Calling */ +export function isToolCallingSupported(): boolean { + const model = modelSelectEl?.value; + if (!model) return false; + const caps = modelCapabilityCache.get(model); + return caps?.tools === true; +} diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index 62af777..5cddf77 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -113,7 +113,16 @@ export function initSettingsModal(): void { const db = state.get(KEYS.DB); state.set('toolCallingEnabled', toggleToolCalling.checked); if (db) await db.saveSetting('toolCallingEnabled', toggleToolCalling.checked); - showToast(toggleToolCalling.checked ? '工具调用已开启' : '工具调用已关闭', 'info'); + if (toggleToolCalling.checked) { + const modelSupportsTools = state.get('modelSupportsTools', false); + if (!modelSupportsTools) { + showToast('⚠️ 当前模型可能不支持 Tool Calling,建议使用 Qwen3、Llama 3.1+ 等模型', 'warning', 5000); + } else { + showToast('工具调用已开启', 'info'); + } + } else { + showToast('工具调用已关闭', 'info'); + } }); } diff --git a/src/renderer/index.html b/src/renderer/index.html index 1a1c8b9..bda42f3 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -74,6 +74,7 @@
+
diff --git a/src/renderer/services/agent-engine.ts b/src/renderer/services/agent-engine.ts index a36f2a6..9bf80b0 100644 --- a/src/renderer/services/agent-engine.ts +++ b/src/renderer/services/agent-engine.ts @@ -20,6 +20,11 @@ import type { ToolCallRecord } from '../types.js'; +const MAX_LOOPS = 10; +const MAX_LOOP_TIME = 300000; // 5 分钟总超时 +const TOOL_EXEC_TIMEOUT = 30000; // 工具执行超时 30 秒 +const STREAM_TIMEOUT = 120000; // 单次流式调用超时 2 分钟 + export interface AgentCallbacks { onThinking: (text: string) => void; onContent: (text: string) => void; @@ -44,6 +49,12 @@ export async function runAgentLoop( return; } + // 检查模型是否支持 Tool Calling + const modelSupportsTools = state.get('modelSupportsTools', false); + if (!modelSupportsTools) { + console.warn(`[AgentEngine] 模型 ${model} 未声明 tools 能力,Agent Loop 可能不稳定`); + } + const messages: OllamaMessage[] = []; let systemPromptParts: string[] = []; @@ -84,12 +95,11 @@ export async function runAgentLoop( const useTools = tools.length > 0; let loopCount = 0; - const maxLoops = 10; const allToolRecords: ToolCallRecord[] = []; const loopStartTime = Date.now(); - const MAX_LOOP_TIME = 300000; // 5分钟总超时 + let content = ''; - while (loopCount < maxLoops) { + while (loopCount < MAX_LOOPS) { loopCount++; // 全局超时检查 @@ -106,55 +116,61 @@ export async function runAgentLoop( } let thinking = ''; - let content = ''; + content = ''; const toolCalls: ToolCall[] = []; const abortController = new AbortController(); state.set(KEYS.ABORT_CONTROLLER, abortController); try { - await api.chatStream( - { - model, - messages, - stream: true, - think: state.get('thinkEnabled', false), - options: { - num_ctx: state.get(KEYS.NUM_CTX, 24576), - temperature: state.get('temperature', 0.7) + // 带超时保护的流式调用 + await Promise.race([ + api.chatStream( + { + model, + messages, + stream: true, + think: state.get('thinkEnabled', false), + options: { + num_ctx: state.get(KEYS.NUM_CTX, 24576), + temperature: state.get('temperature', 0.7) + }, + ...(useTools && { tools }) }, - ...(useTools && { tools }) - }, - (chunk: OllamaStreamChunk) => { - if (chunk.message?.thinking) { - thinking += chunk.message.thinking; - callbacks.onThinking(thinking); - } - if (chunk.message?.content) { - content += chunk.message.content; - callbacks.onContent(content); - } - if (chunk.message?.tool_calls?.length) { - for (const tc of chunk.message.tool_calls) { - if (tc.function?.name) { - toolCalls.push({ - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments || {} + (chunk: OllamaStreamChunk) => { + if (chunk.message?.thinking) { + thinking += chunk.message.thinking; + callbacks.onThinking(thinking); + } + if (chunk.message?.content) { + content += chunk.message.content; + callbacks.onContent(content); + } + if (chunk.message?.tool_calls?.length) { + for (const tc of chunk.message.tool_calls) { + if (tc.function?.name) { + toolCalls.push({ + type: 'function', + function: { + name: tc.function.name, + arguments: tc.function.arguments || {} + } + }); + } else if (toolCalls.length > 0) { + const last = toolCalls[toolCalls.length - 1]; + if (tc.function?.arguments && typeof tc.function.arguments === 'object') { + Object.assign(last.function.arguments, tc.function.arguments); } - }); - } else if (toolCalls.length > 0) { - const last = toolCalls[toolCalls.length - 1]; - if (tc.function?.arguments && typeof tc.function.arguments === 'object') { - Object.assign(last.function.arguments, tc.function.arguments); } } } - } - }, - abortController - ); + }, + abortController + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error('模型响应超时(2分钟无数据)')), STREAM_TIMEOUT) + ) + ]); } catch (err) { if (abortController.signal.aborted) { if (content || thinking) { @@ -167,6 +183,19 @@ export async function runAgentLoop( callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined); return; } + // 超时错误 — 保留已生成的内容 + if ((err as Error).message.includes('超时')) { + console.warn('[AgentEngine] 流式调用超时:', (err as Error).message); + if (content || thinking) { + messages.push({ + role: 'assistant', + content: content || '(模型响应超时)', + ...(thinking && { thinking }) + }); + } + callbacks.onDone(content || '(模型响应超时,已自动中断)', allToolRecords.length > 0 ? allToolRecords : undefined); + return; + } throw err; } @@ -222,12 +251,11 @@ export async function runAgentLoop( } try { - // 工具执行超时保护(30秒) - const TOOL_TIMEOUT = 30000; + // 工具执行超时保护 const result = await Promise.race([ executeTool(call.function.name, call.function.arguments), new Promise((_, reject) => - setTimeout(() => reject(new Error('工具执行超时(30秒)')), TOOL_TIMEOUT) + setTimeout(() => reject(new Error('工具执行超时(30秒)')), TOOL_EXEC_TIMEOUT) ) ]); const record: ToolCallRecord = { diff --git a/src/renderer/styles/style.css b/src/renderer/styles/style.css index d70e2bf..9a5c991 100644 --- a/src/renderer/styles/style.css +++ b/src/renderer/styles/style.css @@ -377,6 +377,12 @@ html, body { border-color: rgba(179, 136, 255, 0.15); } +.tools-badge { + color: #FFB74D; + background: rgba(255, 183, 77, 0.06); + border-color: rgba(255, 183, 77, 0.15); +} + /* ── 温度滑块 ── */ .temp-slider { width: 100%; diff --git a/src/renderer/types.d.ts b/src/renderer/types.d.ts index 5be5790..1e1536f 100644 --- a/src/renderer/types.d.ts +++ b/src/renderer/types.d.ts @@ -51,6 +51,13 @@ export interface OllamaModelDetail { [key: string]: unknown; } +export interface ModelCaps { + think: boolean; + vision: boolean; + tools: boolean; + completion: boolean; +} + export interface OllamaVersionResponse { version: string; }