diff --git a/README.md b/README.md index dc84d25..0991fce 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript license @@ -34,7 +34,7 @@ | | 功能 | 说明 | |:---:|:---|:---| -| 🤖 | **ReAct Agent Loop** | Thought → Action → Observation → Reflection 完整循环,最大 85 轮(可配置),自动重试 2 次,工具去重,并行/链式执行 | +| 🤖 | **ReAct Agent Loop** | 始终开启的唯一对话模式。Thought → Action → Observation → Reflection 完整循环,最大 85 轮(可配置),自动重试 2 次,工具去重,并行/链式执行 | | 🔧 | **38 个内置工具** | 文件系统 · 命令执行 · 联网搜索 · 浏览器控制 · Git · 记忆 · 技能 · 会话 · 子代理 | | 🧠 | **智能记忆系统** | 三类记忆(fact / preference / rule),FTS5 全文搜索 + 向量语义搜索,写入前安全扫描,容量 500 条,90 天衰减 | | 📋 | **自定义文件** | SOUL.md(人格,不可压缩)+ AGENT.md(行为准则)+ USER.md(用户画像),工作空间优先,内置默认 fallback | @@ -231,7 +231,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -产出:`release/Metona Ollama Setup v0.10.0.exe` +产出:`release/Metona Ollama Setup v0.10.1.exe` ## 🛠️ 常用命令 @@ -261,7 +261,7 @@ npm run dist # 构建 Windows 安装包 | | Feature | Description | |:---:|:---|:---| -| 🤖 | **ReAct Agent Loop** | Full Thought → Action → Observation → Reflection cycle, up to 85 iterations (configurable), auto-retry 2 times, tool dedup, parallel/chain execution | +| 🤖 | **ReAct Agent Loop** | Always-on, only chat mode. Full Thought → Action → Observation → Reflection cycle, up to 85 iterations (configurable), auto-retry 2 times, tool dedup, parallel/chain execution | | 🔧 | **38 Built-in Tools** | File system · Command · Web search · Browser · Git · Memory · Skills · Sessions · Sub-agent | | 🧠 | **Smart Memory System** | Three types (fact / preference / rule), FTS5 + vector semantic search, pre-write security scan, 500 capacity, 90-day decay | | 📋 | **Custom Files** | SOUL.md (persona, never compressed) + AGENT.md (behavior rules) + USER.md (user profile), workspace-first with built-in defaults | @@ -454,7 +454,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v0.10.0.exe` +Output: `release/Metona Ollama Setup v0.10.1.exe` ## 🛠️ Common Commands diff --git a/docs/BUILD.md b/docs/BUILD.md index a2b7a4b..c938fed 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -91,7 +91,7 @@ npm run dist # NSIS 安装包 | 文件 | 说明 | |------|------| -| `release/Metona Ollama Setup v0.10.0.exe` | NSIS 安装包(可选目录、创建快捷方式) | +| `release/Metona Ollama Setup v0.10.1.exe` | NSIS 安装包(可选目录、创建快捷方式) | > 仅提供 NSIS 安装包。 @@ -103,7 +103,7 @@ npm run dist # NSIS 安装包 TOKEN="your_access_token" curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases?access_token=$TOKEN" \ -H "Content-Type: application/json" \ - -d '{"tag_name":"v0.10.0","name":"v0.10.0","body":"Release notes","target_commitish":"master"}' + -d '{"tag_name":"v0.10.1","name":"v0.10.1","body":"Release notes","target_commitish":"master"}' ``` ### 上传附件 @@ -112,7 +112,7 @@ curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/release TOKEN="your_access_token" RELEASE_ID="" curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases/$RELEASE_ID/attach_files?access_token=$TOKEN" \ - -H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.10.0.exe" + -H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.10.1.exe" ``` ## 故障排查 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8ca45fc..1e12e78 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,6 +1,6 @@ # Metona Ollama Desktop — 开发规范 -> 版本: v0.10.0 | 更新: 2026-06-05 | 维护: 项目团队 +> 版本: v0.10.1 | 更新: 2026-06-05 | 维护: 项目团队 --- diff --git a/package-lock.json b/package-lock.json index bee77d5..dd5edbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "0.10.0", + "version": "0.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "0.10.0", + "version": "0.10.1", "license": "MIT", "dependencies": { "sql.js": "^1.11.0" diff --git a/package.json b/package.json index 08fb95a..2dd85b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.10.0", + "version": "0.10.1", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "main": "dist/main/main.js", "author": "thzxx", @@ -48,7 +48,7 @@ "requestedExecutionLevel": "asInvoker" }, "nsis": { - "artifactName": "Metona Ollama Setup v0.10.0.${ext}", + "artifactName": "Metona Ollama Setup v0.10.1.${ext}", "oneClick": false, "allowToChangeInstallationDirectory": true, "createDesktopShortcut": true, diff --git a/src/main/menu.ts b/src/main/menu.ts index 9760ad0..aa9b795 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -101,7 +101,7 @@ export function createMenu(): void { dialog.showMessageBox(mainWindow!, { type: 'info', title: '关于 Metona Ollama', - message: 'Metona Ollama Desktop v0.10.0', + message: 'Metona Ollama Desktop v0.10.1', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', icon: getIconPath() }); diff --git a/src/renderer/api/ollama.ts b/src/renderer/api/ollama.ts index d0e45d7..d01762a 100644 --- a/src/renderer/api/ollama.ts +++ b/src/renderer/api/ollama.ts @@ -56,6 +56,7 @@ export class OllamaAPI { model: params.model, messages: params.messages, stream: false, + keep_alive: '1h', }; if (params.think !== undefined) body.think = params.think; if (params.system) body.system = params.system; @@ -117,7 +118,7 @@ export class OllamaAPI { const chunk: OllamaStreamChunk = JSON.parse(line); if (onChunk) onChunk(chunk); if (chunk.done) { - reader.cancel(); + // 不调用 reader.cancel() — TCP RST 可能导致 Ollama 不执行 keep_alive return; } } catch { diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index e062ad9..76c13d4 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -327,22 +327,19 @@ async function handleRetry(): Promise { clearMessagesDOM(); renderMessages(); - // 重新触发 Agent Loop / 普通聊天 - const toolCallingEnabled = state.get('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 { } }); } 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 { const currentSession = state.get(KEYS.CURRENT_SESSION); if (!currentSession) return; - // ── 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; - } - - // ── 原有普通聊天流程 ── - - 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(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(KEYS.API)!; - const abortController = new AbortController(); - state.set(KEYS.ABORT_CONTROLLER, abortController); - const thinkMode = isThinkEnabled(); - const numCtx = state.get(KEYS.NUM_CTX, 24576); - - let assistantContent = ''; - let thinkContent = ''; - let finalStats: any = null; - let modelName = ''; - - try { - const apiMessages = buildApiMessages(freshSession.messages); - const temperature = state.get('temperature', 0.7); - - const chatParams: Record = { - 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(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 += '

[已停止]

'; - 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) + '

[已中断]

'; - 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' 状态的同名工具记录 */ diff --git a/src/renderer/components/tools-modal.ts b/src/renderer/components/tools-modal.ts index a5979c6..7e4045a 100644 --- a/src/renderer/components/tools-modal.ts +++ b/src/renderer/components/tools-modal.ts @@ -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(KEYS.DB); - state.set('toolCallingEnabled', toggleToolCalling.checked); - if (db) await db.saveSetting('toolCallingEnabled', toggleToolCalling.checked); - if (toggleToolCalling.checked) { - const modelSupportsTools = state.get('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(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('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('runCommandMode', 'confirm'); const selectMode = modalEl.querySelector('#selectRunCommandMode') as HTMLSelectElement; diff --git a/src/renderer/index.html b/src/renderer/index.html index 2a9acc6..2049f90 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@
Metona Ollama - v0.10.0 + v0.10.1