feat: 升级至 v0.2.2 — 工作空间路径注入、Provider 切换修复、编码与样式优化

功能增强:
- System Prompt 注入当前工作空间路径到动态区(LLM 可使用绝对路径调用工具)
- ContextBuilder.buildSystemPrompt 新增 workspacePath 参数

Provider 切换修复:
- reloadAdapter 返回 boolean,失败时 sendMessage 中止发送
- 切换 Provider 时自动清空 apiKey(不同 Provider key 不通用)
- apiKey 为空时显示警告提示
- Provider 切换事件加 sessionId 字段

编码修复:
- run_command 使用 encoding: 'buffer' 获取原始字节
- 新增 decodeBuffer 智能解码:UTF-8 严格 → GBK → UTF-8 宽松
- 修复不响应 chcp 的 Windows 命令中文乱码问题

样式优化:
- 代码块底色改用主题变量(灰底主题色文字,双主题适配)
- 表格标题 th/td 底色和文字色改用主题变量
- CodeBlock 组件 pre 背景从 secondary.main 改为 background.default
- 设置面板已自动执行工具行 opacity 0.8→0.15(淡绿底,文字清晰)

版本号 0.2.1 → 0.2.2
This commit is contained in:
thzxx
2026-07-12 13:46:09 +08:00
parent 6e49c44742
commit aa13a98f63
9 changed files with 96 additions and 32 deletions
+9 -2
View File
@@ -25,6 +25,7 @@ interface ContextBuildParams {
history?: MetonaMessage[]; history?: MetonaMessage[];
memories?: MetonaMemoryItem[]; memories?: MetonaMemoryItem[];
workspaceFiles?: WorkspaceFiles; workspaceFiles?: WorkspaceFiles;
workspacePath?: string;
contextWindow?: number; contextWindow?: number;
} }
@@ -43,10 +44,11 @@ export class ContextBuilder {
history = [], history = [],
memories = [], memories = [],
workspaceFiles, workspaceFiles,
workspacePath,
contextWindow = 128_000, contextWindow = 128_000,
} = params; } = params;
const systemPrompt = this.buildSystemPrompt(workspaceFiles); const systemPrompt = this.buildSystemPrompt(workspaceFiles, workspacePath);
const estimatedTokens = this.estimateTokens(history, memories, availableTools, userInput, systemPrompt); const estimatedTokens = this.estimateTokens(history, memories, availableTools, userInput, systemPrompt);
return { return {
@@ -76,7 +78,7 @@ export class ContextBuilder {
* 4. MEMORY.md(记忆)— 动态区 * 4. MEMORY.md(记忆)— 动态区
* 5. 内置安全准则 — 尾部锚定 * 5. 内置安全准则 — 尾部锚定
*/ */
buildSystemPrompt(workspaceFiles?: WorkspaceFiles): MetonaSystemPrompt { buildSystemPrompt(workspaceFiles?: WorkspaceFiles, workspacePath?: string): MetonaSystemPrompt {
let staticUserProfile = ''; let staticUserProfile = '';
// ===== 静态区:用户画像(变化不频繁,放入静态区利用 LLM 缓存)===== // ===== 静态区:用户画像(变化不频繁,放入静态区利用 LLM 缓存)=====
@@ -99,6 +101,11 @@ export class ContextBuilder {
// ===== 动态区:记忆 ===== // ===== 动态区:记忆 =====
const dynamicParts: string[] = []; 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) { if (workspaceFiles?.memory) {
const memoryContent = this.extractContent(workspaceFiles.memory); const memoryContent = this.extractContent(workspaceFiles.memory);
if (memoryContent) { if (memoryContent) {
+28 -5
View File
@@ -24,6 +24,26 @@ import type { SandboxManager } from '../../sandbox/sandbox';
const execAsync = promisify(exec); 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 ===== // ===== 9. run_command =====
export class RunCommandTool implements IMetonaTool { export class RunCommandTool implements IMetonaTool {
@@ -100,10 +120,13 @@ export class RunCommandTool implements IMetonaTool {
? `chcp 65001 >nul 2>&1 && ${command}` ? `chcp 65001 >nul 2>&1 && ${command}`
: command; : command;
// 使用 encoding: 'buffer' 获取原始字节,手动智能解码
// 部分程序(如 dir、systeminfo)不响应 chcp 65001,仍输出 GBK 字节流
const { stdout, stderr } = await execAsync(finalCommand, { const { stdout, stderr } = await execAsync(finalCommand, {
cwd: resolvedWorkdir, cwd: resolvedWorkdir,
timeout, timeout,
maxBuffer: 1024 * 1024, // 1MB maxBuffer: 1024 * 1024, // 1MB
encoding: 'buffer', // 返回 Buffer 而非字符串,便于智能解码
env: { env: {
...process.env, ...process.env,
NODE_ENV: 'production', NODE_ENV: 'production',
@@ -118,18 +141,18 @@ export class RunCommandTool implements IMetonaTool {
return { return {
success: true, success: true,
stdout: stdout.slice(0, 50_000), stdout: decodeBuffer(stdout).slice(0, 50_000),
stderr: stderr.slice(0, 10_000), stderr: decodeBuffer(stderr).slice(0, 10_000),
command, command,
}; };
} catch (error) { } 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 { return {
success: false, success: false,
error: err.message, error: err.message,
exit_code: err.code, exit_code: err.code,
stdout: (err.stdout ?? '').slice(0, 10_000), stdout: decodeBuffer(err.stdout ?? Buffer.alloc(0)).slice(0, 10_000),
stderr: (err.stderr ?? '').slice(0, 10_000), stderr: decodeBuffer(err.stderr ?? Buffer.alloc(0)).slice(0, 10_000),
command, command,
}; };
} }
+26 -5
View File
@@ -44,7 +44,7 @@ export function registerAllIPCHandlers(
sessionRecorder: SessionRecorder, sessionRecorder: SessionRecorder,
memoryManager: MemoryManager, memoryManager: MemoryManager,
mcpManager: MCPManager, mcpManager: MCPManager,
reloadAdapter: () => void, reloadAdapter: () => boolean,
promptInjectionDefender: PromptInjectionDefender, promptInjectionDefender: PromptInjectionDefender,
outputValidator: OutputValidator, outputValidator: OutputValidator,
confirmationHook: ConfirmationHook, confirmationHook: ConfirmationHook,
@@ -56,8 +56,29 @@ export function registerAllIPCHandlers(
ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => { ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => {
log.info('[AGENT] sendMessage:', sessionId, userMessage.content.slice(0, 80)); log.info('[AGENT] sendMessage:', sessionId, userMessage.content.slice(0, 80));
// 发送消息前确保 Adapter 使用最新配置(防止 config:set 中间状态导致 reload 失败 // 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送
reloadAdapter(); 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 层:开始录制 // TRACE 层:开始录制
sessionRecorder.startRecording(sessionId); sessionRecorder.startRecording(sessionId);
@@ -88,9 +109,9 @@ export function registerAllIPCHandlers(
timestamp: m.timestamp, timestamp: m.timestamp,
})); }));
// 从工作空间文件构建 System Prompt // 从工作空间文件构建 System Prompt(注入当前工作空间路径,便于 LLM 在需要绝对路径时使用)
const workspaceFiles = workspaceService.getFiles(); const workspaceFiles = workspaceService.getFiles();
const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles); const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles, workspaceService.getPath());
// 检索与用户消息相关的记忆,注入到 System Prompt 动态区 // 检索与用户消息相关的记忆,注入到 System Prompt 动态区
try { try {
+4 -1
View File
@@ -261,7 +261,7 @@ async function initialize(): Promise<void> {
// ===== 热重载 Adapter 回调(设置变更时触发)===== // ===== 热重载 Adapter 回调(设置变更时触发)=====
let lastProvider = configService.get<string>('llm.provider') ?? ''; let lastProvider = configService.get<string>('llm.provider') ?? '';
const reloadAdapter = () => { const reloadAdapter = (): boolean => {
try { try {
const newAdapter = createAdapter(); const newAdapter = createAdapter();
agentLoop.setAdapter(newAdapter); agentLoop.setAdapter(newAdapter);
@@ -282,6 +282,7 @@ async function initialize(): Promise<void> {
from: lastProvider, from: lastProvider,
to: provider, to: provider,
reason: 'config_changed', reason: 'config_changed',
sessionId: '',
}); });
} }
mainWindow.webContents.send('toast:show', { mainWindow.webContents.send('toast:show', {
@@ -290,6 +291,7 @@ async function initialize(): Promise<void> {
}); });
} }
lastProvider = provider; lastProvider = provider;
return true;
} catch (err) { } catch (err) {
log.error(`[CONFIG] Failed to reload adapter: ${(err as Error).message}`); log.error(`[CONFIG] Failed to reload adapter: ${(err as Error).message}`);
// 通知渲染进程 Provider 切换失败(UI 显示错误 Toast) // 通知渲染进程 Provider 切换失败(UI 显示错误 Toast)
@@ -299,6 +301,7 @@ async function initialize(): Promise<void> {
message: `Provider 切换失败: ${(err as Error).message}`, message: `Provider 切换失败: ${(err as Error).message}`,
}); });
} }
return false;
} }
}; };
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.2.1", "version": "0.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.2.1", "version": "0.2.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.2.1", "version": "0.2.2",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js", "main": "dist-electron/main/main.js",
"author": "Metona Team", "author": "Metona Team",
+2 -2
View File
@@ -169,7 +169,7 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac
return ( return (
<Box sx={{ position: 'relative', my: 1, '&:hover .copy-btn': { opacity: 1 } }}> <Box sx={{ position: 'relative', my: 1, '&:hover .copy-btn': { opacity: 1 } }}>
<Stack direction="row" sx={{ px: 1.5, py: 0.75, borderTopLeftRadius: 8, borderTopRightRadius: 8, borderBottom: '1px solid', borderColor: 'divider', bgcolor: 'background.default', fontSize: 11, color: 'text.secondary', justifyContent: 'space-between', alignItems: 'center' }}> <Stack direction="row" sx={{ px: 1.5, py: 0.75, borderTopLeftRadius: 8, borderTopRightRadius: 8, borderBottom: '1px solid', borderColor: 'divider', bgcolor: 'background.paper', fontSize: 11, color: 'text.secondary', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{language}</span> <span>{language}</span>
<Tooltip title={copied ? '已复制' : '复制'}> <Tooltip title={copied ? '已复制' : '复制'}>
<IconButton className="copy-btn" size="small" onClick={handleCopy} sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.secondary' }}> <IconButton className="copy-btn" size="small" onClick={handleCopy} sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.secondary' }}>
@@ -177,7 +177,7 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Stack> </Stack>
<Box component="pre" sx={{ m: 0, bgcolor: 'secondary.main', border: '1px solid', borderColor: 'divider', borderTop: 'none', borderBottomLeftRadius: 8, borderBottomRightRadius: 8, p: 1.5, overflowX: 'auto', fontSize: 13, lineHeight: 1.6 }}> <Box component="pre" sx={{ m: 0, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider', borderTop: 'none', borderBottomLeftRadius: 8, borderBottomRightRadius: 8, p: 1.5, overflowX: 'auto', fontSize: 13, lineHeight: 1.6, color: 'text.primary' }}>
<code className={language ? `language-${language}` : ''}>{code}</code> <code className={language ? `language-${language}` : ''}>{code}</code>
</Box> </Box>
</Box> </Box>
+15 -5
View File
@@ -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 { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen, Globe } from 'lucide-react';
import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store'; import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
import { useAgentStore } from '@renderer/stores/agent-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'; type SettingsTab = 'workspace' | 'llm' | 'agent' | 'tools' | 'mcp' | 'searxng' | 'appearance' | 'logs';
const TABS: { id: SettingsTab; label: string; icon: typeof Settings }[] = [ const TABS: { id: SettingsTab; label: string; icon: typeof Settings }[] = [
@@ -118,11 +119,13 @@ function LLMSettings() {
} }
}, [provider, numCtx]); }, [provider, numCtx]);
// 切换 Provider 时自动填充默认 URL + 清空 Ollama 的 apiKey // 切换 Provider 时自动填充默认 URL + 清空 apiKey(不同 Provider 的 key 不通用)
const handleProviderChange = (newProvider: string) => { const handleProviderChange = (newProvider: string) => {
const oldProvider = provider;
setProvider(newProvider); setProvider(newProvider);
// Ollama 不需要 API Key,切换到 Ollama 时清空旧 key // 切换 Provider 时清空 apiKey(不同 Provider 的 key 格式不同,避免用旧 key 调用新 API 导致 401
if (newProvider === 'ollama' && apiKey) { // Ollama 不需要 apiKey,也一并清空
if (oldProvider !== newProvider && apiKey) {
setApiKey(''); setApiKey('');
} }
// 自动填充默认 URL(仅在 URL 为空或与旧 provider 默认 URL 匹配时) // 自动填充默认 URL(仅在 URL 为空或与旧 provider 默认 URL 匹配时)
@@ -146,9 +149,16 @@ function LLMSettings() {
<TextField size="small" label="API Base URL" value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" /> <TextField size="small" label="API Base URL" value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" />
<TextField size="small" label="模型名称" value={model} onChange={(e) => setModel(e.target.value)} placeholder="如 deepseek-v4-pro、qwen3:latest" /> <TextField size="small" label="模型名称" value={model} onChange={(e) => setModel(e.target.value)} placeholder="如 deepseek-v4-pro、qwen3:latest" />
{provider !== 'ollama' && ( {provider !== 'ollama' && (
<>
<TextField size="small" label="API Key" type={showKey ? 'text' : 'password'} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-..." <TextField size="small" label="API Key" type={showKey ? 'text' : 'password'} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-..."
slotProps={{ input: { endAdornment: <IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton> } }} slotProps={{ input: { endAdornment: <IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton> } }}
/> />
{!apiKey && (
<Typography variant="caption" sx={{ color: 'warning.main', fontSize: 11 }}>
{PROVIDER_LABELS[provider] ?? provider} API Key
</Typography>
)}
</>
)} )}
{provider === 'ollama' && ( {provider === 'ollama' && (
<TextField <TextField
@@ -292,9 +302,9 @@ function ToolsSettings() {
</Typography> </Typography>
) : ( ) : (
autoExecToolDetails.map((t) => ( autoExecToolDetails.map((t) => (
<Stack key={t.name} direction="row" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'success.main', opacity: 0.8, justifyContent: 'space-between', alignItems: 'center' }}> <Stack key={t.name} direction="row" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'success.main', opacity: 0.15, justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', minWidth: 0, flex: 1 }}> <Stack direction="row" spacing={1} sx={{ alignItems: 'center', minWidth: 0, flex: 1 }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>{t.name}</Typography> <Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12, color: 'text.primary' }}>{t.name}</Typography>
<Chip label="自动" size="small" color="success" sx={{ height: 16, fontSize: 9 }} /> <Chip label="自动" size="small" color="success" sx={{ height: 16, fontSize: 9 }} />
</Stack> </Stack>
<Button size="small" color="warning" variant="outlined" sx={{ fontSize: 10, minWidth: 70 }} onClick={() => handleSetAutoExec(t.name, false)}> <Button size="small" color="warning" variant="outlined" sx={{ fontSize: 10, minWidth: 70 }} onClick={() => handleSetAutoExec(t.name, false)}>
+6 -6
View File
@@ -216,17 +216,17 @@ code, pre, .font-mono { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', mo
} }
.prose-metona pre { .prose-metona pre {
background: #1e1e2e; background: var(--bg-tertiary);
border: 1px solid #2a2d3a; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
overflow-x: auto; overflow-x: auto;
margin-bottom: 1em; margin-bottom: 1em;
color: #cdd6f4; color: var(--text-primary);
} }
.prose-metona pre code { .prose-metona pre code {
color: #cdd6f4; color: var(--text-primary);
background: transparent; background: transparent;
padding: 0; padding: 0;
font-size: 13px; font-size: 13px;
@@ -260,8 +260,8 @@ code, pre, .font-mono { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', mo
.prose-metona li { margin-bottom: 0.25em; } .prose-metona li { margin-bottom: 0.25em; }
.prose-metona table { width: 100%; border-collapse: collapse; margin-bottom: 1em; font-size: 13px; } .prose-metona table { width: 100%; border-collapse: collapse; margin-bottom: 1em; font-size: 13px; }
.prose-metona th { text-align: left; padding: 8px 12px; background: #252836; border-bottom: 1px solid #2a2d3a; color: #64748b; font-weight: 600; } .prose-metona th { text-align: left; padding: 8px 12px; background: var(--bg-tertiary); border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-weight: 600; }
.prose-metona td { padding: 8px 12px; border-bottom: 1px solid #2a2d3a; } .prose-metona td { padding: 8px 12px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); }
.prose-metona hr { border: none; height: 1px; background: #2a2d3a; margin: 1.5em 0; } .prose-metona hr { border: none; height: 1px; background: #2a2d3a; margin: 1.5em 0; }
.prose-metona img { max-width: 100%; border-radius: 8px; } .prose-metona img { max-width: 100%; border-radius: 8px; }