diff --git a/README.md b/README.md index 1c17c03..8d074c3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript license @@ -177,13 +177,13 @@ main.ts (入口) ├── chat-area.ts # 聊天消息区域 ├── input-area.ts # 输入框 + 文件上传 ├── workspace-panel.ts # 终端 + 文件浏览器 - ├── log-panel.ts # 结构化日志面板 ├── settings-modal.ts # 设置面板 ├── history-modal.ts # 会话历史 ├── memory-modal.ts # 记忆管理 ├── tools-modal.ts # 工具列表 ├── token-dashboard.ts # Token 消耗仪表盘 ├── tool-confirm-modal.ts # 工具执行确认 + ├── prompt-modal.ts # 系统提示词查看 ├── toast.ts # Toast 通知 └── lightbox.ts # 图片灯箱 ``` @@ -231,7 +231,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -产出:`release/Metona Ollama Setup v0.9.0.exe` +产出:`release/Metona Ollama Setup v0.9.1.exe` ## 🛠️ 常用命令 @@ -400,13 +400,13 @@ main.ts (Entry) ├── chat-area.ts # Chat message area ├── input-area.ts # Input box + file upload ├── workspace-panel.ts # Terminal + file browser - ├── log-panel.ts # Structured log panel ├── settings-modal.ts # Settings panel ├── history-modal.ts # Session history ├── memory-modal.ts # Memory management ├── tools-modal.ts # Tool list ├── token-dashboard.ts # Token consumption dashboard ├── tool-confirm-modal.ts # Tool execution confirmation + ├── prompt-modal.ts # System prompt viewer ├── toast.ts # Toast notifications └── lightbox.ts # Image lightbox ``` @@ -454,7 +454,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v0.9.0.exe` +Output: `release/Metona Ollama Setup v0.9.1.exe` ## 🛠️ Common Commands diff --git a/docs/BUILD.md b/docs/BUILD.md index 28138e7..84b0546 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -14,7 +14,6 @@ # 1. 克隆并安装依赖 git clone https://gitee.com/thzxx/metona-ollama-desktop.git cd metona-ollama-desktop -git checkout desktop-v1.1.0 npm config set registry https://registry.npmmirror.com npm install @@ -92,9 +91,9 @@ npm run dist # NSIS 安装包 | 文件 | 说明 | |------|------| -| `release/Metona Ollama Setup v1.1.0.exe` | NSIS 安装包(可选目录、创建快捷方式) | +| `release/Metona Ollama Setup v0.9.1.exe` | NSIS 安装包(可选目录、创建快捷方式) | -> v3.2.0 起不再提供绿色便携版,仅保留 NSIS 安装包。 +> 仅提供 NSIS 安装包。 ## 发布到 Gitee @@ -102,18 +101,18 @@ npm run dist # NSIS 安装包 ```bash TOKEN="your_access_token" -curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama/releases?access_token=$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":"v3.2.0","name":"v3.2.0","body":"Release notes","target_commitish":"refactor/modular-architecture"}' + -d '{"tag_name":"v0.9.1","name":"v0.9.1","body":"Release notes","target_commitish":"master"}' ``` ### 上传附件 ```bash TOKEN="your_access_token" -RELEASE_ID="600564" -curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama/releases/$RELEASE_ID/attach_files?access_token=$TOKEN" \ - -H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup 3.2.0.exe" +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.9.1.exe" ``` ## 故障排查 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 407bfc5..aa8b5a0 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,6 +1,6 @@ # Metona Ollama Desktop — 开发规范 -> 版本: v1.1.0 | 更新: 2026-04-24 | 维护: 项目团队 +> 版本: v0.9.1 | 更新: 2026-06-05 | 维护: 项目团队 --- @@ -60,9 +60,7 @@ src/ │ │ ├── context-manager.ts # 上下文窗口管理 │ │ ├── skill-manager.ts # 技能自动生成(Level 0) │ │ ├── sub-agent.ts # 子代理委派 -│ │ ├── cron-manager.ts # 定时任务 │ │ ├── mcp-client.ts # MCP 渲染端客户端 -│ │ ├── document-processor.ts # 文档分块 │ │ ├── log-service.ts # 结构化日志 │ │ └── crypto.ts # AES-256-GCM 加密 │ ├── utils/ @@ -154,10 +152,10 @@ import { ## 五、架构规范 -### 5.1 四大子系统 +### 5.1 五大子系统 ``` -① Agent 系统 → agent-engine.ts + tool-registry.ts(38 工具 + MCP 动态) +① Agent 系统 → agent-engine.ts + tool-registry.ts(38 内置工具 + MCP 动态) ② 记忆系统 → memory-manager.ts + vector-memory.ts ③ 向量存储 → vector-store.ts(IVF 索引) ④ 工作空间 → workspace.ts (主进程) + workspace-panel.ts (渲染进程) @@ -194,7 +192,7 @@ import { ### 5.4 联网搜索联动 - web_search 返回搜索结果(标题、URL、摘要),默认 15 条 -- TOOL_USAGE_GUIDE 要求模型在 web_search 后必须选择相关 URL 调用 web_fetch 抓取详情 +- 模型在 web_search 后应选择相关 URL 调用 web_fetch 抓取详情 - web_fetch 默认返回完整内容(`max_chars=0`),不截断 ### 5.5 工作空间与 AI 集成 @@ -258,23 +256,17 @@ npm run dist # 构建 Windows 安装包 | `package.json` | `"version": "X.Y.Z"` | | `package-lock.json` | 顶层 version 字段(2 处) | | `src/renderer/index.html` | `vX.Y.Z` | -| `README.md` | 版本徽章、下载文件名、构建分支引用 | -| `docs/BUILD.md` | 构建分支引用、新增构建日志条目 | -| `docs/CHANGELOG.md` | 版本分支结构链、新增版本说明条目 | +| `README.md` | 版本徽章、下载文件名 | +| `src/main/menu.ts` | 关于对话框版本号 | +| `docs/BUILD.md` | 构建产物文件名、Gitee release tag | | `docs/DEVELOPMENT.md` | 文件头部版本号 | -### 更新日志 - -- **所有更新日志只写在 `docs/CHANGELOG.md`** -- `README.md` 不写历史版本记录,只保留一行链接指向 `docs/CHANGELOG.md` - ### 7.2 发布流程 1. 更新版本号(同步上述文件) -2. 更新 `docs/CHANGELOG.md` -3. 构建并测试:`npm start` -4. 构建安装包:`npm run dist` -5. 推送到 develop → 合并到 master → 创建版本分支 +2. 构建并测试:`npm start` +3. 构建安装包:`npm run dist` +4. 推送到 master --- @@ -282,11 +274,7 @@ npm run dist # 构建 Windows 安装包 ### 分支模型 -- `master` — 稳定发布版本 -- `develop` — 开发主分支 -- `metona-ollama-desktop-vX.Y.Z` — 版本发布分支 -- `feature/*` — 功能开发 -- `fix/*` — Bug 修复 +- `master` — 主分支 ### Commit 规范 diff --git a/package-lock.json b/package-lock.json index 775dcaf..73f5ac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "1.1.0", + "version": "0.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "1.1.0", + "version": "0.9.1", "license": "MIT", "dependencies": { "sql.js": "^1.11.0" diff --git a/package.json b/package.json index 8b525c9..a5c38ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.9.0", + "version": "0.9.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.9.0.${ext}", + "artifactName": "Metona Ollama Setup v0.9.1.${ext}", "oneClick": false, "allowToChangeInstallationDirectory": true, "createDesktopShortcut": true, diff --git a/src/main/menu.ts b/src/main/menu.ts index d66cdf5..dbc0322 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.9.0', + message: 'Metona Ollama Desktop v0.9.1', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', icon: getIconPath() }); diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index d59faa9..e062ad9 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -133,28 +133,90 @@ function autoResizeTextarea(): void { chatInputEl.style.height = Math.min(chatInputEl.scrollHeight, 120) + 'px'; } +// ── 图片压缩管线 ── +const IMG_MAX_WIDTH = 1280; +const IMG_MAX_HEIGHT = 1280; +const IMG_QUALITY = 0.8; +const IMG_MAX_SIZE_BEFORE_COMPRESS = 300 * 1024; // 300KB以下不压缩 + +/** + * Canvas 前端压缩图片:等比缩放 + JPEG 转换 + * base64 → Image → Canvas 缩放绘制 → toBlob → 压缩后 base64 + */ +function compressImage(base64: string): Promise<{ compressed: string; origBytes: number; newBytes: number }> { + return new Promise((resolve, reject) => { + // 先计算原始 base64 的大小(字节数 ≈ base64长度 * 0.75) + const origBytes = Math.ceil(base64.length * 0.75); + // 小图跳过压缩 + if (origBytes <= IMG_MAX_SIZE_BEFORE_COMPRESS) { + resolve({ compressed: base64, origBytes, newBytes: origBytes }); + return; + } + + const img = new Image(); + img.onload = () => { + let { width, height } = img; + // 等比缩放 + if (width > IMG_MAX_WIDTH || height > IMG_MAX_HEIGHT) { + const ratio = Math.min(IMG_MAX_WIDTH / width, IMG_MAX_HEIGHT / height); + width = Math.round(width * ratio); + height = Math.round(height * ratio); + } + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d')!; + ctx.drawImage(img, 0, 0, width, height); + canvas.toBlob((blob) => { + if (!blob) { resolve({ compressed: base64, origBytes, newBytes: origBytes }); return; } + const reader = new FileReader(); + let newBytes = 0; + reader.onloadend = () => { + const compressed = (reader.result as string).split(',')[1] || base64; + newBytes = blob.size; + resolve({ compressed, origBytes, newBytes }); + }; + reader.onerror = () => { resolve({ compressed: base64, origBytes, newBytes: origBytes }); }; + reader.readAsDataURL(blob); + }, 'image/jpeg', IMG_QUALITY); + }; + img.onerror = () => resolve({ compressed: base64, origBytes, newBytes: origBytes }); + img.src = `data:image/png;base64,${base64}`; + }); +} + async function handleImagePaths(paths: string[]): Promise { const bridge = getBridge(); const maxSize = 20 * 1024 * 1024; - for (const filePath of paths) { + + // 并行读取所有图片 + const results = await Promise.all(paths.map(async (filePath) => { const name = filePath.split(/[/\\]/).pop() || filePath; try { const result = await bridge!.fs.readFileBase64(filePath); - if (!result.success) { - appendSystemMessage(`❌ 读取 ${name} 失败: ${result.error}`); - continue; - } + if (!result.success) return { name, error: result.error }; const size = result.size ?? 0; - if (size > maxSize) { - appendSystemMessage(`⚠️ 图片 ${name} 超过 20MB 限制`); - continue; - } - const base64 = result.content!; - pendingImages.push({ name, base64 }); - logInfo(`图片已添加: ${name}`, formatSize(size)); + if (size > maxSize) return { name, error: `超过 20MB 限制` }; + return { name, base64: result.content!, size }; } catch (err) { - logError('图片读取失败', (err as Error).message); - appendSystemMessage(`❌ 读取 ${name} 失败`); + return { name, error: (err as Error).message }; + } + })); + + for (const r of results) { + if ('error' in r && r.error) { + appendSystemMessage(`❌ 读取 ${r.name} 失败: ${r.error}`); + continue; + } + if ('base64' in r) { + const imgResult = r as { name: string; base64: string; size: number }; + const { compressed, origBytes, newBytes } = await compressImage(imgResult.base64); + pendingImages.push({ name: imgResult.name, base64: compressed }); + if (origBytes !== newBytes) { + logInfo(`图片已压缩: ${r.name}`, `${formatSize(origBytes)} → ${formatSize(newBytes)} (${Math.round((1 - newBytes / origBytes) * 100)}%)`); + } else { + logInfo(`图片已添加: ${r.name}`, formatSize(origBytes)); + } } } renderImagePreviews(); @@ -177,36 +239,40 @@ function renderImagePreviews(): void { async function handleTextFilePaths(paths: string[]): Promise { const bridge = getBridge(); - for (const filePath of paths) { + + // 并行读取所有文件 + const results = await Promise.all(paths.map(async (filePath) => { const name = filePath.split(/[/\\]/).pop() || filePath; - if (!isTextFile(name)) { - appendSystemMessage(`⚠️ ${name} 不是支持的文本/代码文件`); - continue; - } + if (!isTextFile(name)) return { name, error: `不是支持的文本/代码文件` }; try { const result = await bridge!.fs.readFile(filePath); - if (!result.success) { - appendSystemMessage(`❌ 读取 ${name} 失败: ${result.error}`); - continue; - } + if (!result.success) return { name, error: result.error }; const content = result.content as string; const size = new TextEncoder().encode(content).length; - if (size > MAX_FILE_SIZE) { - appendSystemMessage(`⚠️ ${name} 超过 500KB 限制(${formatSize(size)})`); - continue; - } - if (pendingFiles.some(f => f.name === name)) { - appendSystemMessage(`ℹ️ ${name} 已添加`); - continue; - } - pendingFiles.push({ name, content, language: detectLanguage(name), size }); - logInfo(`文件已添加: ${name}`, formatSize(size)); - renderFilePreviews(); + if (size > MAX_FILE_SIZE) return { name, error: `超过 500KB 限制(${formatSize(size)})` }; + if (pendingFiles.some(f => f.name === name)) return { name, skipped: true }; + return { name, content, language: detectLanguage(name), size }; } catch (err) { - logError('文件读取失败', (err as Error).message); - appendSystemMessage(`❌ 读取 ${name} 失败`); + return { name, error: (err as Error).message }; + } + })); + + for (const r of results) { + if ('skipped' in r) { + appendSystemMessage(`ℹ️ ${r.name} 已添加`); + continue; + } + if ('error' in r && r.error) { + appendSystemMessage(`❌ 读取 ${r.name} 失败: ${r.error}`); + continue; + } + if ('content' in r) { + const fileResult = r as { name: string; content: string; language: string; size: number }; + pendingFiles.push({ name: fileResult.name, content: fileResult.content, language: fileResult.language, size: fileResult.size }); + logInfo(`文件已添加: ${fileResult.name}`, formatSize(fileResult.size)); } } + renderFilePreviews(); } function renderFilePreviews(): void { @@ -283,7 +349,7 @@ async function handleRetry(): Promise { .map(m => { let content = m.content || ''; if (m._fileContents?.length) { - const fileParts = m._fileContents.map(f => `📄 文件:\n\`\`\`${f.language}\n${f.content}\n\`\`\``); + const fileParts = buildFileContentParts(m._fileContents); content = content ? content + '\n\n---\n' + fileParts.join('\n\n---\n') : fileParts.join('\n\n---\n'); } return { role: m.role, content, ...(m.images?.length && { images: m.images }) }; @@ -539,14 +605,117 @@ function stopGeneration(): void { if (abortController) abortController.abort(); } +/** Token 预算比例:文件内容最多占用上下文窗口的比例 */ +const FILE_TOKEN_BUDGET_RATIO = 0.3; + +// ── 注释剥离 ── + +interface CommentRule { single: RegExp | null; multi: [RegExp, string] | null } + +const COMMENT_RULES: Record = { + javascript: { single: /\/\/.*$/gm, multi: [/\/\*[\s\S]*?\*\//g, ''] }, + typescript: { single: /\/\/.*$/gm, multi: [/\/\*[\s\S]*?\*\//g, ''] }, + tsx: { single: /\/\/.*$/gm, multi: [/\/\*[\s\S]*?\*\//g, ''] }, + jsx: { single: /\/\/.*$/gm, multi: [/\/\*[\s\S]*?\*\//g, ''] }, + java: { single: /\/\/.*$/gm, multi: [/\/\*[\s\S]*?\*\//g, ''] }, + c: { single: /\/\/.*$/gm, multi: [/\/\*[\s\S]*?\*\//g, ''] }, + cpp: { single: /\/\/.*$/gm, multi: [/\/\*[\s\S]*?\*\//g, ''] }, + csharp: { single: /\/\/.*$/gm, multi: [/\/\*[\s\S]*?\*\//g, ''] }, + go: { single: /\/\/.*$/gm, multi: [/\/\*[\s\S]*?\*\//g, ''] }, + rust: { single: /\/\/.*$/gm, multi: [/\/\*[\s\S]*?\*\//g, ''] }, + swift: { single: /\/\/.*$/gm, multi: [/\/\*[\s\S]*?\*\//g, ''] }, + kotlin: { single: /\/\/.*$/gm, multi: [/\/\*[\s\S]*?\*\//g, ''] }, + scala: { single: /\/\/.*$/gm, multi: [/\/\*[\s\S]*?\*\//g, ''] }, + css: { single: null, multi: [/\/\*[\s\S]*?\*\//g, ''] }, + python: { single: /#.*$/gm, multi: [/('''[\s\S]*?'''|\"\"\"[\s\S]*?\"\"\")/g, ''] }, + ruby: { single: /#.*$/gm, multi: null }, + perl: { single: /#.*$/gm, multi: null }, + shell: { single: /#.*$/gm, multi: null }, + bash: { single: /#.*$/gm, multi: null }, + r: { single: /#.*$/gm, multi: null }, + yaml: { single: /#.*$/gm, multi: null }, + sql: { single: /--.*$/gm, multi: [/\/\*[\s\S]*?\*\//g, ''] }, + lua: { single: /--.*$/gm, multi: [/--\[\[[\s\S]*?\]\]/g, ''] }, + haskell: { single: /--.*$/gm, multi: [/\{-[\s\S]*?-\}/g, ''] }, + html: { single: null, multi: [//g, ''] }, + xml: { single: null, multi: [//g, ''] }, + markdown: { single: null, multi: [//g, ''] }, +}; + +function stripComments(content: string, language: string): string { + const rule = COMMENT_RULES[language]; + if (!rule) return content; + + let result = content; + if (rule.single) result = result.replace(rule.single, ''); + if (rule.multi) result = result.replace(rule.multi[0], rule.multi[1]); + + // 压缩连续空白行:>2个连续换行 → 2个换行 + result = result.replace(/\n{3,}/g, '\n\n'); + return result; +} + +/** + * 构建文件内容 Parts,带 token 预算感知 + * - 小文件直接嵌入,大文件按预算截断 + * - 代码文件自动剥离注释 + */ +function buildFileContentParts(fileContents: Array<{ language: string; content: string }>): string[] { + const numCtx = state.get(KEYS.NUM_CTX, 24576); + const fileBudget = Math.floor(numCtx * FILE_TOKEN_BUDGET_RATIO); + let usedTokens = 0; + const maxSingleFileTokens = Math.floor(fileBudget * 0.5); // 单文件不超过预算的50% + + return fileContents.map((f, i) => { + const lang = f.language || ''; + const rawTokens = estimateTokens(f.content); + const total = fileContents.length; + + // 文件足够小,直接全量(不剥离注释,保持完整性) + if (rawTokens <= 500 && usedTokens + rawTokens <= fileBudget) { + usedTokens += rawTokens; + return `📄 文件 ${i + 1}/${total}:\n\n\`\`\`${lang}\n${f.content}\n\`\`\``; + } + + // 大文件:剥离注释 + token 预算截断 + const remaining = fileBudget - usedTokens; + if (remaining <= 0) { + return `⚠️ 文件 ${i + 1}/${total} 因 token 预算耗尽被跳过 (${f.content.length} 字符)`; + } + + let text = stripComments(f.content, lang); + let truncated = false; + + // 如果剥离注释后仍然超预算,截断到剩余预算 + const strippedTokens = estimateTokens(text); + if (strippedTokens > maxSingleFileTokens) { + // 按比例截取字符:预算 / 估算tokens * 长度 + const ratio = Math.min(1, maxSingleFileTokens / strippedTokens); + const cutAt = Math.floor(text.length * ratio); + text = text.slice(0, cutAt); + truncated = true; + } + + const finalTokens = estimateTokens(text); + if (usedTokens + finalTokens > fileBudget) { + const ratio = Math.min(1, remaining / finalTokens); + const cutAt = Math.floor(text.length * ratio); + text = text.slice(0, cutAt); + truncated = true; + } + + usedTokens += estimateTokens(text); + const label = strippedTokens !== rawTokens ? ' (已剥离注释)' : ''; + const truncLabel = truncated ? ' [已截断]' : ''; + return `📄 文件 ${i + 1}/${total}${label}${truncLabel}:\n\n\`\`\`${lang}\n${text}\n\`\`\``; + }); +} + function buildApiMessages(messages: ChatMessage[]): Array<{ role: string; content: string; images?: string[] }> { return messages.map(m => { let content = m.content || ''; if (m._fileContents && m._fileContents.length > 0) { - const fileParts = m._fileContents.map(f => { - const lang = f.language || ''; - return `📄 以下是一个文件内容:\n\n\`\`\`${lang}\n${f.content}\n\`\`\``; - }); + const fileParts = buildFileContentParts(m._fileContents); if (content) { content += '\n\n---\n' + fileParts.join('\n\n---\n'); } else { @@ -1015,7 +1184,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio .map(m => { let content = m.content || ''; if (m._fileContents && m._fileContents.length > 0) { - const fileParts = m._fileContents.map(f => `📄 文件:\n\`\`\`${f.language}\n${f.content}\n\`\`\``); + const fileParts = buildFileContentParts(m._fileContents); content = content ? content + '\n\n---\n' + fileParts.join('\n\n---\n') : fileParts.join('\n\n---\n'); } return { diff --git a/src/renderer/index.html b/src/renderer/index.html index 05475db..2a0344e 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@

Metona Ollama - v0.9.0 + v0.9.1