diff --git a/electron/harness/prompts/context-builder.ts b/electron/harness/prompts/context-builder.ts index 0b1bde6..a0aa191 100644 --- a/electron/harness/prompts/context-builder.ts +++ b/electron/harness/prompts/context-builder.ts @@ -25,6 +25,7 @@ interface ContextBuildParams { history?: MetonaMessage[]; memories?: MetonaMemoryItem[]; workspaceFiles?: WorkspaceFiles; + workspacePath?: string; contextWindow?: number; } @@ -43,10 +44,11 @@ export class ContextBuilder { history = [], memories = [], workspaceFiles, + workspacePath, contextWindow = 128_000, } = params; - const systemPrompt = this.buildSystemPrompt(workspaceFiles); + const systemPrompt = this.buildSystemPrompt(workspaceFiles, workspacePath); const estimatedTokens = this.estimateTokens(history, memories, availableTools, userInput, systemPrompt); return { @@ -76,7 +78,7 @@ export class ContextBuilder { * 4. MEMORY.md(记忆)— 动态区 * 5. 内置安全准则 — 尾部锚定 */ - buildSystemPrompt(workspaceFiles?: WorkspaceFiles): MetonaSystemPrompt { + buildSystemPrompt(workspaceFiles?: WorkspaceFiles, workspacePath?: string): MetonaSystemPrompt { let staticUserProfile = ''; // ===== 静态区:用户画像(变化不频繁,放入静态区利用 LLM 缓存)===== @@ -99,6 +101,11 @@ export class ContextBuilder { // ===== 动态区:记忆 ===== const dynamicParts: string[] = []; + // 注入当前工作空间路径(动态区,路径可能切换故不放入静态区) + if (workspacePath) { + dynamicParts.push(`## Current Workspace\nWorkspace root path: \`${workspacePath}\`\n\nAll relative paths in tool calls are resolved against this workspace root. Use this path when absolute paths are required (e.g., in run_command).`); + } + if (workspaceFiles?.memory) { const memoryContent = this.extractContent(workspaceFiles.memory); if (memoryContent) { diff --git a/electron/harness/tools/built-in/command.ts b/electron/harness/tools/built-in/command.ts index e91eecc..f06dc3e 100644 --- a/electron/harness/tools/built-in/command.ts +++ b/electron/harness/tools/built-in/command.ts @@ -24,6 +24,26 @@ import type { SandboxManager } from '../../sandbox/sandbox'; const execAsync = promisify(exec); +// ===== Windows 编码智能解码 ===== +// 某些 Windows 程序(dir、systeminfo、ipconfig 等)不响应 chcp 65001, +// 仍按系统代码页(GBK/CP936)输出字节流。 +// 策略:先尝试严格 UTF-8 解码,失败则用 GBK 解码,最后降级为 UTF-8 宽松模式。 +function decodeBuffer(buf: Buffer): string { + if (buf.length === 0) return ''; + try { + // 严格模式:包含非法 UTF-8 字节序列时抛错 + return new TextDecoder('utf-8', { fatal: true }).decode(buf); + } catch { + // UTF-8 解码失败 → 用 GBK/CP936 解码(Windows 中文系统默认代码页) + try { + return new TextDecoder('gbk').decode(buf); + } catch { + // 最终降级:UTF-8 宽松模式(用 替换字符 代替非法字节) + return buf.toString('utf-8'); + } + } +} + // ===== 9. run_command ===== export class RunCommandTool implements IMetonaTool { @@ -100,10 +120,13 @@ export class RunCommandTool implements IMetonaTool { ? `chcp 65001 >nul 2>&1 && ${command}` : command; + // 使用 encoding: 'buffer' 获取原始字节,手动智能解码 + // 部分程序(如 dir、systeminfo)不响应 chcp 65001,仍输出 GBK 字节流 const { stdout, stderr } = await execAsync(finalCommand, { cwd: resolvedWorkdir, timeout, maxBuffer: 1024 * 1024, // 1MB + encoding: 'buffer', // 返回 Buffer 而非字符串,便于智能解码 env: { ...process.env, NODE_ENV: 'production', @@ -118,18 +141,18 @@ export class RunCommandTool implements IMetonaTool { return { success: true, - stdout: stdout.slice(0, 50_000), - stderr: stderr.slice(0, 10_000), + stdout: decodeBuffer(stdout).slice(0, 50_000), + stderr: decodeBuffer(stderr).slice(0, 10_000), command, }; } catch (error) { - const err = error as { message: string; code?: number; stdout?: string; stderr?: string }; + const err = error as { message: string; code?: number; stdout?: Buffer; stderr?: Buffer }; return { success: false, error: err.message, exit_code: err.code, - stdout: (err.stdout ?? '').slice(0, 10_000), - stderr: (err.stderr ?? '').slice(0, 10_000), + stdout: decodeBuffer(err.stdout ?? Buffer.alloc(0)).slice(0, 10_000), + stderr: decodeBuffer(err.stderr ?? Buffer.alloc(0)).slice(0, 10_000), command, }; } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 74be754..d6b1b79 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -44,7 +44,7 @@ export function registerAllIPCHandlers( sessionRecorder: SessionRecorder, memoryManager: MemoryManager, mcpManager: MCPManager, - reloadAdapter: () => void, + reloadAdapter: () => boolean, promptInjectionDefender: PromptInjectionDefender, outputValidator: OutputValidator, confirmationHook: ConfirmationHook, @@ -56,8 +56,29 @@ export function registerAllIPCHandlers( ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => { log.info('[AGENT] sendMessage:', sessionId, userMessage.content.slice(0, 80)); - // 发送消息前确保 Adapter 使用最新配置(防止 config:set 中间状态导致 reload 失败) - reloadAdapter(); + // 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送) + if (!reloadAdapter()) { + const errorMsg = 'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)'; + log.error('[AGENT]', errorMsg); + if (!mainWindow.isDestroyed()) { + const errorEvent: MetonaStreamEvent = { + type: MetonaStreamEventType.ERROR, + requestId: '', + sessionId, + iteration: 0, + seq: 0, + timestamp: Date.now(), + error: { code: MetonaErrorCode.UNKNOWN, message: errorMsg, retryable: false }, + }; + mainWindow.webContents.send('agent:streamEvent', errorEvent); + mainWindow.webContents.send('agent:streamEvent', { + ...errorEvent, + type: MetonaStreamEventType.DONE, + }); + } + sessionRecorder.stopRecording({ totalIterations: 0, totalTokens: 0, durationMs: 0, terminationReason: 'error' }); + return { success: false, error: errorMsg }; + } // TRACE 层:开始录制 sessionRecorder.startRecording(sessionId); @@ -88,9 +109,9 @@ export function registerAllIPCHandlers( timestamp: m.timestamp, })); - // 从工作空间文件构建 System Prompt + // 从工作空间文件构建 System Prompt(注入当前工作空间路径,便于 LLM 在需要绝对路径时使用) const workspaceFiles = workspaceService.getFiles(); - const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles); + const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles, workspaceService.getPath()); // 检索与用户消息相关的记忆,注入到 System Prompt 动态区 try { diff --git a/electron/main.ts b/electron/main.ts index f7b950d..e074654 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -261,7 +261,7 @@ async function initialize(): Promise { // ===== 热重载 Adapter 回调(设置变更时触发)===== let lastProvider = configService.get('llm.provider') ?? ''; - const reloadAdapter = () => { + const reloadAdapter = (): boolean => { try { const newAdapter = createAdapter(); agentLoop.setAdapter(newAdapter); @@ -282,6 +282,7 @@ async function initialize(): Promise { from: lastProvider, to: provider, reason: 'config_changed', + sessionId: '', }); } mainWindow.webContents.send('toast:show', { @@ -290,6 +291,7 @@ async function initialize(): Promise { }); } lastProvider = provider; + return true; } catch (err) { log.error(`[CONFIG] Failed to reload adapter: ${(err as Error).message}`); // 通知渲染进程 Provider 切换失败(UI 显示错误 Toast) @@ -299,6 +301,7 @@ async function initialize(): Promise { message: `Provider 切换失败: ${(err as Error).message}`, }); } + return false; } }; diff --git a/package-lock.json b/package-lock.json index 89f4f5d..20e6953 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index 690f0e8..c9767cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.2.1", + "version": "0.2.2", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", diff --git a/src/components/chat/AssistantMessage.tsx b/src/components/chat/AssistantMessage.tsx index 4965425..be6bf02 100644 --- a/src/components/chat/AssistantMessage.tsx +++ b/src/components/chat/AssistantMessage.tsx @@ -169,7 +169,7 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac return ( - + {language} @@ -177,7 +177,7 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac - + {code} diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 748eafb..b05b6e0 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogTitle, Button, TextField, Select, MenuItem import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen, Globe } from 'lucide-react'; import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store'; import { useAgentStore } from '@renderer/stores/agent-store'; +import { PROVIDER_LABELS } from '@renderer/lib/constants'; type SettingsTab = 'workspace' | 'llm' | 'agent' | 'tools' | 'mcp' | 'searxng' | 'appearance' | 'logs'; const TABS: { id: SettingsTab; label: string; icon: typeof Settings }[] = [ @@ -118,11 +119,13 @@ function LLMSettings() { } }, [provider, numCtx]); - // 切换 Provider 时自动填充默认 URL + 清空 Ollama 的 apiKey + // 切换 Provider 时自动填充默认 URL + 清空 apiKey(不同 Provider 的 key 不通用) const handleProviderChange = (newProvider: string) => { + const oldProvider = provider; setProvider(newProvider); - // Ollama 不需要 API Key,切换到 Ollama 时清空旧 key - if (newProvider === 'ollama' && apiKey) { + // 切换 Provider 时清空 apiKey(不同 Provider 的 key 格式不同,避免用旧 key 调用新 API 导致 401) + // Ollama 不需要 apiKey,也一并清空 + if (oldProvider !== newProvider && apiKey) { setApiKey(''); } // 自动填充默认 URL(仅在 URL 为空或与旧 provider 默认 URL 匹配时) @@ -146,9 +149,16 @@ function LLMSettings() { setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" /> setModel(e.target.value)} placeholder="如 deepseek-v4-pro、qwen3:latest" /> {provider !== 'ollama' && ( - setApiKey(e.target.value)} placeholder="sk-..." - slotProps={{ input: { endAdornment: setShowKey(!showKey)}>{showKey ? : } } }} - /> + <> + setApiKey(e.target.value)} placeholder="sk-..." + slotProps={{ input: { endAdornment: setShowKey(!showKey)}>{showKey ? : } } }} + /> + {!apiKey && ( + + ⚠ 请输入 {PROVIDER_LABELS[provider] ?? provider} 的 API Key,否则发送消息会报错 + + )} + )} {provider === 'ollama' && ( ) : ( autoExecToolDetails.map((t) => ( - + - {t.name} + {t.name}