fix: 修复工具调用确认后卡死问题

根本原因:
1. 模型能力检测缺少 tools 检查 — gemma4 等模型不支持 Tool Calling 但代码允许启用
2. 流式调用无超时保护 — 模型无限思考时无法自动恢复
3. AbortController 竞态 — 确认对话框后状态同步问题

修复内容:
- 模型能力检测增加 tools 字段检查,模型栏显示 🔧 Tools 标签
- Agent Loop 流式调用增加 2 分钟单次超时保护
- 开启 Tool Calling 时自动检测模型兼容性并警告
- 设置面板开启 Tool Calling 时检查并提示不兼容模型
- 输入区域 Agent Loop 分支增加模型支持检测和用户提示
This commit is contained in:
Metona Fix
2026-04-06 19:02:34 +08:00
parent 43c827e9ff
commit 0f25921fb9
8 changed files with 123 additions and 52 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "2.0.0", "version": "3.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "2.0.0", "version": "3.0.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^20.17.0", "@types/node": "^20.17.0",
+5 -1
View File
@@ -4,7 +4,7 @@
import { state, KEYS } from '../state/state.js'; import { state, KEYS } from '../state/state.js';
import { fileToBase64, fileToText, detectLanguage, truncate, escapeHtml, formatSize } from '../utils/utils.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 { import {
renderMessages, appendAssistantPlaceholder, appendSystemMessage, renderMessages, appendAssistantPlaceholder, appendSystemMessage,
updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll
@@ -260,6 +260,10 @@ export async function sendMessage(): Promise<void> {
// ── Tool Calling Agent Loop 分支 ── // ── Tool Calling Agent Loop 分支 ──
const toolCallingEnabled = state.get<boolean>('toolCallingEnabled', false); const toolCallingEnabled = state.get<boolean>('toolCallingEnabled', false);
if (toolCallingEnabled) { if (toolCallingEnabled) {
// 检查模型是否支持 Tool Calling
if (!isToolCallingSupported()) {
appendSystemMessage('⚠️ 当前模型未声明支持 Tool CallingAgent Loop 可能不稳定。建议使用 Qwen3、Llama 3.1+ 或 Mistral 等支持工具调用的模型。');
}
await sendMessageWithAgentLoop(text, currentSession, model); await sendMessageWithAgentLoop(text, currentSession, model);
return; return;
} }
+20 -4
View File
@@ -6,21 +6,23 @@ import { state, KEYS } from '../state/state.js';
import { formatSize } from '../utils/utils.js'; import { formatSize } from '../utils/utils.js';
import { OllamaAPI } from '../api/ollama.js'; import { OllamaAPI } from '../api/ollama.js';
import { ChatDB } from '../db/chat-db.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 modelSelectEl: HTMLSelectElement;
let badgeThinkEl: HTMLElement; let badgeThinkEl: HTMLElement;
let badgeVisionEl: HTMLElement; let badgeVisionEl: HTMLElement;
let badgeToolsEl: HTMLElement;
let thinkCheckbox: HTMLInputElement; let thinkCheckbox: HTMLInputElement;
let thinkWrap: HTMLElement; let thinkWrap: HTMLElement;
let btnAttachImg: HTMLButtonElement; let btnAttachImg: HTMLButtonElement;
const modelCapabilityCache = new Map<string, { think: boolean; vision: boolean; completion: boolean }>(); const modelCapabilityCache = new Map<string, ModelCaps>();
export function initModelBar(): void { export function initModelBar(): void {
modelSelectEl = document.querySelector('#modelSelect')!; modelSelectEl = document.querySelector('#modelSelect')!;
badgeThinkEl = document.querySelector('#badgeThink')!; badgeThinkEl = document.querySelector('#badgeThink')!;
badgeVisionEl = document.querySelector('#badgeVision')!; badgeVisionEl = document.querySelector('#badgeVision')!;
badgeToolsEl = document.querySelector('#badgeTools')!;
thinkCheckbox = document.querySelector('#toggleThink')!; thinkCheckbox = document.querySelector('#toggleThink')!;
thinkWrap = document.querySelector('#thinkToggleWrap')!; thinkWrap = document.querySelector('#thinkToggleWrap')!;
btnAttachImg = document.querySelector('#btnAttachImg')!; btnAttachImg = document.querySelector('#btnAttachImg')!;
@@ -38,6 +40,8 @@ export function initModelBar(): void {
setVisionAvailable(false); setVisionAvailable(false);
badgeThinkEl.style.display = 'none'; badgeThinkEl.style.display = 'none';
badgeVisionEl.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<void>
modelCapabilityCache.set(m.name, { modelCapabilityCache.set(m.name, {
think: caps.includes('thinking'), think: caps.includes('thinking'),
vision: caps.includes('vision'), vision: caps.includes('vision'),
tools: caps.includes('tools'),
completion: isCompletion, completion: isCompletion,
}); });
if (!isCompletion) { if (!isCompletion) {
@@ -132,23 +137,26 @@ async function checkModelCapability(modelName: string): Promise<void> {
const caps = { const caps = {
think: capabilities.includes('thinking'), think: capabilities.includes('thinking'),
vision: capabilities.includes('vision'), vision: capabilities.includes('vision'),
tools: capabilities.includes('tools'),
completion: capabilities.includes('completion'), completion: capabilities.includes('completion'),
}; };
modelCapabilityCache.set(modelName, caps); modelCapabilityCache.set(modelName, caps);
applyCapability(modelName, caps); applyCapability(modelName, caps);
} catch (err) { } catch (err) {
console.warn('[ModelBar] 获取模型能力失败:', 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); modelCapabilityCache.set(modelName, fallback);
applyCapability(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); setThinkAvailable(caps.think);
setVisionAvailable(caps.vision); setVisionAvailable(caps.vision);
badgeThinkEl.style.display = caps.think ? '' : 'none'; badgeThinkEl.style.display = caps.think ? '' : 'none';
badgeVisionEl.style.display = caps.vision ? '' : 'none'; badgeVisionEl.style.display = caps.vision ? '' : 'none';
badgeToolsEl.style.display = caps.tools ? '' : 'none';
state.set('modelSupportsTools', caps.tools);
} }
function setThinkAvailable(available: boolean): void { function setThinkAvailable(available: boolean): void {
@@ -193,3 +201,11 @@ export function setSelectedModel(modelName: string): void {
export function isThinkEnabled(): boolean { export function isThinkEnabled(): boolean {
return thinkCheckbox.checked && !thinkCheckbox.disabled; 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;
}
+10 -1
View File
@@ -113,7 +113,16 @@ export function initSettingsModal(): void {
const db = state.get<ChatDB | null>(KEYS.DB); const db = state.get<ChatDB | null>(KEYS.DB);
state.set('toolCallingEnabled', toggleToolCalling.checked); state.set('toolCallingEnabled', toggleToolCalling.checked);
if (db) await db.saveSetting('toolCallingEnabled', toggleToolCalling.checked); if (db) await db.saveSetting('toolCallingEnabled', toggleToolCalling.checked);
showToast(toggleToolCalling.checked ? '工具调用已开启' : '工具调用已关闭', 'info'); if (toggleToolCalling.checked) {
const modelSupportsTools = state.get<boolean>('modelSupportsTools', false);
if (!modelSupportsTools) {
showToast('⚠️ 当前模型可能不支持 Tool Calling,建议使用 Qwen3、Llama 3.1+ 等模型', 'warning', 5000);
} else {
showToast('工具调用已开启', 'info');
}
} else {
showToast('工具调用已关闭', 'info');
}
}); });
} }
+1
View File
@@ -74,6 +74,7 @@
<div class="model-badges" id="modelBadges"> <div class="model-badges" id="modelBadges">
<span class="model-badge think-badge" id="badgeThink" style="display:none;">🧠 Think</span> <span class="model-badge think-badge" id="badgeThink" style="display:none;">🧠 Think</span>
<span class="model-badge vision-badge" id="badgeVision" style="display:none;">👁️ Vision</span> <span class="model-badge vision-badge" id="badgeVision" style="display:none;">👁️ Vision</span>
<span class="model-badge tools-badge" id="badgeTools" style="display:none;">🔧 Tools</span>
<span class="model-badge rag-badge" id="badgeRAG" style="display:none;">📚 RAG</span> <span class="model-badge rag-badge" id="badgeRAG" style="display:none;">📚 RAG</span>
</div> </div>
</div> </div>
+72 -44
View File
@@ -20,6 +20,11 @@ import type {
ToolCallRecord ToolCallRecord
} from '../types.js'; } 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 { export interface AgentCallbacks {
onThinking: (text: string) => void; onThinking: (text: string) => void;
onContent: (text: string) => void; onContent: (text: string) => void;
@@ -44,6 +49,12 @@ export async function runAgentLoop(
return; return;
} }
// 检查模型是否支持 Tool Calling
const modelSupportsTools = state.get<boolean>('modelSupportsTools', false);
if (!modelSupportsTools) {
console.warn(`[AgentEngine] 模型 ${model} 未声明 tools 能力,Agent Loop 可能不稳定`);
}
const messages: OllamaMessage[] = []; const messages: OllamaMessage[] = [];
let systemPromptParts: string[] = []; let systemPromptParts: string[] = [];
@@ -84,12 +95,11 @@ export async function runAgentLoop(
const useTools = tools.length > 0; const useTools = tools.length > 0;
let loopCount = 0; let loopCount = 0;
const maxLoops = 10;
const allToolRecords: ToolCallRecord[] = []; const allToolRecords: ToolCallRecord[] = [];
const loopStartTime = Date.now(); const loopStartTime = Date.now();
const MAX_LOOP_TIME = 300000; // 5分钟总超时 let content = '';
while (loopCount < maxLoops) { while (loopCount < MAX_LOOPS) {
loopCount++; loopCount++;
// 全局超时检查 // 全局超时检查
@@ -106,55 +116,61 @@ export async function runAgentLoop(
} }
let thinking = ''; let thinking = '';
let content = ''; content = '';
const toolCalls: ToolCall[] = []; const toolCalls: ToolCall[] = [];
const abortController = new AbortController(); const abortController = new AbortController();
state.set(KEYS.ABORT_CONTROLLER, abortController); state.set(KEYS.ABORT_CONTROLLER, abortController);
try { try {
await api.chatStream( // 带超时保护的流式调用
{ await Promise.race([
model, api.chatStream(
messages, {
stream: true, model,
think: state.get<boolean>('thinkEnabled', false), messages,
options: { stream: true,
num_ctx: state.get<number>(KEYS.NUM_CTX, 24576), think: state.get<boolean>('thinkEnabled', false),
temperature: state.get<number>('temperature', 0.7) options: {
num_ctx: state.get<number>(KEYS.NUM_CTX, 24576),
temperature: state.get<number>('temperature', 0.7)
},
...(useTools && { tools })
}, },
...(useTools && { tools }) (chunk: OllamaStreamChunk) => {
}, if (chunk.message?.thinking) {
(chunk: OllamaStreamChunk) => { thinking += chunk.message.thinking;
if (chunk.message?.thinking) { callbacks.onThinking(thinking);
thinking += chunk.message.thinking; }
callbacks.onThinking(thinking); if (chunk.message?.content) {
} content += chunk.message.content;
if (chunk.message?.content) { callbacks.onContent(content);
content += chunk.message.content; }
callbacks.onContent(content); if (chunk.message?.tool_calls?.length) {
} for (const tc of chunk.message.tool_calls) {
if (chunk.message?.tool_calls?.length) { if (tc.function?.name) {
for (const tc of chunk.message.tool_calls) { toolCalls.push({
if (tc.function?.name) { type: 'function',
toolCalls.push({ function: {
type: 'function', name: tc.function.name,
function: { arguments: tc.function.arguments || {}
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<never>((_, reject) =>
setTimeout(() => reject(new Error('模型响应超时(2分钟无数据)')), STREAM_TIMEOUT)
)
]);
} catch (err) { } catch (err) {
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
if (content || thinking) { if (content || thinking) {
@@ -167,6 +183,19 @@ export async function runAgentLoop(
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined); callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined);
return; 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; throw err;
} }
@@ -222,12 +251,11 @@ export async function runAgentLoop(
} }
try { try {
// 工具执行超时保护30秒) // 工具执行超时保护
const TOOL_TIMEOUT = 30000;
const result = await Promise.race([ const result = await Promise.race([
executeTool(call.function.name, call.function.arguments), executeTool(call.function.name, call.function.arguments),
new Promise<never>((_, reject) => new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('工具执行超时(30秒)')), TOOL_TIMEOUT) setTimeout(() => reject(new Error('工具执行超时(30秒)')), TOOL_EXEC_TIMEOUT)
) )
]); ]);
const record: ToolCallRecord = { const record: ToolCallRecord = {
+6
View File
@@ -377,6 +377,12 @@ html, body {
border-color: rgba(179, 136, 255, 0.15); 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 { .temp-slider {
width: 100%; width: 100%;
+7
View File
@@ -51,6 +51,13 @@ export interface OllamaModelDetail {
[key: string]: unknown; [key: string]: unknown;
} }
export interface ModelCaps {
think: boolean;
vision: boolean;
tools: boolean;
completion: boolean;
}
export interface OllamaVersionResponse { export interface OllamaVersionResponse {
version: string; version: string;
} }