diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d8520e3 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmmirror.com diff --git a/README.md b/README.md index fb10209..7470f75 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript license @@ -212,7 +212,7 @@ SQLite (sql.js WASM),7 张表,WAL 模式 + FTS5 全文搜索: ```bash git clone https://gitee.com/thzxx/metona-ollama-desktop.git cd metona-ollama-desktop -git checkout desktop-v1-stable-release +git checkout desktop-v1.1.0 npm config set registry https://registry.npmmirror.com npm install npm start @@ -225,7 +225,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -产出:`release/Metona Ollama Setup v1-stable-release.exe` +产出:`release/Metona Ollama Setup v1.1.0.exe` ## 🛠️ 常用命令 @@ -433,7 +433,7 @@ SQLite (sql.js WASM), 7 tables, WAL mode + FTS5 full-text search: ```bash git clone https://gitee.com/thzxx/metona-ollama-desktop.git cd metona-ollama-desktop -git checkout desktop-v1-stable-release +git checkout desktop-v1.1.0 npm config set registry https://registry.npmmirror.com npm install npm start @@ -446,7 +446,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v1-stable-release.exe` +Output: `release/Metona Ollama Setup v1.1.0.exe` ## 🛠️ Common Commands diff --git a/docs/BUILD.md b/docs/BUILD.md index 7e4778f..28138e7 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -14,7 +14,7 @@ # 1. 克隆并安装依赖 git clone https://gitee.com/thzxx/metona-ollama-desktop.git cd metona-ollama-desktop -git checkout desktop-v1-stable-release +git checkout desktop-v1.1.0 npm config set registry https://registry.npmmirror.com npm install @@ -92,7 +92,7 @@ npm run dist # NSIS 安装包 | 文件 | 说明 | |------|------| -| `release/Metona Ollama Setup v1-stable-release.exe` | NSIS 安装包(可选目录、创建快捷方式) | +| `release/Metona Ollama Setup v1.1.0.exe` | NSIS 安装包(可选目录、创建快捷方式) | > v3.2.0 起不再提供绿色便携版,仅保留 NSIS 安装包。 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b5f469d..407bfc5 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,6 +1,6 @@ # Metona Ollama Desktop — 开发规范 -> 版本: v1-stable-release | 更新: 2026-04-24 | 维护: 项目团队 +> 版本: v1.1.0 | 更新: 2026-04-24 | 维护: 项目团队 --- diff --git a/package-lock.json b/package-lock.json index f2da74c..775dcaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "sql.js": "^1.11.0" @@ -1867,7 +1867,7 @@ "license": "Python-2.0" }, "node_modules/assert-plus": { - "version": "v1-stable-release", + "version": "1.0.0", "resolved": "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz", "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", "dev": true, @@ -1913,7 +1913,7 @@ "license": "MIT" }, "node_modules/at-least-node": { - "version": "v1-stable-release", + "version": "1.0.0", "resolved": "https://registry.npmmirror.com/at-least-node/-/at-least-node-1.0.0.tgz", "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "dev": true, diff --git a/package.json b/package.json index 085ee0e..66adf82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "1.0.0", + "version": "1.1.0", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "main": "dist/main/main.js", "author": "thzxx", @@ -48,7 +48,7 @@ "requestedExecutionLevel": "asInvoker" }, "nsis": { - "artifactName": "Metona Ollama Setup v1-stable-release.${ext}", + "artifactName": "Metona Ollama Setup v1.1.0.${ext}", "oneClick": false, "allowToChangeInstallationDirectory": true, "createDesktopShortcut": true, diff --git a/release-notes.md b/release-notes.md index 7945000..294e738 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,4 +1,4 @@ -# Metona Ollama Desktop v1-stable-release +# Metona Ollama Desktop v1.1.0 > 本地 Ollama AI 桌面客户端 — 首个稳定版本 @@ -135,7 +135,7 @@ Metona Ollama Desktop 是一个基于 **Ollama** 的本地 AI 桌面客户端, ## 安装说明 -1. 下载 `Metona Ollama Setup v1-stable-release.exe` +1. 下载 `Metona Ollama Setup v1.1.0.exe` 2. 运行安装程序,可自定义安装目录 3. 确保本地已安装并运行 [Ollama](https://ollama.com) 4. 启动 Metona Ollama,在设置中配置 Ollama 服务地址 diff --git a/src/main/menu.ts b/src/main/menu.ts index 07b6fcc..03910d9 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 v3.2.2', + message: 'Metona Ollama Desktop v1.1.0', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', icon: getIconPath() }); diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts index 51fb8f6..44f8685 100644 --- a/src/renderer/components/chat-area.ts +++ b/src/renderer/components/chat-area.ts @@ -6,6 +6,7 @@ import { state, KEYS } from '../state/state.js'; import { logError, logSession } from '../services/log-service.js'; import { marked } from '../utils/marked-config.js'; import { escapeHtml, formatTime } from '../utils/utils.js'; +import { estimateTokens } from '../services/context-manager.js'; import type { ChatSession, ChatMessage, ToolCallRecord } from '../types.js'; function getFileIcon(filename: string): string { @@ -30,6 +31,31 @@ let scrollBtnEl: HTMLElement; let autoScroll = true; let currentPlaceholder: HTMLElement | null = null; +/** 渲染可折叠系统提示词卡片 */ +let _sysPromptIdCounter = 0; +function renderSystemPromptCard(sysPrompt: string): string { + const id = `sysprompt_${++_sysPromptIdCounter}`; + const tokenEstimate = estimateTokens(sysPrompt); + const preview = sysPrompt.length > 120 ? sysPrompt.slice(0, 120).replace(/\n/g, ' ') + '…' : sysPrompt.replace(/\n/g, ' '); + return `

+
+ 📋 + 系统提示词 + ~${tokenEstimate} tokens + ${escapeHtml(preview)} + +
+ +
`; +} + export function initChatArea(): void { chatAreaEl = document.querySelector('#chatArea')!; messagesContainerEl = document.querySelector('#messagesContainer')!; @@ -118,6 +144,12 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void { const safeContent = (msg.content != null) ? String(msg.content) : ''; if (msg.role === 'assistant') { + // ── 系统提示词折叠卡片(每条助理消息顶部展示)── + const sysPrompt = state.get('_lastSystemPrompt', ''); + if (sysPrompt) { + contentHtml += renderSystemPromptCard(sysPrompt); + } + if (msg.think) { contentHtml += `
@@ -273,6 +305,21 @@ export function updateLastAssistantMessage( const loadingText = lastMsg.querySelector('.loading-text'); if (loadingDots) loadingDots.remove(); if (loadingText) loadingText.remove(); + + // 流式消息首次转正:注入系统提示词折叠卡片 + const sysPrompt = state.get('_lastSystemPrompt', ''); + if (sysPrompt && !lastMsg.querySelector('.system-prompt-card')) { + const msgBody = lastMsg.querySelector('.msg-body'); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = renderSystemPromptCard(sysPrompt); + const card = tempDiv.firstElementChild!; + const thinkBlock = msgBody?.querySelector('.think-block'); + if (thinkBlock) { + msgBody!.insertBefore(card, thinkBlock); + } else { + msgBody!.insertBefore(card, msgBody!.firstChild); + } + } } if (model) { diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index 9f0026a..14b40a3 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -12,7 +12,7 @@ import { appendToolCallCardToPlaceholder, updateToolCallCardInPlaceholder, resetCurrentPlaceholder } from './chat-area.js'; import { showToast } from './toast.js'; -import { addToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal } from './workspace-panel.js'; +import { addToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath } from './workspace-panel.js'; import { ChatDB } from '../db/chat-db.js'; import { OllamaAPI } from '../api/ollama.js'; import { runAgentLoop } from '../services/agent-engine.js'; @@ -94,9 +94,7 @@ export function initInputArea(): void { const bridge = getBridge(); if (!bridge) return; const paths = await bridge.dialog.openFile({ - filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }], - properties: ['openFile'], - multiple: false + filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }] }); if (!paths || paths.length === 0) return; await handleImagePaths(paths); @@ -141,7 +139,7 @@ async function handleImagePaths(paths: string[]): Promise { for (const filePath of paths) { const name = filePath.split(/[/\\]/).pop() || filePath; try { - const result = await bridge.fs.readFileBase64(filePath); + const result = await bridge!.fs.readFileBase64(filePath); if (!result.success) { appendSystemMessage(`❌ 读取 ${name} 失败: ${result.error}`); continue; @@ -186,7 +184,7 @@ async function handleTextFilePaths(paths: string[]): Promise { continue; } try { - const result = await bridge.fs.readFile(filePath); + const result = await bridge!.fs.readFile(filePath); if (!result.success) { appendSystemMessage(`❌ 读取 ${name} 失败: ${result.error}`); continue; @@ -308,7 +306,7 @@ async function handleRetry(): Promise { ...(retryThinkContent && { think: retryThinkContent }), ...(toolCallRecords?.length && { toolCalls: toolCallRecords }), }; - state.update(KEYS.CURRENT_SESSION, (s: ChatSession | null) => ({ + state.update(KEYS.CURRENT_SESSION, (s: any) => ({ ...s, messages: [...s.messages, prevMsg], updatedAt: Date.now() })); renderMessages(); @@ -353,7 +351,7 @@ async function handleRetry(): Promise { ...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }), ...(loopStats?.total_duration && { total_duration: loopStats.total_duration }), }; - state.update(KEYS.CURRENT_SESSION, (s: ChatSession | null) => ({ + state.update(KEYS.CURRENT_SESSION, (s: any) => ({ ...s, messages: [...s.messages, lastMsg], updatedAt: Date.now() })); } @@ -367,7 +365,7 @@ async function handleRetry(): Promise { ...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }), ...(loopStats?.total_duration && { total_duration: loopStats.total_duration }), }; - state.update(KEYS.CURRENT_SESSION, (s: ChatSession | null) => ({ + state.update(KEYS.CURRENT_SESSION, (s: any) => ({ ...s, messages: [...s.messages, assistantMsg], updatedAt: Date.now() })); updateLastAssistantMessage(finalContent, retryThinkContent || null, loopStats || null, undefined, Date.now(), toolRecords?.length ? toolRecords : undefined); @@ -644,7 +642,7 @@ export async function sendMessage(): Promise { } const isFirstMsg = currentSession.messages.length === 0; - state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({ + state.update(KEYS.CURRENT_SESSION, (session: any) => ({ ...session, ...(isFirstMsg && { title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30), @@ -682,7 +680,7 @@ export async function sendMessage(): Promise { let assistantContent = ''; let thinkContent = ''; - let finalStats: { eval_count?: number; prompt_eval_count?: number; total_duration?: number } | null = null; + let finalStats: any = null; let modelName = ''; try { @@ -697,6 +695,20 @@ export async function sendMessage(): Promise { }; if (numCtx) chatParams.options = { num_ctx: numCtx, temperature }; + // ── 扫描工作空间 SOUL.md ── + let soulContent = ''; + const workspaceDir = getWorkspaceDirPath(); + if (workspaceDir) { + try { + const soulResult = await window.metonaDesktop?.workspace.readFile( + workspaceDir.replace(/\/+$/, '') + '/SOUL.md' + ); + if (soulResult?.success && soulResult.content) { + soulContent = `[SOUL.md] ${soulResult.content}`; + } + } catch { /* SOUL.md 不存在,静默跳过 */ } + } + // ── Agent 记忆注入 ── if (isMemoryEnabled()) { const userMessage = text || (msgsToAdd.find(m => m.role === 'user')?.content || ''); @@ -704,7 +716,7 @@ export async function sendMessage(): Promise { const relevantMemories = searchMemories(userMessage, 6); if (relevantMemories.length > 0) { const memoryContext = buildMemoryContext(relevantMemories); - chatParams.system = memoryContext; + chatParams.system = (soulContent ? soulContent + '\n\n' : '') + memoryContext; for (const m of relevantMemories) { await markMemoryUsed(m.id); } @@ -712,6 +724,12 @@ export async function sendMessage(): Promise { } } } + if (soulContent && !chatParams.system) { + chatParams.system = soulContent; + } + + // 存入 state 供 chat-area 渲染系统提示词卡片 + state.set('_lastSystemPrompt', chatParams.system || ''); await api.chatStream(chatParams as any, (chunk: OllamaStreamChunk) => { if (chunk.message) { @@ -752,9 +770,9 @@ export async function sendMessage(): Promise { timestamp: Date.now(), ...(modelName && { model: modelName }), ...(thinkContent && { think: thinkContent }), - ...(finalStats && { eval_count: finalStats.eval_count, prompt_eval_count: finalStats.prompt_eval_count, total_duration: finalStats.total_duration }) + ...((finalStats || {}) as any) }; - state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({ + state.update(KEYS.CURRENT_SESSION, (session: any) => ({ ...session, messages: [...session.messages, assistantMsg], updatedAt: Date.now() @@ -800,7 +818,7 @@ export async function sendMessage(): Promise { ...(thinkContent && { think: thinkContent }), stopped: true }; - state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({ + state.update(KEYS.CURRENT_SESSION, (session: any) => ({ ...session, messages: [...session.messages, partialMsg], updatedAt: Date.now() @@ -829,7 +847,7 @@ export async function sendMessage(): Promise { const contentDiv = placeholder?.querySelector('.msg-content'); if (assistantContent && contentDiv) { - state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({ + 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() @@ -864,14 +882,14 @@ export async function sendMessage(): Promise { /** 更新当前会话消息中最近一个 'running' 状态的同名工具记录 */ function updateMessageToolRecord(toolName: string, status: 'success' | 'error', result: Record): void { let updated = false; - state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => { + state.update(KEYS.CURRENT_SESSION, (session: any) => { if (!session) return session; const messages = [...session.messages]; // 从后往前找最近一条包含该工具 running 状态记录的消息 for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (!msg.toolCalls) continue; - const idx = msg.toolCalls.findIndex(tc => tc.name === toolName && tc.status === 'running'); + const idx = msg.toolCalls.findIndex((tc: any) => tc.name === toolName && tc.status === 'running'); if (idx !== -1) { const updatedRecords = [...msg.toolCalls]; updatedRecords[idx] = { ...updatedRecords[idx], status, result }; @@ -919,7 +937,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio } const isFirstMsg = currentSession.messages.length === 0; - state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({ + state.update(KEYS.CURRENT_SESSION, (session: any) => ({ ...session, ...(isFirstMsg && { title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30), @@ -1009,7 +1027,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio ...(iterationEvalCount > 0 && { eval_count: iterationEvalCount }), ...(iterationDuration > 0 && { total_duration: iterationDuration }), }; - state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({ + state.update(KEYS.CURRENT_SESSION, (session: any) => ({ ...session, messages: [...session.messages, prevMsg], updatedAt: Date.now() @@ -1075,7 +1093,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio ...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }), ...(loopStats?.total_duration && { total_duration: loopStats.total_duration }), }; - state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({ + state.update(KEYS.CURRENT_SESSION, (session: any) => ({ ...session, messages: [...session.messages, lastMsg], updatedAt: Date.now() @@ -1094,7 +1112,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio ...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }), ...(loopStats?.total_duration && { total_duration: loopStats.total_duration }), }; - state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({ + state.update(KEYS.CURRENT_SESSION, (session: any) => ({ ...session, messages: [...session.messages, assistantMsg], updatedAt: Date.now() @@ -1133,7 +1151,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio ...(abortToolRecords?.length && { toolCalls: abortToolRecords }), stopped: true }; - state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({ + state.update(KEYS.CURRENT_SESSION, (session: any) => ({ ...session, messages: [...session.messages, partialMsg], updatedAt: Date.now() diff --git a/src/renderer/components/model-bar.ts b/src/renderer/components/model-bar.ts index fe93301..2512451 100644 --- a/src/renderer/components/model-bar.ts +++ b/src/renderer/components/model-bar.ts @@ -122,11 +122,22 @@ async function filterEmbedModels(models: Array<{ name: string }>): Promise const info = await api.showModel(m.name); const caps = info.capabilities || []; const isCompletion = caps.includes('completion'); + + // 从 model_info 提取上下文长度 + let contextLength = 2048; + for (const key of Object.keys(info.model_info || {})) { + if (key.endsWith('.context_length')) { + contextLength = Number(info.model_info![key]) || 2048; + break; + } + } + modelCapabilityCache.set(m.name, { think: caps.includes('thinking'), vision: caps.includes('vision'), tools: caps.includes('tools'), completion: isCompletion, + contextLength, }); if (!isCompletion) { const opt = modelSelectEl.querySelector(`option[value="${CSS.escape(m.name)}"]`); @@ -149,7 +160,11 @@ async function filterEmbedModels(models: Array<{ name: string }>): Promise async function checkModelCapability(modelName: string): Promise { if (modelCapabilityCache.has(modelName)) { - applyCapability(modelName, modelCapabilityCache.get(modelName)!); + const cached = modelCapabilityCache.get(modelName)!; + state.set(KEYS.NUM_CTX, cached.contextLength); + const displayEl = document.querySelector('#displayNumCtx'); + if (displayEl) displayEl.textContent = `${cached.contextLength.toLocaleString()} tokens`; + applyCapability(modelName, cached); return; } @@ -159,18 +174,40 @@ async function checkModelCapability(modelName: string): Promise { try { const info = await api.showModel(modelName); const capabilities = info.capabilities || []; - const caps = { + + // 从 model_info 中提取模型实际上下文长度 + let contextLength = 2048; // 默认保守值 + const modelInfo = info.model_info || {}; + for (const key of Object.keys(modelInfo)) { + if (key.endsWith('.context_length')) { + contextLength = Number(modelInfo[key]) || 2048; + break; + } + } + + const caps: ModelCaps = { think: capabilities.includes('thinking'), vision: capabilities.includes('vision'), tools: capabilities.includes('tools'), completion: capabilities.includes('completion'), + contextLength, }; modelCapabilityCache.set(modelName, caps); - logDebug(`能力检测: ${modelName}`, capabilities.join(', ')); + logDebug(`能力检测: ${modelName}`, `ctx: ${contextLength}, ` + capabilities.join(', ')); + + // 自动设置上下文长度为模型实际支持的值 + state.set(KEYS.NUM_CTX, contextLength); + const db = state.get(KEYS.DB); + if (db) await db.saveSetting('numCtx', contextLength); + + // 更新设置面板显示 + const displayEl = document.querySelector('#displayNumCtx'); + if (displayEl) displayEl.textContent = `${contextLength.toLocaleString()} tokens`; + applyCapability(modelName, caps); } catch (err) { logWarn(`能力检测失败: ${modelName}`, (err as Error).message); - const fallback = { think: false, vision: false, tools: false, completion: true }; + const fallback: ModelCaps = { think: false, vision: false, tools: false, completion: true, contextLength: 2048 }; modelCapabilityCache.set(modelName, fallback); applyCapability(modelName, fallback); } diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index 552437b..a9d81ef 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -39,16 +39,6 @@ export function initSettingsModal(): void { }, 500); document.querySelector('#inputServerUrl')!.addEventListener('input', saveServerUrl); - const saveNumCtx = debounce(async () => { - const db = state.get(KEYS.DB); - const val = (document.querySelector('#inputNumCtx') as HTMLInputElement).value.trim(); - const numCtx = val ? parseInt(val) : 24576; - state.set(KEYS.NUM_CTX, numCtx); - if (db) await db.saveSetting('numCtx', numCtx); - logSetting('上下文长度', `${numCtx} tokens`); - }, 500); - document.querySelector('#inputNumCtx')!.addEventListener('input', saveNumCtx); - const tempSlider = document.querySelector('#inputTemperature') as HTMLInputElement; const tempDisplay = document.querySelector('#tempValue')!; tempSlider.addEventListener('input', () => { @@ -250,9 +240,9 @@ export function initSettingsModal(): void { const result = await bridge.workspace.setDir(newDir); if (result.success) { showToast('工作空间目录已更新', 'success'); - logSetting('工作空间目录', result.dir); + logSetting('工作空间目录', result.dir!); } else { - showToast(`设置失败: ${result.error}`, 'error'); + showToast(`设置失败: ${result.error!}`, 'error'); // 恢复原值 const info = await bridge.workspace.getDir(); inputWorkspaceDir.value = info.dir; @@ -401,7 +391,7 @@ async function importSessions(filePath: string): Promise { try { const bridge = window.metonaDesktop; - const result = await bridge.fs.readFileBase64(filePath); + const result = await bridge!.fs.readFileBase64(filePath); if (!result.success) { showToast(`读取文件失败: ${result.error}`, 'error'); return; diff --git a/src/renderer/components/token-dashboard.ts b/src/renderer/components/token-dashboard.ts index a232a6f..174958e 100644 --- a/src/renderer/components/token-dashboard.ts +++ b/src/renderer/components/token-dashboard.ts @@ -301,7 +301,7 @@ function renderBarChart( /** 渲染全局柱状图(按会话) */ function renderGlobalChart(stats: AllSessionsTokenStats): void { - const chartEl = dashboardModalEl.querySelector('#tdChart')!; + const chartEl = dashboardModalEl.querySelector('#tdChart') as HTMLElement; const barsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null; const savedScrollLeft = barsEl?.scrollLeft ?? 0; @@ -329,7 +329,7 @@ function renderGlobalChart(stats: AllSessionsTokenStats): void { /** 渲染全局明细表格 */ function renderGlobalTable(stats: AllSessionsTokenStats): void { - const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle'); + const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null; if (tableTitleEl) tableTitleEl.textContent = '📋 会话汇总明细'; // 动态更新表头 @@ -406,7 +406,7 @@ function renderSessionView(): void { `; // 柱状图 - const chartEl = dashboardModalEl.querySelector('#tdChart')!; + const chartEl = dashboardModalEl.querySelector('#tdChart') as HTMLElement; const barsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null; const savedScrollLeft = barsEl?.scrollLeft ?? 0; @@ -423,7 +423,7 @@ function renderSessionView(): void { renderBarChart(chartEl, chartData, '按轮次', undefined, savedScrollLeft); // 表格标题 - const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle'); + const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null; if (tableTitleEl) tableTitleEl.textContent = '📋 轮次明细(按时间倒序)'; // 动态更新表头 diff --git a/src/renderer/components/tools-modal.ts b/src/renderer/components/tools-modal.ts index 0eada31..a5979c6 100644 --- a/src/renderer/components/tools-modal.ts +++ b/src/renderer/components/tools-modal.ts @@ -7,7 +7,7 @@ import { state, KEYS } from '../state/state.js'; import { setToolEnabled, setRunCommandMode } from '../services/tool-registry.js'; import { showToast } from './toast.js'; import { logInfo, logWarn } from '../services/log-service.js'; -import type { ChatDB } from '../types.js'; +import type { ChatDB } from '../db/chat-db.js'; let modalEl: HTMLElement; diff --git a/src/renderer/db/chat-db.ts b/src/renderer/db/chat-db.ts index f6c999f..fcca55a 100644 --- a/src/renderer/db/chat-db.ts +++ b/src/renderer/db/chat-db.ts @@ -10,9 +10,9 @@ function isDesktop(): boolean { return !!window.metonaDesktop?.isDesktop; } -/** 桌面端 DB 桥接 */ +/** 桌面端 DB 桥接(isDesktop 已检查,安全断言非空) */ function dbBridge() { - return window.metonaDesktop?.db; + return window.metonaDesktop!.db!; } export class ChatDB { @@ -201,7 +201,7 @@ export class ChatDB { async getSetting(key: string, defaultValue: T | null = null): Promise { if (isDesktop()) { - return dbBridge().getSetting(key, defaultValue); + return dbBridge().getSetting(key, defaultValue) as Promise; } return this._idbGetSetting(key, defaultValue); } diff --git a/src/renderer/index.html b/src/renderer/index.html index ed8df2c..3c2cb09 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@
Metona Ollama - v1-stable-release + v1.1.0
- +
- - tokens + 24576 tokens
-

常见值:2048 / 4096 / 8192 / 32768。值越大上下文越长,显存占用越高。

+

选择模型时自动从模型元数据中获取实际上下文长度。

低值更精确稳定,高值更有创意多样(0 = 确定性,2 = 最大随机)

diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 424183c..3c2b461 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -100,8 +100,8 @@ async function migrateIndexedDBToSQLite(db: ChatDB): Promise { created_at: s.createdAt, updated_at: s.updatedAt })), - messages: sessions.flatMap(s => - s.messages.map((m: Record, idx: number) => ({ + messages: (sessions as any[]).flatMap((s: any) => + (s.messages || []).map((m: any, idx: number) => ({ id: `${s.id}_msg_${idx}_${m.timestamp}`, session_id: s.id, role: m.role, @@ -120,7 +120,7 @@ async function migrateIndexedDBToSQLite(db: ChatDB): Promise { type: m.type, content: m.content, importance: m.importance || 5, - tags: m.tags?.length ? JSON.stringify(m.tags) : null, + tags: (m.tags as any[])?.length ? JSON.stringify(m.tags) : null, source: m.source || null, session_id: m.sessionId || null, use_count: m.useCount || 0, @@ -354,9 +354,8 @@ async function init(): Promise { state.set(KEYS.NUM_CTX, numCtx); state.set('temperature', temperature); - (document.querySelector('#inputNumCtx') as HTMLInputElement).value = String(numCtx); (document.querySelector('#inputTemperature') as HTMLInputElement).value = String(temperature); - document.querySelector('#tempValue')!.textContent = parseFloat(temperature).toFixed(1); + document.querySelector('#tempValue')!.textContent = parseFloat(String(temperature)).toFixed(1); // ── v5.1.1 Agent 最大轮数 ── const maxTurns = await db.getSetting('maxTurns', 85); diff --git a/src/renderer/services/agent-engine.ts b/src/renderer/services/agent-engine.ts index 31d4412..1326c55 100644 --- a/src/renderer/services/agent-engine.ts +++ b/src/renderer/services/agent-engine.ts @@ -18,14 +18,15 @@ import { showToast } from '../components/toast.js'; import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse } from './log-service.js'; import { getWorkspaceDirPath } from '../components/workspace-panel.js'; import { generateId } from '../utils/utils.js'; -import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD } from './context-manager.js'; +import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD, recordActualTokens } from './context-manager.js'; import type { OllamaMessage, OllamaStreamChunk, ToolCall, ToolResult, ToolCallRecord, - TraceEntry + TraceEntry, + ChatSession } from '../types.js'; const MAX_RETRIES = 2; // 工具错误自动重试次数 @@ -173,7 +174,7 @@ async function inferUserProfile(userMsg: string, assistantMsg: string): Promise< const bridge = window.metonaDesktop; if (!bridge?.db?.getSetting) return; - const profile: Record = await bridge.db.getSetting('user_profile', {}) || {}; + const profile: Record = await bridge.db.getSetting('user_profile', {}) || {}; // 技术栈检测 const techPatterns: Record = { @@ -363,7 +364,7 @@ export interface AgentCallbacks { } /** 保存执行轨迹到 SQLite */ -async function saveTrace(trace: Omit): Promise { +async function saveTrace(trace: Record): Promise { try { const bridge = window.metonaDesktop; if (!bridge?.db) return; @@ -390,7 +391,7 @@ export async function runAgentLoop( ): Promise { const api = state.get(KEYS.API); const model = state.get('_defaultModel', ''); - const currentSession = state.get(KEYS.CURRENT_SESSION); + const currentSession = state.get(KEYS.CURRENT_SESSION); const sessionId = currentSession?.id || 'unknown'; if (!api || !model) { @@ -411,6 +412,24 @@ export async function runAgentLoop( const messages: OllamaMessage[] = []; let systemPromptParts: string[] = []; + // ── 扫描工作空间 SOUL.md ── + const workspaceDir = getWorkspaceDirPath(); + if (workspaceDir) { + try { + const soulResult = await window.metonaDesktop?.workspace.readFile( + workspaceDir.replace(/\/+$/, '') + '/SOUL.md' + ); + if (soulResult?.success && soulResult.content) { + // SOUL.md 注入为独立 system 消息,标记为不可压缩 + messages.push({ + role: 'system', + content: `[SOUL.md] ${soulResult.content}`, + }); + logInfo('SOUL.md 已注入系统提示词', `${soulResult.lines || 0} 行`); + } + } catch { /* SOUL.md 不存在或读取失败,静默跳过 */ } + } + // 注入记忆上下文 if (isMemoryEnabled() && userContent) { const relevantMemories = searchMemories(userContent, 6); @@ -423,7 +442,6 @@ export async function runAgentLoop( } // 注入工作空间上下文 - const workspaceDir = getWorkspaceDirPath(); if (workspaceDir) { systemPromptParts.push(`【工作空间】 当前工作空间目录: ${workspaceDir} @@ -447,7 +465,7 @@ export async function runAgentLoop( const db = state.get(KEYS.DB); if (db) { try { - const userProfile = await db.getSetting('user_profile', null); + const userProfile = await db.getSetting('user_profile', null) as Record | null; if (userProfile && typeof userProfile === 'object') { const parts: string[] = []; if (userProfile.tech_stack?.length) parts.push(`技术栈: ${userProfile.tech_stack.join(', ')}`); @@ -475,6 +493,9 @@ export async function runAgentLoop( messages.push({ role: 'system', content: fullSystemPrompt }); + // 将完整系统提示词存入 state,供 input-area 在助理消息上展示 + state.set('_lastSystemPrompt', fullSystemPrompt); + // 添加历史消息 for (const msg of historyMessages) { messages.push({ @@ -503,7 +524,7 @@ export async function runAgentLoop( logInfo(`自动上下文压缩触发: tokens≈${estimateTokens(messages.map(m => m.content || '').join(''))} > ${Math.floor(numCtx * AUTO_COMPRESS_THRESHOLD)} (50% of ${numCtx})`); const compressAC = state.get(KEYS.ABORT_CONTROLLER) || new AbortController(); try { - const compressed = await compressWithLLM(messages, api, model, { abortController: compressAC }); + const compressed = await compressWithLLM(messages, api as any, model, { abortController: compressAC }); if (compressed.length < messages.length || estimateTokens(compressed.map(m => m.content || '').join('')) < estimateTokens(messages.map(m => m.content || '').join(''))) { messages.length = 0; messages.push(...compressed); @@ -633,10 +654,25 @@ export async function runAgentLoop( totalPromptEvalCount += loopPromptEvalCount; totalInferenceNs += loopInferenceNs; state.set('_currentEvalCount', totalEvalCount); + + // Token 校准:用 Ollama 返回的实际计数修正估算器(在重置前保存本轮值) + const thisLoopEval = loopEvalCount; + const thisLoopPrompt = loopPromptEvalCount; // 重置本轮计数器(下一轮重新从 chunk 收集) loopEvalCount = 0; loopPromptEvalCount = 0; loopInferenceNs = 0; + + // Token 校准:用 Ollama 返回的实际计数自动修正估算器 + try { + if (thisLoopEval > 0 || thisLoopPrompt > 0) { + const estimatedThisLoop = estimateTokens(messages.map(m => m.content || '').join('')); + if (estimatedThisLoop > 0) { + recordActualTokens(thisLoopPrompt, thisLoopEval, estimatedThisLoop); + } + } + } catch { /* 校准失败不阻塞主流程 */ } + } catch (err) { if (abortController.signal.aborted) { logInfo('流式调用已中止'); @@ -722,7 +758,7 @@ export async function runAgentLoop( // v4.2 自动提取技能 if (allToolRecords.length >= 2) { try { - await extractSkillsFromToolRecords(allToolRecords, userContent, currentSession?.title || ''); + await extractSkillsFromToolRecords(allToolRecords, userContent, (currentSession as ChatSession)?.title || ''); } catch { /* 不阻塞 */ } } diff --git a/src/renderer/services/context-manager.ts b/src/renderer/services/context-manager.ts index 6369f94..3f7b4d6 100644 --- a/src/renderer/services/context-manager.ts +++ b/src/renderer/services/context-manager.ts @@ -7,7 +7,31 @@ import type { OllamaMessage, OllamaStreamChunk } from '../types.js'; import { logInfo, logWarn, logSuccess, logError } from './log-service.js'; -/** 粗略估算 token 数 */ +// ── Token 校准系统 ── + +/** 校准比例:actualTokens / estimatedTokens,基于 Ollama 返回的实际计数动态修正 */ +let _tokenCalibrationRatio = 1.0; +let _calibrationSamples = 0; +const MIN_CALIBRATION_SAMPLES = 3; + +/** + * 记录 Ollama 返回的实际 token 计数,用于校准估算器。 + * 在 agent-engine.ts 每轮流式完成后调用。 + * @param actualInputTokens Ollama 返回的 prompt_eval_count + * @param actualOutputTokens Ollama 返回的 eval_count + * @param estimatedTokens 本轮消息调用 estimateTokens 的合计值 + */ +export function recordActualTokens(actualInputTokens: number, actualOutputTokens: number, estimatedCount: number): void { + if (actualInputTokens <= 0 || estimatedCount <= 0) return; + const actualTotal = actualInputTokens + actualOutputTokens; + const sampleRatio = actualTotal / Math.max(1, estimatedCount); + // 指数移动平均,平滑异常值 + const alpha = 0.3; + _tokenCalibrationRatio = _tokenCalibrationRatio * (1 - alpha) + sampleRatio * alpha; + _calibrationSamples++; +} + +/** 估算 token 数(自动使用校准后的比例) */ export function estimateTokens(text: string): number { if (!text) return 0; // 中文按 1.5 字/token,英文按 4 字符/token @@ -17,7 +41,17 @@ export function estimateTokens(text: string): number { if (/[\u4e00-\u9fff]/.test(ch)) chineseChars++; else otherChars++; } - return Math.ceil(chineseChars / 1.5 + otherChars / 4); + const raw = Math.ceil(chineseChars / 1.5 + otherChars / 4); + // 应用校准比例(仅在有足够样本后) + if (_calibrationSamples >= MIN_CALIBRATION_SAMPLES) { + return Math.ceil(raw * _tokenCalibrationRatio); + } + return raw; +} + +/** 获取当前校准比例(供调试用) */ +export function getTokenCalibration(): { ratio: number; samples: number } { + return { ratio: _tokenCalibrationRatio, samples: _calibrationSamples }; } /** 自动压缩阈值:当消息 token 占 context window 比例超过此值时触发自动压缩 */ @@ -27,6 +61,57 @@ export const AUTO_COMPRESS_THRESHOLD = 0.5; const COMPRESS_KEEP_HEAD = 2; const COMPRESS_KEEP_TAIL = 2; +// ── 消息重要性评分(纯规则,不调用 LLM)── + +/** 重要性评分:0-10,越高越应该保留。SOUL.md 和规则记忆始终保留。 */ +export function scoreMessageImportance(msg: OllamaMessage): number { + let score = 5; // 默认中等 + + // SOUL.md 消息永远不可压缩 + if (msg.content?.startsWith('[SOUL.md]')) return 10; + + // 角色权重 + if (msg.role === 'user') score += 2; // 用户消息最重要 + if (msg.role === 'system') score -= 2; // 系统消息通常可压缩 + + // 已压缩标记 → 低权重(避免压缩产物堆积) + if (msg.compressed) score = 1; + + const content = msg.content || ''; + + // 关键词检测 + const highValuePatterns = [/路径|目录|path|file|config|配置|命令|command|exec/i, + /错误|error|失败|fail|bug|fix|修复|解决/i, + /版本|version|API|http|url|端口|port|localhost/i, + /记住|保存|memory|偏好|偏好|规则|rule/i, + /完成|done|✓|success|成功|结果|result/i, + /项目|project|工作空间|workspace|git|repo|仓库/i, + ]; + const lowValuePatterns = [/好的|明白|ok|知道了|嗯|哦|好/i, + /继续|请|帮我|可以吗/i, + /谢谢|感谢|不客气/i, + ]; + + for (const p of highValuePatterns) { + if (p.test(content)) { score += 1; break; } + } + for (const p of lowValuePatterns) { + if (p.test(content) && content.length < 100) { score -= 2; break; } + } + + // 工具调用 → 高价值 + if (msg.tool_calls?.length) score += 2; + + // 长度加分:长消息通常包含更多信息 + if (content.length > 500) score += 1; + if (content.length > 2000) score += 1; + + // 图像附件 → 中等价值(大但语义密度低) + if (msg.images?.length) score -= 1; + + return Math.max(1, Math.min(10, score)); +} + export interface ContextBuildOptions { /** 滑动窗口大小(最近 N 条消息完整保留) */ windowSize?: number; @@ -127,9 +212,28 @@ export function shouldAutoCompress(messages: OllamaMessage[], numCtx: number): b return totalTokens > threshold; } +// ── 结构化压缩 ── + +/** 结构化摘要 */ +export interface StructuredSummary { + /** 讨论的主题 */ + topics: string[]; + /** 已做出的决定 */ + decisions: string[]; + /** 未完成的待办事项 */ + pendingTasks: string[]; + /** 发现的约束/规则 */ + constraints: string[]; + /** 关键知识点(跨会话有价值的信息) */ + knowledge: string[]; + /** 工具调用结果摘要 */ + toolResults: string[]; +} + /** - * LLM 摘要压缩:调用模型对中间消息生成摘要 - * 保留首尾各 keepHead/keepTail 条消息,中间用 LLM 摘要替换 + * LLM 摘要压缩:调用模型对中间消息生成结构化 JSON 摘要。 + * 保留首尾各 keepHead/keepTail 条消息,中间用 JSON 摘要替换。 + * v6.0: 输出结构化 JSON,支持增量合并。 * * @returns 压缩后的消息列表(包含 compressed 标记的摘要消息) */ @@ -182,14 +286,14 @@ export async function compressWithLLM( logInfo(`上下文压缩: 开始 LLM 摘要,${uncompressedMiddle.length} 条消息待压缩`); - let summary = ''; + let summaryJson = ''; try { await api.chatStream( { model, messages: [{ role: 'user', - content: `请将以下对话摘要为一段简洁的上下文总结。保留关键信息:讨论的主题、得出的结论、用户的偏好、未完成的任务、关键工具调用结果。用中文输出,不超过 ${maxSummaryTokens} 字。\n\n对话记录:\n${conversationText}` + content: `请将以下对话摘要为结构化 JSON。保留关键信息,用中文输出。严格按此 JSON 格式返回(不要输出其他内容):\n\n{\n "topics": ["讨论的 2-4 个核心主题"],\n "decisions": ["已做出的决定(最多 3 条)"],\n "pendingTasks": ["尚未完成的任务(最多 3 条)"],\n "constraints": ["发现的约束/规则/偏好(最多 3 条)"],\n "knowledge": ["跨会话有价值的长期知识点(最多 3 条)"],\n "toolResults": ["关键工具调用结果摘要(最多 3 条)"]\n}\n\n对话记录:\n${conversationText}` }], stream: true, think: false, @@ -197,7 +301,7 @@ export async function compressWithLLM( }, (chunk: OllamaStreamChunk) => { if (chunk.message?.content) { - summary += chunk.message.content; + summaryJson += chunk.message.content; } }, options.abortController @@ -211,15 +315,34 @@ export async function compressWithLLM( return messages; } - if (!summary.trim()) { + if (!summaryJson.trim()) { logWarn('上下文压缩: 模型未返回摘要内容'); return messages; } + // 解析 JSON 摘要(容错:提取第一个 JSON 块或直接解析) + let parsed: StructuredSummary; + try { + const jsonMatch = summaryJson.match(/\{[\s\S]*"topics"[\s\S]*\}/); + parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : JSON.parse(summaryJson); + } catch { + logWarn('上下文压缩: JSON 解析失败,使用纯文本摘要'); + parsed = { topics: [summaryJson.slice(0, 100)], decisions: [], pendingTasks: [], constraints: [], knowledge: [], toolResults: [] }; + } + + // 构建结构化摘要消息文本 + const parts: string[] = []; + if (parsed.topics.length) parts.push(`📌 主题: ${parsed.topics.join(';')}`); + if (parsed.decisions.length) parts.push(`✅ 决策: ${parsed.decisions.join(';')}`); + if (parsed.pendingTasks.length) parts.push(`⏳ 待办: ${parsed.pendingTasks.join(';')}`); + if (parsed.constraints.length) parts.push(`📏 约束: ${parsed.constraints.join(';')}`); + if (parsed.knowledge.length) parts.push(`🧠 知识点: ${parsed.knowledge.join(';')}`); + if (parsed.toolResults.length) parts.push(`🔧 工具结果: ${parsed.toolResults.join(';')}`); + // 构建压缩后的摘要消息 const summaryMsg: OllamaMessage = { role: 'system', - content: `📋 以下是对之前对话的摘要(已压缩 ${uncompressedMiddle.length} 条消息):\n\n${summary}`, + content: `📋 以下是对之前对话的摘要(已压缩 ${uncompressedMiddle.length} 条消息):\n\n${parts.join('\n')}`, compressed: true }; @@ -243,7 +366,8 @@ export async function compressWithLLM( } /** - * 将较早的消息每 batchSize 条压缩为一段摘要(快速文本截取,不调用 LLM) + * 将较早的消息每 batchSize 条压缩为一段摘要。 + * v6.0: 重要性高的消息保留更多内容,低价值消息激进截断。 */ function summarizeOlderMessages(messages: OllamaMessage[], batchSize: number): OllamaMessage[] { const summaries: OllamaMessage[] = []; @@ -262,7 +386,7 @@ function summarizeOlderMessages(messages: OllamaMessage[], batchSize: number): O } /** - * 快速摘要(不调用模型,直接提取关键信息) + * 快速摘要:重要性高的消息保留更多信息,低价值的激进截断 */ function createQuickSummary(messages: OllamaMessage[]): string { const parts: string[] = []; @@ -270,7 +394,21 @@ function createQuickSummary(messages: OllamaMessage[]): string { for (const msg of messages) { const role = msg.role === 'user' ? '用户' : 'AI'; const content = msg.content || ''; - const preview = content.length > 100 ? content.slice(0, 100) + '...' : content; + const importance = scoreMessageImportance(msg); + + let preview: string; + if (importance >= 8) { + // 高价值消息:保留 200 字 + preview = content.length > 200 ? content.slice(0, 200) + '...' : content; + } else if (importance >= 5) { + // 中等价值:保留 100 字 + preview = content.length > 100 ? content.slice(0, 100) + '...' : content; + } else { + // 低价值:仅保留 40 字或跳过 + if (content.trim().length < 10) continue; + preview = content.length > 40 ? content.slice(0, 40) + '...' : content; + } + if (preview.trim()) { parts.push(`${role}: ${preview}`); } @@ -284,31 +422,46 @@ function createQuickSummary(messages: OllamaMessage[]): string { } /** - * 根据 token 限制裁剪消息 + * 根据 token 限制裁剪消息。 + * v6.0: 按重要性评分决定保留顺序——重要性低的优先被丢弃。 */ function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaMessage[] { - let totalTokens = 0; - const result: OllamaMessage[] = []; + // 分离 system 和非 system 消息 + const systemMsgs = messages.filter(m => m.role === 'system'); + const nonSystemMsgs = messages.filter(m => m.role !== 'system'); - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - const msgTokens = estimateTokens(msg.content || '') + - (msg.images ? msg.images.length * 100 : 0) + - (msg.tool_calls ? msg.tool_calls.length * 50 : 0); + if (nonSystemMsgs.length <= 4) return messages; // 消息太少不裁剪 - // system 消息始终保留 - if (msg.role === 'system') { - totalTokens += msgTokens; - result.unshift(msg); - continue; - } + // 计算每条消息的 token + 重要性 + const scored = nonSystemMsgs.map(m => ({ + msg: m, + tokens: estimateTokens(m.content || '') + + (m.images ? m.images.length * 100 : 0) + + (m.tool_calls ? m.tool_calls.length * 50 : 0), + importance: scoreMessageImportance(m), + })); - if (totalTokens + msgTokens > maxTokens && result.length > 2) { - break; - } + // system 消息的 token 消耗 + const systemTokens = systemMsgs.reduce((sum, m) => sum + estimateTokens(m.content || ''), 0); + const availableTokens = maxTokens - systemTokens; - totalTokens += msgTokens; - result.unshift(msg); + // 按重要性降序排列,取能装下的最大数量 + scored.sort((a, b) => b.importance - a.importance); + + let usedTokens = 0; + const kept = new Set(); // 保留的索引 + for (let i = 0; i < scored.length; i++) { + if (usedTokens + scored[i].tokens > availableTokens && kept.size >= 2) break; + usedTokens += scored[i].tokens; + kept.add(i); + } + + // 按原始顺序重建:system → 按重要性保留的非system + const result: OllamaMessage[] = [...systemMsgs]; + // 使用原始顺序而非重要性顺序,保持对话的时间线 + const keptSet = new Set(scored.filter((_, i) => kept.has(i)).map(s => s.msg)); + for (const m of nonSystemMsgs) { + if (keptSet.has(m)) result.push(m); } return result; diff --git a/src/renderer/services/crypto.ts b/src/renderer/services/crypto.ts index 0ad6401..0407c63 100644 --- a/src/renderer/services/crypto.ts +++ b/src/renderer/services/crypto.ts @@ -9,9 +9,9 @@ const PBKDF2_ITERATIONS = 100000; async function deriveKeyBytes(salt: Uint8Array): Promise { const passBytes = new TextEncoder().encode(PASSPHRASE); - const keyMaterial = await crypto.subtle.importKey('raw', passBytes, 'PBKDF2', false, ['deriveKey']); + const keyMaterial = await (crypto.subtle as any).importKey('raw', passBytes, 'PBKDF2', false, ['deriveKey']); const key = await crypto.subtle.deriveKey( - { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, + { name: 'PBKDF2', salt: salt as any, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, keyMaterial, { name: 'AES-GCM', length: 256 }, true, @@ -27,7 +27,7 @@ export async function encryptData(data: unknown): Promise { const iv = crypto.getRandomValues(new Uint8Array(12)); const keyBytes = await deriveKeyBytes(salt); - const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']); + const key = await crypto.subtle.importKey('raw', keyBytes as any, 'AES-GCM', false, ['encrypt']); const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, json); const payload = new Uint8Array(ciphertext); @@ -51,7 +51,7 @@ export async function decryptData(buffer: ArrayBuffer): Promise { throw new Error('不支持的加密格式,请使用最新版本重新导出'); } - const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['decrypt']); + const key = await crypto.subtle.importKey('raw', keyBytes as any, 'AES-GCM', false, ['decrypt']); const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, payload); const jsonBytes = new Uint8Array(decrypted); diff --git a/src/renderer/services/memory-manager.ts b/src/renderer/services/memory-manager.ts index b518a88..1e2cb57 100644 --- a/src/renderer/services/memory-manager.ts +++ b/src/renderer/services/memory-manager.ts @@ -17,7 +17,9 @@ import { getMemoryCollectionId } from './vector-memory.js'; import { logMemory, logDebug, logWarn, logInfo } from './log-service.js'; -import type { MemoryEntry, MemorySearchResult, MemoryExtractionResult, ChatDB, OllamaAPI } from '../types.js'; +import type { MemoryEntry, MemorySearchResult, MemoryExtractionResult } from '../types.js'; +import type { ChatDB } from '../db/chat-db.js'; +import type { OllamaAPI } from '../api/ollama.js'; const TYPE_ICONS: Record = { fact: '📌', @@ -686,10 +688,9 @@ ${conversationText.slice(0, 4000)} const response = await api.chat({ model, messages: [{ role: 'user', content: extractPrompt }], - stream: false, think: false, options: { num_ctx: 8192, temperature: 0.1 } - }); + } as any); const content = (response as { message?: { content?: string } })?.message?.content || ''; const jsonMatch = content.match(new RegExp('```json\\s*([\\s\\S]*?)\\s*```')) || content.match(/\{[\s\S]*"entries"[\s\S]*\}/); @@ -715,7 +716,7 @@ ${conversationText.slice(0, 4000)} importance: Math.min(10, Math.max(1, entry.importance || 5)), tags: entry.tags || [], source: '自动提取', - sessionId: currentSession?.id + sessionId: (currentSession as any)?.id }); count++; } diff --git a/src/renderer/services/skill-manager.ts b/src/renderer/services/skill-manager.ts index b8cf3f0..a2c56cb 100644 --- a/src/renderer/services/skill-manager.ts +++ b/src/renderer/services/skill-manager.ts @@ -64,7 +64,7 @@ export async function extractSkillsFromToolRecords( const chainNames = chain.map(s => s.name).join(' → '); // 检查是否已有完全相同的技能链 - const existingSkills: Skill[] = await bridge.db.getAllSkills(); + const existingSkills = await bridge.db.getAllSkills() as Skill[]; const duplicate = existingSkills.find(s => { try { const existingChain: ToolChainStep[] = JSON.parse(s.tool_chain); @@ -73,7 +73,29 @@ export async function extractSkillsFromToolRecords( }); if (duplicate) { - // 已有相同技能,不重复创建 + // v1.1: 重复链 → 更新参数提示(合并新知)+ 增加计数 + try { + const existingChain: ToolChainStep[] = JSON.parse(duplicate.tool_chain); + let updated = false; + for (let i = 0; i < Math.min(chain.length, existingChain.length); i++) { + const newArgs = chain[i].args_hint.split(',').map(a => a.trim()).filter(Boolean); + const oldArgs = existingChain[i].args_hint.split(',').map(a => a.trim()).filter(Boolean); + const merged = [...new Set([...oldArgs, ...newArgs])]; + const mergedHint = merged.join(', '); + if (mergedHint !== existingChain[i].args_hint) { + existingChain[i].args_hint = mergedHint; + updated = true; + } + } + if (updated) { + duplicate.tool_chain = JSON.stringify(existingChain); + duplicate.success_count++; + duplicate.updated_at = Date.now(); + await bridge.db.saveSkill(duplicate); + logInfo(`技能参数优化: ${duplicate.name}`, `合并了新的参数提示`); + return 1; + } + } catch { /* 更新失败不阻塞 */ } logInfo(`技能已存在,跳过提取: ${duplicate.name}`); return 0; } @@ -113,20 +135,37 @@ export async function extractSkillsFromToolRecords( } /** - * 匹配用户消息与已有技能 - * 返回匹配度最高的技能列表 + * 匹配用户消息与已有技能。 + * v1.1: 关键词 + 语义匹配融合,未使用技能衰减。 */ export async function matchSkills(userMessage: string, limit = 3): Promise { const bridge = window.metonaDesktop; if (!bridge?.db?.getAllSkills) return []; try { - const allSkills: Skill[] = await bridge.db.getAllSkills(); + const allSkills = await bridge.db.getAllSkills() as any as Skill[]; if (allSkills.length === 0) return []; const messageLower = userMessage.toLowerCase(); const messageWords = new Set(messageLower.split(/[\s,,。!?、;:""''()\[\]【】]+/).filter(w => w.length > 1)); + // ── 语义匹配(如果 embedding 模型可用)── + let userEmbedding: number[] | null = null; + try { + const memEnabled = state.get('memoryEnabled', false); + const embedModel = state.get('embeddingModel', ''); + if (memEnabled && embedModel) { + const api = state.get(KEYS.API); + if (api?.embed) { + const result = await api.embed(embedModel, userMessage); + userEmbedding = result.embedding || result.embeddings?.[0] || null; + } + } + } catch { /* 语义匹配失败回退关键词 */ } + + const now = Date.now(); + const THIRTY_DAYS = 30 * 24 * 3600 * 1000; + const scored = allSkills.map(skill => { let score = 0; @@ -134,24 +173,43 @@ export async function matchSkills(userMessage: string, limit = 3): Promise k.trim())); for (const kw of kwSet) { - if (kw && messageLower.includes(kw)) score += 0.4; + if (kw && messageLower.includes(kw)) score += 0.3; } } // 名称和描述匹配 const nameWords = skill.name.toLowerCase().split(/[\s→\-]+/); for (const w of nameWords) { - if (w.length > 1 && messageWords.has(w)) score += 0.2; + if (w.length > 1 && messageWords.has(w)) score += 0.15; } - // 成功率加成 + // 语义匹配:余弦相似度加权(主导分数) + if (userEmbedding && skill.summary) { + const skillEmbed = getCachedSkillEmbedding(skill.id); + if (skillEmbed) { + const sim = cosineSimilarity(userEmbedding, skillEmbed); + score += sim * 0.4; // 语义匹配主导 + } + } + + // 成功率加权 const total = skill.success_count + skill.fail_count; if (total > 0) score *= (0.5 + 0.5 * (skill.success_count / total)); + // ── v1.1 未使用技能衰减 ── + if (skill.last_used_at) { + const daysSinceUse = (now - skill.last_used_at) / (24 * 3600 * 1000); + if (daysSinceUse > 30) score *= 0.5; // 30天未用降权50% + if (daysSinceUse > 90) score *= 0.3; // 90天未用几乎忽略 + } + return { skill, score }; }); - return scored + // ── v1.1 技能链合并:相同前缀的链抽象为一个技能 ── + const merged = mergeSimilarSkills(scored); + + return merged .filter(s => s.score >= MATCH_THRESHOLD) .sort((a, b) => b.score - a.score) .slice(0, limit) @@ -161,6 +219,98 @@ export async function matchSkills(userMessage: string, limit = 3): Promise(); + +function getCachedSkillEmbedding(skillId: string): number[] | null { + const cached = _skillEmbedCache.get(skillId); + if (cached && Date.now() - cached.time < 300_000) return cached.embedding; // 5分钟有效 + return null; +} + +export function cacheSkillEmbedding(skillId: string, embedding: number[]): void { + _skillEmbedCache.set(skillId, { embedding, time: Date.now() }); + // 限制缓存大小 + if (_skillEmbedCache.size > 200) { + const oldest = [..._skillEmbedCache.entries()].sort((a, b) => a[1].time - b[1].time)[0]; + if (oldest) _skillEmbedCache.delete(oldest[0]); + } +} + +function cosineSimilarity(a: number[], b: number[]): number { + if (!a || !b || a.length !== b.length) return 0; + let dot = 0, normA = 0, normB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + const denom = Math.sqrt(normA) * Math.sqrt(normB); + return denom === 0 ? 0 : dot / denom; +} + +// ── 技能链合并 ── + +interface ScoredSkill { skill: Skill; score: number; } + +/** + * v1.1 技能链合并:两个以上技能有相同前缀(前 2 步相同), + * 合并为一个抽象技能,分数取最高。 + */ +function mergeSimilarSkills(scored: ScoredSkill[]): ScoredSkill[] { + if (scored.length < 2) return scored; + + const result: ScoredSkill[] = []; + const used = new Set(); + + for (let i = 0; i < scored.length; i++) { + if (used.has(i)) continue; + const group: ScoredSkill[] = [scored[i]]; + + // 找同前缀的 + for (let j = i + 1; j < scored.length; j++) { + if (used.has(j)) continue; + if (getChainPrefix(scored[i].skill) === getChainPrefix(scored[j].skill)) { + group.push(scored[j]); + used.add(j); + } + } + + if (group.length >= 2) { + // 合并为一个抽象技能 + const best = group.reduce((a, b) => a.score > b.score ? a : b); + const chainNames = group.map(g => { + try { return (JSON.parse(g.skill.tool_chain) as ToolChainStep[]).map(s => s.name).join('→'); } + catch { return g.skill.name; } + }); + const mergedSkill: Skill = { + ...best.skill, + name: `${getChainPrefix(best.skill)} → ...`, + description: `合并了 ${group.length} 个相似技能: ${chainNames.join(' | ')}`, + summary: best.skill.summary, + }; + result.push({ skill: mergedSkill, score: best.score * 1.1 }); // 合并后略加分 + used.add(i); + } else { + result.push(scored[i]); + } + } + + return result; +} + +/** 获取技能链的前 2 步前缀(用于相似度判断) */ +function getChainPrefix(skill: Skill): string { + try { + const chain: ToolChainStep[] = JSON.parse(skill.tool_chain); + return chain.slice(0, 2).map(s => s.name).join('→'); + } catch { + return skill.name; + } +} + /** * 从匹配的技能构建 system prompt 上下文(Level 0:仅名称 + 描述索引) * 完整工具链通过 skill_view 工具按需加载(Level 1) @@ -286,8 +436,8 @@ export async function listSkills(): Promise> { const bridge = window.metonaDesktop; if (!bridge?.db?.getAllSkills) return []; - const allSkills: Skill[] = await bridge.db.getAllSkills(); - return allSkills.map(skill => { + const allSkills: Skill[] = await bridge.db.getAllSkills() as any; + return (allSkills as any[]).map((skill: any) => { const total = skill.success_count + skill.fail_count; const rate = total > 0 ? Math.round(skill.success_count / total * 100) : 100; let chainPreview = ''; @@ -313,7 +463,7 @@ export async function viewSkill(skillName: string): Promise<{ }> { const bridge = window.metonaDesktop; if (!bridge?.db?.getAllSkills) return { success: false, error: '数据库不可用' }; - const allSkills: Skill[] = await bridge.db.getAllSkills(); + const allSkills: Skill[] = await bridge.db.getAllSkills() as any; const skill = allSkills.find(s => s.name.toLowerCase() === skillName.toLowerCase() || s.name.toLowerCase().includes(skillName.toLowerCase()) diff --git a/src/renderer/services/vector-memory.ts b/src/renderer/services/vector-memory.ts index d8782aa..75d14f7 100644 --- a/src/renderer/services/vector-memory.ts +++ b/src/renderer/services/vector-memory.ts @@ -6,7 +6,8 @@ import { state, KEYS } from '../state/state.js'; import { VectorStore } from './vector-store.js'; import { logMemory, logDebug, logError } from './log-service.js'; -import type { MemoryEntry, OllamaAPI, VectorItem, SearchResult } from '../types.js'; +import type { MemoryEntry, VectorItem, SearchResult } from '../types.js'; +import type { OllamaAPI } from '../api/ollama.js'; const MEMORY_COLLECTION_NAME = '记忆向量索引'; diff --git a/src/renderer/services/vector-store.ts b/src/renderer/services/vector-store.ts index 1fe99ff..3e88ef7 100644 --- a/src/renderer/services/vector-store.ts +++ b/src/renderer/services/vector-store.ts @@ -228,7 +228,7 @@ export class VectorStore { if (saved && saved.version === this._indexVersion(vectors)) { if (!(saved.invertedLists instanceof Map)) { - saved.invertedLists = new Map(Object.entries(saved.invertedListsObj || {})); + saved.invertedLists = new Map(Object.entries(saved.invertedListsObj || {}).map(([k, v]) => [parseInt(k), v as string[]])); } this._indexCache.set(colId, saved); return saved; diff --git a/src/renderer/state/state.ts b/src/renderer/state/state.ts index 9d81434..23d3a00 100644 --- a/src/renderer/state/state.ts +++ b/src/renderer/state/state.ts @@ -2,7 +2,9 @@ * State - 轻量级响应式状态管理 */ -import type { StateKey, ChatSession, ChatDB, OllamaAPI } from '../types.js'; +import type { StateKey, ChatSession } from '../types.js'; +import type { ChatDB } from '../db/chat-db.js'; +import type { OllamaAPI } from '../api/ollama.js'; function shallowEqual(a: unknown, b: unknown): boolean { if (a === b) return true; @@ -45,7 +47,7 @@ class AppState { } } - update(key: string, updater: (old: unknown) => unknown): void { + update(key: string, updater: (old: any) => any): void { const old = this._state[key]; const value = updater(old); this._state[key] = value; diff --git a/src/renderer/styles/style.css b/src/renderer/styles/style.css index cb54332..befe1d3 100644 --- a/src/renderer/styles/style.css +++ b/src/renderer/styles/style.css @@ -900,6 +900,75 @@ html, body { box-shadow: none; } +/* ── 系统提示词折叠卡片 ── */ +.system-prompt-card { + margin-bottom: 10px; + border: 1px solid rgba(155, 126, 216, 0.12); + border-radius: var(--radius-md); + overflow: hidden; + background: rgba(155, 126, 216, 0.03); +} + +.system-prompt-header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + cursor: pointer; + user-select: none; + font-size: 12px; + color: var(--text-tertiary); + transition: background 0.15s; +} +.system-prompt-header:hover { + background: rgba(155, 126, 216, 0.06); +} + +.sp-icon { font-size: 14px; flex-shrink: 0; } +.sp-title { font-weight: 600; color: #9B7ED8; white-space: nowrap; } +.sp-tokens { + font-size: 10px; + color: var(--text-tertiary); + background: var(--bg-layer); + padding: 1px 6px; + border-radius: 10px; + white-space: nowrap; +} +.sp-preview { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + color: var(--text-tertiary); +} +.sp-chevron { + flex-shrink: 0; + transition: transform 0.2s; + color: var(--text-tertiary); +} +.sp-chevron.sp-expanded { transform: rotate(180deg); } + +.system-prompt-body { + border-top: 1px solid rgba(155, 126, 216, 0.08); + max-height: 300px; + overflow-y: auto; +} +.system-prompt-body pre { + margin: 0; + padding: 10px 14px; + font-size: 11px; + line-height: 1.5; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + background: none; + border: none; + box-shadow: none; + font-family: var(--font); +} + /* 打字光标 */ .typing-cursor { display: inline-block; diff --git a/src/renderer/types.d.ts b/src/renderer/types.d.ts index 6ef1501..8109612 100644 --- a/src/renderer/types.d.ts +++ b/src/renderer/types.d.ts @@ -51,6 +51,7 @@ export interface OllamaModelInfo { export interface OllamaModelDetail { capabilities?: string[]; + model_info?: Record; [key: string]: unknown; } @@ -59,6 +60,7 @@ export interface ModelCaps { vision: boolean; tools: boolean; completion: boolean; + contextLength: number; } export interface OllamaVersionResponse { @@ -111,6 +113,8 @@ export interface ChatMessage { toolCalls?: ToolCallRecord[]; /** 标记此消息为 LLM 压缩摘要生成 */ compressed?: boolean; + /** 实际发送给模型的完整系统提示词(调试用) */ + _systemPrompt?: string; } export interface ChatSession {