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:
Generated
+2
-2
@@ -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",
|
||||||
|
|||||||
@@ -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 Calling,Agent Loop 可能不稳定。建议使用 Qwen3、Llama 3.1+ 或 Mistral 等支持工具调用的模型。');
|
||||||
|
}
|
||||||
await sendMessageWithAgentLoop(text, currentSession, model);
|
await sendMessageWithAgentLoop(text, currentSession, model);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
Vendored
+7
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user