v0.10.1: 修复显存泄漏 + 删除普通聊天模式 + Tool Calling 始终开启

fix: ollama.ts - chat() 添加默认 keep_alive: '1h',chatStream done 时不再 reader.cancel()
refactor: 删除普通聊天模式,所有消息统一走 Agent Loop
refactor: Tool Calling 开关始终开启且不可关闭
chore: 版本号 0.10.0 → 0.10.1 (12 处)
docs: 更新帮助面板和 README 反映架构变更
This commit is contained in:
thzxx
2026-06-06 12:06:48 +08:00
parent c9adc764ae
commit 64264cc0f8
13 changed files with 47 additions and 379 deletions
+10 -332
View File
@@ -327,22 +327,19 @@ async function handleRetry(): Promise<void> {
clearMessagesDOM();
renderMessages();
// 重新触发 Agent Loop / 普通聊天
const toolCallingEnabled = state.get<boolean>('toolCallingEnabled', false);
// 重新触发 Agent Loop
const model = getSelectedModel() || currentSession.model;
// 新一轮开始:清理上一轮的工作空间状态
if (toolCallingEnabled) {
clearToolCardsExternal();
clearTerminalExternal();
}
clearToolCardsExternal();
clearTerminalExternal();
appendAssistantPlaceholder();
state.set(KEYS.IS_STREAMING, true);
updateSendButton(true);
if (toolCallingEnabled) {
const historyMessages = currentSession.messages
// 始终走 Agent Loop
const historyMessages = currentSession.messages
.filter(m => m.role === 'user' || m.role === 'assistant')
.slice(0, -1) // 不包含刚保留的最后一条 user 消息
.slice(-20)
@@ -441,16 +438,8 @@ async function handleRetry(): Promise<void> {
}
});
} catch (err) {
logError('重试失败', (err as Error).message);
appendSystemMessage(`❌ 重试失败: ${(err as Error).message}`);
}
} else {
// 普通聊天模式重试 - 直接调用 sendMessage
chatInputEl.value = userMsg.content || '';
state.set(KEYS.IS_STREAMING, false);
updateSendButton(false);
await sendMessage();
return;
logError('重试失败', (err as Error).message);
appendSystemMessage(`❌ 重试失败: ${(err as Error).message}`);
}
state.set(KEYS.IS_STREAMING, false);
@@ -770,320 +759,9 @@ export async function sendMessage(): Promise<void> {
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
if (!currentSession) return;
// ── Tool Calling Agent Loop 分支 ──
const toolCallingEnabled = state.get<boolean>('toolCallingEnabled', false);
if (toolCallingEnabled) {
// 检查模型是否支持 Tool Calling
if (!isToolCallingSupported()) {
appendSystemMessage('⚠️ 当前模型未声明支持 Tool CallingAgent Loop 可能不稳定。建议使用 Qwen3、Llama 3.1+ 或 Mistral 等支持工具调用的模型。');
}
await sendMessageWithAgentLoop(text, currentSession, model);
return;
}
// ── 原有普通聊天流程 ──
const now = Date.now();
const msgsToAdd: ChatMessage[] = [];
const userFiles: ChatFile[] = pendingFiles.map(f => ({ name: f.name, language: f.language, size: f.size }));
if (pendingImages.length > 0) {
msgsToAdd.push({
role: 'user',
content: pendingImages.length === 1 ? `[上传了图片: ${pendingImages[0].name}]` : `[上传了 ${pendingImages.length} 张图片]`,
images: pendingImages.map(img => img.base64),
timestamp: now
});
}
if (text || pendingFiles.length > 0) {
const msg: ChatMessage = {
role: 'user',
content: text || '',
timestamp: now
};
if (userFiles.length > 0) {
msg.files = userFiles;
msg._fileContents = pendingFiles.map(f => ({ language: f.language, content: f.content }));
}
msgsToAdd.push(msg);
}
const isFirstMsg = currentSession.messages.length === 0;
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
...(isFirstMsg && {
title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30),
model
}),
messages: [...session.messages, ...msgsToAdd],
updatedAt: Date.now()
}));
const freshSession = state.get<ChatSession>(KEYS.CURRENT_SESSION);
enableAutoScroll();
renderMessages();
await saveCurrentSession();
chatInputEl.value = '';
pendingImages = [];
pendingFiles = [];
imagePreviewEl.style.display = 'none';
imagePreviewEl.innerHTML = '';
filePreviewEl.style.display = 'none';
filePreviewEl.innerHTML = '';
autoResizeTextarea();
appendAssistantPlaceholder();
state.set(KEYS.IS_STREAMING, true);
updateSendButton(true);
const api = state.get<OllamaAPI>(KEYS.API)!;
const abortController = new AbortController();
state.set(KEYS.ABORT_CONTROLLER, abortController);
const thinkMode = isThinkEnabled();
const numCtx = state.get<number>(KEYS.NUM_CTX, 24576);
let assistantContent = '';
let thinkContent = '';
let finalStats: any = null;
let modelName = '';
try {
const apiMessages = buildApiMessages(freshSession.messages);
const temperature = state.get<number>('temperature', 0.7);
const chatParams: Record<string, unknown> = {
model,
messages: apiMessages,
stream: true,
think: thinkMode,
};
if (numCtx) chatParams.options = { num_ctx: numCtx, temperature };
// ── 扫描工作空间 SOUL.md ──
let soulContent = '';
let soulMdContent = '';
const workspaceDir = getWorkspaceDirPath();
if (workspaceDir) {
try {
const soulResult = await window.metonaDesktop?.workspace.readFile(
workspaceDir.replace(/\/+$/, '') + '/SOUL.md'
);
if (soulResult?.success && soulResult.content) {
soulMdContent = soulResult.content;
}
} catch { /* 工作空间 SOUL.md 不存在 */ }
}
// fallback:读取内置 SOUL.md
if (!soulMdContent) {
try {
const resp = await fetch('./SOUL.md');
if (resp.ok) {
soulMdContent = await resp.text();
}
} catch { /* 内置 SOUL.md 也不可用 */ }
}
if (soulMdContent) {
soulContent = `[SOUL.md] ${soulMdContent}`;
}
// ── 扫描工作空间 USER.md ──
let userMdContent = '';
if (workspaceDir) {
try {
const userResult = await window.metonaDesktop?.workspace.readFile(
workspaceDir.replace(/\/+$/, '') + '/USER.md'
);
if (userResult?.success && userResult.content) {
userMdContent = userResult.content;
}
} catch { /* 工作空间 USER.md 不存在 */ }
}
if (!userMdContent) {
try {
const resp = await fetch('./USER.md');
if (resp.ok) {
userMdContent = await resp.text();
}
} catch { /* 内置 USER.md 也不可用 */ }
}
// ── Agent 记忆注入 ──
if (isMemoryEnabled()) {
const userMessage = text || (msgsToAdd.find(m => m.role === 'user')?.content || '');
if (userMessage) {
const relevantMemories = searchMemories(userMessage, 6);
if (relevantMemories.length > 0) {
const memoryContext = buildMemoryContext(relevantMemories);
const parts = [soulContent, userMdContent ? `[USER.md] ${userMdContent}` : '', memoryContext].filter(Boolean);
chatParams.system = parts.join('\n\n');
for (const m of relevantMemories) {
await markMemoryUsed(m.id);
}
appendSystemMessage(`🧠 已注入 ${relevantMemories.length} 条相关记忆`, 'memory-status');
}
}
}
if (soulContent && !chatParams.system) {
const parts = [soulContent, userMdContent ? `[USER.md] ${userMdContent}` : ''].filter(Boolean);
chatParams.system = parts.join('\n\n');
}
// 存入 state 供 chat-area 渲染系统提示词卡片
state.set('_lastSystemPrompt', chatParams.system || '');
await api.chatStream(chatParams as any, (chunk: OllamaStreamChunk) => {
if (chunk.message) {
if (chunk.message.content) assistantContent += chunk.message.content;
const think = chunk.message.thinking || chunk.message.reasoning_content;
if (think && typeof think === 'string') thinkContent += think;
}
if (chunk.model) modelName = chunk.model;
// 捕获任何包含统计的 chunk(不限于 done)
if (chunk.eval_count || chunk.prompt_eval_count || chunk.total_duration) {
finalStats = {
eval_count: chunk.eval_count ?? finalStats?.eval_count,
prompt_eval_count: chunk.prompt_eval_count ?? finalStats?.prompt_eval_count,
total_duration: chunk.total_duration ?? finalStats?.total_duration,
};
}
updateLastAssistantMessage(assistantContent, thinkContent || null, null, modelName || undefined);
if (chunk.done) {
// done chunk 优先使用其统计,保留之前的兜底
finalStats = {
eval_count: chunk.eval_count ?? finalStats?.eval_count,
prompt_eval_count: chunk.prompt_eval_count ?? finalStats?.prompt_eval_count,
total_duration: chunk.total_duration ?? finalStats?.total_duration,
};
}
}, abortController);
if (!assistantContent && !thinkContent) {
appendSystemMessage('⚠️ 模型未返回任何内容,可能是上下文过长或模型不支持当前请求');
logWarn('模型未返回任何内容');
}
logStream(`流式完成: ${assistantContent.length} 字符${finalStats?.eval_count ? `, ${finalStats.eval_count} tokens` : ''}`);
const assistantMsg: ChatMessage = {
role: 'assistant',
content: assistantContent || '',
timestamp: Date.now(),
...(modelName && { model: modelName }),
...(thinkContent && { think: thinkContent }),
...((finalStats || {}) as any)
};
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, assistantMsg],
updatedAt: Date.now()
}));
if (finalStats) {
updateLastAssistantMessage(assistantContent, thinkContent || null, finalStats, modelName || undefined, Date.now());
} else {
updateLastAssistantMessage(assistantContent, thinkContent || null, null, modelName || undefined, Date.now());
}
await saveCurrentSession();
// ── 自动提取记忆(非阻塞)──
if (isMemoryEnabled() && freshSession.messages.length >= 10) {
const currentSession2 = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
if (currentSession2) {
extractMemoriesFromConversation(
currentSession2.messages.map(m => ({ role: m.role, content: m.content })),
currentSession2.title
).then(count => {
if (count > 0) {
logInfo(`自动提取了 ${count} 条记忆`);
}
}).catch(err => logError('记忆提取失败', (err as Error).message));
}
}
} catch (err) {
if ((err as Error).name === 'AbortError') {
const placeholder = document.querySelector('#messagesContainer .message.assistant.loading') as HTMLElement;
const loadingDots = placeholder?.querySelector('.loading-dots');
const loadingText = placeholder?.querySelector('.loading-text');
if (loadingDots) loadingDots.remove();
if (loadingText) loadingText.remove();
placeholder?.classList.remove('loading');
const contentDiv = placeholder?.querySelector('.msg-content');
const partialMsg: ChatMessage = {
role: 'assistant',
content: assistantContent,
timestamp: Date.now(),
...(modelName && { model: modelName }),
...(thinkContent && { think: thinkContent }),
stopped: true
};
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, partialMsg],
updatedAt: Date.now()
}));
if (contentDiv) {
let html = assistantContent ? safeMarkdown(assistantContent) : '';
html += '<p><code>[已停止]</code></p>';
contentDiv.innerHTML = html;
}
appendSystemMessage('⏹ 已停止生成');
await saveCurrentSession();
state.set(KEYS.IS_STREAMING, false);
updateSendButton(false);
state.set(KEYS.ABORT_CONTROLLER, null);
return;
}
const placeholder = document.querySelector('#messagesContainer .message.assistant.loading') as HTMLElement;
const loadingDots = placeholder?.querySelector('.loading-dots');
const loadingText = placeholder?.querySelector('.loading-text');
if (loadingDots) loadingDots.remove();
if (loadingText) loadingText.remove();
placeholder?.classList.remove('loading');
const contentDiv = placeholder?.querySelector('.msg-content');
if (assistantContent && contentDiv) {
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, { role: 'assistant', content: assistantContent + '\n\n`[已中断]`', timestamp: Date.now() } as ChatMessage],
updatedAt: Date.now()
}));
contentDiv.innerHTML = safeMarkdown(assistantContent) + '<p><code>[已中断]</code></p>';
await saveCurrentSession();
} else {
if (placeholder) placeholder.remove();
}
let errMsg = `❌ 错误: ${(err as Error).message}`;
if ((err as Error).message.includes('Failed to fetch') || (err as Error).message.includes('NetworkError')) {
errMsg = '❌ 连接失败。请检查:\n1. Ollama 是否正在运行\n2. 地址是否正确\n3. 是否设置了 OLLAMA_ORIGINS="*"';
logError('连接失败', 'Ollama 服务不可达');
} else if ((err as Error).message.includes('400')) {
errMsg = '❌ 请求参数错误,可能是上下文过长';
logError('请求参数错误 (400)', (err as Error).message);
} else if ((err as Error).message.includes('500')) {
errMsg = '❌ Ollama 服务器错误,请检查 Ollama 日志';
logError('Ollama 服务器错误 (500)', (err as Error).message);
} else {
logError('对话错误', (err as Error).message);
}
appendSystemMessage(errMsg);
} finally {
state.set(KEYS.IS_STREAMING, false);
updateSendButton(false);
state.set(KEYS.ABORT_CONTROLLER, null);
}
// 所有消息统一走 Agent Loop
await sendMessageWithAgentLoop(text, currentSession, model);
return;
}
/** 更新当前会话消息中最近一个 'running' 状态的同名工具记录 */
+12 -23
View File
@@ -1,6 +1,6 @@
/**
* ToolsModal - 工具面板模态框
* 展示所有可用工具的详细信息,管理 Tool Calling 开关
* 展示所有可用工具的详细信息Tool Calling 始终开启,不可关闭。
*/
import { state, KEYS } from '../state/state.js';
@@ -21,27 +21,14 @@ export function initToolsModal(): void {
if (e.target === modalEl) closeToolsModal();
});
// Tool Calling 主开关
// Tool Calling 始终开启,不可关闭
const toggleToolCalling = modalEl.querySelector('#toggleToolCalling') as HTMLInputElement;
if (toggleToolCalling) {
toggleToolCalling.addEventListener('change', async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
state.set('toolCallingEnabled', toggleToolCalling.checked);
if (db) await db.saveSetting('toolCallingEnabled', toggleToolCalling.checked);
if (toggleToolCalling.checked) {
const modelSupportsTools = state.get<boolean>('modelSupportsTools', false);
if (!modelSupportsTools) {
showToast('⚠️ 当前模型可能不支持 Tool Calling,建议使用 Qwen3、Llama 3.1+ 等模型', 'warning', 5000);
logWarn('Tool Calling 已开启(模型不支持)');
} else {
showToast('工具调用已开启', 'info');
logInfo('Tool Calling 已开启');
}
} else {
showToast('工具调用已关闭', 'info');
logInfo('Tool Calling 已关闭');
}
});
toggleToolCalling.checked = true;
toggleToolCalling.disabled = true;
state.set('toolCallingEnabled', true);
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) db.saveSetting('toolCallingEnabled', true);
}
// run_command 模式下拉框
@@ -76,10 +63,12 @@ function updateRunCommandBadge(mode: string): void {
}
export function openToolsModal(): void {
// 同步开关状态
const toolCallingEnabled = state.get<boolean>('toolCallingEnabled', false);
// 同步开关状态 — 始终开启
const toggleTC = modalEl.querySelector('#toggleToolCalling') as HTMLInputElement;
if (toggleTC) toggleTC.checked = toolCallingEnabled;
if (toggleTC) {
toggleTC.checked = true;
toggleTC.disabled = true;
}
const runCommandMode = state.get<string>('runCommandMode', 'confirm');
const selectMode = modalEl.querySelector('#selectRunCommandMode') as HTMLSelectElement;