feat: v0.4.0 四阶段迭代 — 安全加固 + 工程基线 + 架构重构 + 双 Provider 扩展
P0 安全修复: - API Key 加密存储(safeStorage 密钥链,版本化前缀,历史明文平滑兼容) - 间接提示注入防护(SecurityScanHook 工具结果深扫描,网络工具脱敏/本地工具警示分级) - error:report IPC 断链修复(渲染进程错误上报落 electron-log + 审计) - abort 信号贯通工具层(run_command/dev-tools 子进程随会话中断终止) - run_command 沙箱加固(cd 系统目录/敏感文件读取拦截 + chcp 前缀剥离防解析退化) - .env 真实生效(dotenv 回退加载,应用内配置优先) P1 工程基础: - ESLint 9 flat config + 全部 34 条存量 warnings 清零(零容忍基线) - 测试基线 118 用例 11 文件(token/文件防护/权限/沙箱/注入/命令/引擎/注册表/审计链/摘要分层) - test:electron 双模式(ELECTRON_RUN_AS_NODE 跑 Electron ABI,SQLite 套件全执行) - SessionRecorder 多会话隔离 + 9 种 TRACE 事件补全(含最终轮 iteration_end) - Provider 故障转移(重试耗尽/不可重试一次性切换 fallback + 前端通知) - MCP 真就绪(等待全部连接完成再广播 tools:ready) - SLO/HealthChecker 真实接入(60s 巡检 + 托盘状态) - CONFIG_DEFAULTS 单一来源(消除 SEED 双源漂移) P2 架构升级: - handlers.ts 1940 行拆分为 13 个 IPC 域模块(防重入注册 + 多窗口广播) - AgentEngineManager 每会话独立引擎(LRU 30 + adapter 工厂隔离 abort 信号) - TaskOrchestrator EngineProvider 改造 + abortByParent 联动中断 SubAgent - 会话摘要分层上下文(session_summaries 滚动摘要 + 截断游标清理防因果污染) - 消息编辑重发/重新生成(truncateAfter IPC + store 动作 + UI) - Markdown 导出 / WebSearch 并行抓取(并发 3)/ 记忆 TF 缓存 / 版本构建期注入 P3 能力扩展: - OpenAI Adapter(o 系列推理模型 reasoning_effort/max_completion_tokens) - Anthropic Adapter(原生 Messages API:tool_use 块/角色合并/thinking budget/图片 base64/SSE 事件机) - 设置页/Onboarding 六 Provider 全链路接入
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Dialog, DialogContent, InputBase, List, ListItemButton, ListItemIcon, ListItemText, Typography, Box, Divider } from '@mui/material';
|
||||
import { Dialog, InputBase, List, ListItemButton, ListItemIcon, ListItemText, Typography, Box, Divider } from '@mui/material';
|
||||
import { Search, MessageSquare, Settings, Plus, Trash2 } from 'lucide-react';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
|
||||
@@ -175,14 +175,25 @@ function copyWithToast(text: string): void {
|
||||
export function createContextMenuItems(type: ContextMenuType, data?: unknown): ContextMenuItem[] {
|
||||
switch (type) {
|
||||
case 'message': {
|
||||
const content = (data as { content?: string })?.content ?? '';
|
||||
return [
|
||||
const d = (data as { content?: string; role?: string }) ?? {};
|
||||
const content = d.content ?? '';
|
||||
const items: ContextMenuItem[] = [
|
||||
{ id: 'copy', icon: Copy, label: '复制', action: () => copyWithToast(content) },
|
||||
{ id: 'quote', icon: Quote, label: '引用回复', action: () => {
|
||||
const input = document.querySelector<HTMLTextAreaElement>('[data-chat-input]');
|
||||
if (input) { input.value = content.split('\n').map((l: string) => `> ${l}`).join('\n') + '\n\n'; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); }
|
||||
}},
|
||||
];
|
||||
// P2-11: assistant 消息支持重新生成(删除最后一条用户消息后的回复并重发)
|
||||
if (d.role === 'assistant') {
|
||||
items.push({
|
||||
id: 'regenerate',
|
||||
icon: RotateCcw,
|
||||
label: '重新生成',
|
||||
action: () => { void useAgentStore.getState().regenerate(); },
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
case 'tool-call': {
|
||||
@@ -258,7 +269,7 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
||||
showError('归档失败');
|
||||
}
|
||||
}},
|
||||
{ id: 'export', icon: FileDown, label: '导出', action: () => {
|
||||
{ id: 'export', icon: FileDown, label: '导出 JSON', action: () => {
|
||||
if (sid) window.metona?.sessions.getMessages(sid).then((msgs) => {
|
||||
const b = new Blob([JSON.stringify(msgs, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `session-${sid}.json`; a.click();
|
||||
@@ -267,6 +278,20 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
||||
showError('导出失败');
|
||||
});
|
||||
}},
|
||||
// P2-11: 导出 Markdown(人类可读格式)
|
||||
{ id: 'export-md', icon: FileDown, label: '导出 Markdown', action: async () => {
|
||||
if (!sid) return;
|
||||
try {
|
||||
const msgs = await window.metona?.sessions.getMessages(sid);
|
||||
const { buildSessionMarkdown, downloadMarkdown } = await import('@renderer/lib/export-markdown');
|
||||
const title = useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ?? '会话导出';
|
||||
const md = buildSessionMarkdown(title, msgs as Array<{ id: string; role: string; content: string | null; toolCalls?: Array<{ name: string }>; attachments?: Array<{ name: string }>; timestamp: number }>);
|
||||
downloadMarkdown(`session-${sid}.md`, md);
|
||||
} catch (err) {
|
||||
console.error('[ContextMenu]', err);
|
||||
showError('导出失败');
|
||||
}
|
||||
}},
|
||||
{ id: 'delete', icon: Trash2, label: '删除', action: async () => {
|
||||
if (!sid) return;
|
||||
// 用 MUI Dialog 替代原生 confirm()(Electron 下不可靠)
|
||||
|
||||
@@ -41,7 +41,7 @@ function AssistantMessageImpl({ message, isStreaming }: AssistantMessageProps):
|
||||
// 新 isThinking = agentStatus === 'thinking'(agentStatus 在非流式时恒为 'idle')
|
||||
const agentStatus = useAgentStore((s) => (isStreaming ? s.agentStatus : 'idle'));
|
||||
|
||||
const contextMenuItems = createContextMenuItems('message', { content });
|
||||
const contextMenuItems = createContextMenuItems('message', { content, role: 'assistant' });
|
||||
|
||||
const hasThinking = !!message.reasoningContent;
|
||||
const hasTools = !!message.toolCalls?.length;
|
||||
|
||||
@@ -16,13 +16,13 @@ import { nanoid } from 'nanoid';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { formatTokens, formatFileSize } from '@renderer/lib/formatters';
|
||||
import { formatFileSize } from '@renderer/lib/formatters';
|
||||
|
||||
const SLASH_COMMANDS = [
|
||||
{ id: 'tool', label: '/tool', description: '选择工具' },
|
||||
{ id: 'memory', label: '/memory', description: '搜索记忆' },
|
||||
{ id: 'clear', label: '/clear', description: '清空会话' },
|
||||
{ id: 'export', label: '/export', description: '导出会话' },
|
||||
{ id: 'export', label: '/export', description: '导出 Markdown' },
|
||||
];
|
||||
|
||||
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
@@ -49,7 +49,6 @@ export function ChatInput(): React.JSX.Element {
|
||||
const configLoaded = useAgentStore((s) => s.configLoaded);
|
||||
// v0.3.18 修复: 工具未就绪时禁用发送按钮
|
||||
const toolsReady = useAgentStore((s) => s.toolsReady);
|
||||
const tokenUsage = useAgentStore((s) => s.tokenUsage);
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
const provider = useAgentStore((s) => s.provider);
|
||||
|
||||
@@ -182,14 +181,12 @@ export function ChatInput(): React.JSX.Element {
|
||||
const cmd = trimmed.split(' ')[0].toLowerCase();
|
||||
if (cmd === '/clear') { useAgentStore.getState().clearMessages(); setInput(''); setAttachments([]); setShowSlashMenu(false); return; }
|
||||
if (cmd === '/export') {
|
||||
const blob = new Blob([JSON.stringify(useAgentStore.getState().messages, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `session-${Date.now()}.json`;
|
||||
a.click();
|
||||
// v0.3.0 修复:释放 Blob URL,避免内存泄漏
|
||||
URL.revokeObjectURL(url);
|
||||
// P2-11: /export 改为导出 Markdown(人类可读),JSON 导出走会话右键菜单
|
||||
import('@renderer/lib/export-markdown').then(({ buildSessionMarkdown, downloadMarkdown }) => {
|
||||
const messages = useAgentStore.getState().messages;
|
||||
const md = buildSessionMarkdown('会话导出', messages);
|
||||
downloadMarkdown(`session-${Date.now()}.md`, md);
|
||||
}).catch(() => {});
|
||||
setInput(''); setShowSlashMenu(false); return;
|
||||
}
|
||||
// v0.3.0: /tool — 打开设置面板的工具管理 Tab
|
||||
@@ -217,7 +214,7 @@ export function ChatInput(): React.JSX.Element {
|
||||
}
|
||||
|
||||
// 构建用户可见内容(纯文本 + 附件描述隐藏)
|
||||
let messageContent = trimmed;
|
||||
const messageContent = trimmed;
|
||||
const images: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }> = [];
|
||||
|
||||
// 附件元数据(用于 UI 渲染)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import type { ChatMessage } from '@renderer/stores/agent-store';
|
||||
import { formatTime } from '@renderer/lib/formatters';
|
||||
|
||||
interface SystemMessageProps { message: ChatMessage; }
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Typography, IconButton, Collapse } from '@mui/material';
|
||||
import { Box, IconButton, Collapse } from '@mui/material';
|
||||
import { ChevronDown, ChevronRight, Brain } from 'lucide-react';
|
||||
|
||||
interface ThoughtBlockProps { content: string; defaultExpanded?: boolean; }
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Box, Typography, Avatar, Stack, TextareaAutosize } from '@mui/material';
|
||||
import { User, FileText, Image as ImageIcon, X } from 'lucide-react';
|
||||
import { Box, Typography, Avatar, Stack, TextareaAutosize, Button } from '@mui/material';
|
||||
import { User, FileText, Image as ImageIcon } from 'lucide-react';
|
||||
import type { ChatMessage, AttachmentInfo } from '@renderer/stores/agent-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { formatTime, formatFileSize } from '@renderer/lib/formatters';
|
||||
@@ -21,16 +21,24 @@ export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const updateMessage = useAgentStore((s) => s.updateMessage);
|
||||
// P2-11: 编辑重发(截断该消息之后的所有消息并重新发送修订内容)
|
||||
const editAndResend = useAgentStore((s) => s.editAndResend);
|
||||
const isStreaming = useAgentStore((s) => s.isStreaming);
|
||||
|
||||
const handleDoubleClick = useCallback(() => { setEditing(true); setEditContent(message.content); }, [message.content]);
|
||||
const handleEditSave = useCallback(() => {
|
||||
if (editContent.trim() && editContent !== message.content) updateMessage(message.id, { content: editContent.trim() });
|
||||
setEditing(false);
|
||||
}, [editContent, message.content, message.id, updateMessage]);
|
||||
const handleEditResend = useCallback(() => {
|
||||
if (!editContent.trim()) return;
|
||||
setEditing(false);
|
||||
void editAndResend(message.id, editContent);
|
||||
}, [editContent, editAndResend, message.id]);
|
||||
const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { setEditing(false); setEditContent(message.content); }
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleEditSave();
|
||||
}, [handleEditSave, message.content]);
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !e.shiftKey) handleEditResend();
|
||||
}, [handleEditResend, message.content]);
|
||||
useEffect(() => { if (editing) textareaRef.current?.focus(); }, [editing]);
|
||||
|
||||
const hasAttachments = message.attachments && message.attachments.length > 0;
|
||||
@@ -54,14 +62,22 @@ export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
|
||||
|
||||
{/* 文本内容 */}
|
||||
{editing ? (
|
||||
<TextareaAutosize ref={textareaRef} value={editContent} onChange={(e) => setEditContent(e.target.value)} onKeyDown={handleEditKeyDown} onBlur={handleEditSave} minRows={3} style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', color: 'inherit', fontSize: 13, lineHeight: 1.7, resize: 'none', fontFamily: 'inherit' }} />
|
||||
<>
|
||||
<TextareaAutosize ref={textareaRef} value={editContent} onChange={(e) => setEditContent(e.target.value)} onKeyDown={handleEditKeyDown} minRows={3} style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', color: 'inherit', fontSize: 13, lineHeight: 1.7, resize: 'none', fontFamily: 'inherit' }} />
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>
|
||||
<Button size="small" variant="outlined" onClick={() => { setEditing(false); setEditContent(message.content); }}>取消</Button>
|
||||
<Button size="small" variant="outlined" onClick={handleEditSave}>仅保存</Button>
|
||||
<Button size="small" variant="contained" onClick={handleEditResend} disabled={isStreaming || !editContent.trim()}>保存并重发</Button>
|
||||
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.disabled' }}>重发将删除此消息之后的所有消息 · Ctrl+Enter</Typography>
|
||||
</Stack>
|
||||
</>
|
||||
) : message.content ? (
|
||||
<Typography sx={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7, color: 'text.primary' }}>{message.content}</Typography>
|
||||
) : null}
|
||||
|
||||
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'text.disabled' }}>{formatTime(message.timestamp)}</Typography>
|
||||
</Box>
|
||||
{contextMenu && <ContextMenu x={contextMenu.x} y={contextMenu.y} items={createContextMenuItems('message', { content: message.content })} onClose={() => setContextMenu(null)} />}
|
||||
{contextMenu && <ContextMenu x={contextMenu.x} y={contextMenu.y} items={createContextMenuItems('message', { content: message.content, role: 'user' })} onClose={() => setContextMenu(null)} />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 完全使用 MUI 组件,Table 布局标签-值对。
|
||||
*/
|
||||
|
||||
import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell, Chip } from '@mui/material';
|
||||
import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell } from '@mui/material';
|
||||
import { Activity, Cpu } from 'lucide-react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useAgentStore, type AgentStatus } from '@renderer/stores/agent-store';
|
||||
|
||||
@@ -32,6 +32,8 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||
agnes: 'Agnes',
|
||||
mimo: 'MiMo',
|
||||
ollama: 'Ollama',
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
};
|
||||
|
||||
export function Header(): React.JSX.Element {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Box, Typography, Button, IconButton, TextField, Stack, Collapse, List, ListItemButton, ListItemIcon, ListItemText, Badge, Divider, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
|
||||
import { Box, Typography, Button, IconButton, TextField, Collapse, List, ListItemButton, ListItemIcon, ListItemText, Badge, Divider, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
|
||||
import Fuse from 'fuse.js';
|
||||
import { Plus, Search, MessageSquare, Pin, Wrench, ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
|
||||
import { useSessionStore, type Session } from '@renderer/stores/session-store';
|
||||
|
||||
@@ -18,7 +18,10 @@ export function StatusBar(): React.JSX.Element {
|
||||
const model = useAgentStore((s) => s.model);
|
||||
const tokenUsage = useAgentStore((s) => s.tokenUsage);
|
||||
const openSettings = useUIStore((s) => s.openSettings);
|
||||
const [version, setVersion] = useState('v0.1.1');
|
||||
// P2-12: 版本兜底改为构建期注入的 __APP_VERSION__(原硬编码 v0.1.1 过期)
|
||||
const [version, setVersion] = useState(
|
||||
typeof __APP_VERSION__ !== 'undefined' && __APP_VERSION__ ? `v${__APP_VERSION__}` : 'dev',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// M-28 修复: 添加 cancelled 标志防止组件卸载后 setState
|
||||
|
||||
@@ -346,7 +346,6 @@ function MemoryItemRow({
|
||||
}): React.JSX.Element {
|
||||
const type = item.type;
|
||||
const createdAt = getCreatedAt(item);
|
||||
const importance = item.importance ?? 0;
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, Button, TextField, Select, MenuItem, Stepper, Step, StepLabel, Box, Typography, Stack, FormControl, InputLabel, IconButton, InputAdornment } from '@mui/material';
|
||||
import { ArrowRight, ArrowLeft, FolderOpen, Play, CheckCircle, Eye, EyeOff } from 'lucide-react';
|
||||
import { ArrowRight, ArrowLeft, CheckCircle, Eye, EyeOff } from 'lucide-react';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
|
||||
@@ -75,6 +75,8 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
agnes: 1_000_000,
|
||||
mimo: 1_000_000,
|
||||
ollama: null,
|
||||
openai: 128_000,
|
||||
anthropic: 200_000,
|
||||
};
|
||||
// 上下文窗口校验:ollama 允许空,最小 512;其他 provider 最小 4096
|
||||
const ctxMin = provider === 'ollama' ? 512 : 4096;
|
||||
@@ -163,6 +165,8 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
<MenuItem value="agnes">Agnes AI</MenuItem>
|
||||
<MenuItem value="mimo">MiMo (小米)</MenuItem>
|
||||
<MenuItem value="ollama">Ollama (本地)</MenuItem>
|
||||
<MenuItem value="openai">OpenAI</MenuItem>
|
||||
<MenuItem value="anthropic">Anthropic</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField size="small" label="API Base URL" value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" />
|
||||
|
||||
@@ -107,7 +107,7 @@ function useConfig<T>(key: string, defaultValue: T): [T, (v: T) => void] {
|
||||
return [value, set];
|
||||
}
|
||||
|
||||
const PROVIDER_URLS: Record<string, string> = { deepseek: 'https://api.deepseek.com', agnes: 'https://apihub.agnes-ai.com/v1', mimo: 'https://api.xiaomimimo.com/v1', ollama: 'http://localhost:11434' };
|
||||
const PROVIDER_URLS: Record<string, string> = { deepseek: 'https://api.deepseek.com', agnes: 'https://apihub.agnes-ai.com/v1', mimo: 'https://api.xiaomimimo.com/v1', ollama: 'http://localhost:11434', openai: 'https://api.openai.com/v1', anthropic: 'https://api.anthropic.com' };
|
||||
|
||||
function WorkspaceSettings() {
|
||||
const [workspacePath, setWorkspacePath] = useConfig('workspace.path', '');
|
||||
@@ -365,7 +365,16 @@ function LLMSettings() {
|
||||
const [dsCtxWindow, setDsCtxWindow] = useState<number>(1000000);
|
||||
const [agnesCtxWindow, setAgnesCtxWindow] = useState<number>(1000000);
|
||||
const [mimoCtxWindow, setMimoCtxWindow] = useState<number>(1000000);
|
||||
// P3: OpenAI/Anthropic contextWindow
|
||||
const [oaCtxWindow, setOaCtxWindow] = useState<number>(128000);
|
||||
const [anthropicCtxWindow, setAnthropicCtxWindow] = useState<number>(200000);
|
||||
// P1: 故障转移 Provider 配置
|
||||
const [fbProvider, setFbProvider] = useState<string>('');
|
||||
const [fbModel, setFbModel] = useState<string>('');
|
||||
const [fbApiKey, setFbApiKey] = useState<string>('');
|
||||
const [fbBaseURL, setFbBaseURL] = useState<string>('');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [showFbKey, setShowFbKey] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -378,7 +387,7 @@ function LLMSettings() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [p, m, k, u, nc, ds, ag, mi] = await Promise.all([
|
||||
const results = await Promise.all([
|
||||
window.metona.config.get('llm.provider'),
|
||||
window.metona.config.get('llm.model'),
|
||||
window.metona.config.get('llm.apiKey'),
|
||||
@@ -387,8 +396,16 @@ function LLMSettings() {
|
||||
window.metona.config.get('deepseek.contextWindow'),
|
||||
window.metona.config.get('agnes.contextWindow'),
|
||||
window.metona.config.get('mimo.contextWindow'),
|
||||
window.metona.config.get('openai.contextWindow'),
|
||||
window.metona.config.get('anthropic.contextWindow'),
|
||||
// P1: 故障转移配置
|
||||
window.metona.config.get('llm.fallbackProvider'),
|
||||
window.metona.config.get('llm.fallbackModel'),
|
||||
window.metona.config.get('llm.fallbackApiKey'),
|
||||
window.metona.config.get('llm.fallbackBaseURL'),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const [p, m, k, u, nc, ds, ag, mi, oa, an, fbp, fbm, fbk, fbu] = results;
|
||||
setProvider((p as string) ?? '');
|
||||
setModel((m as string) ?? '');
|
||||
setApiKey((k as string) ?? '');
|
||||
@@ -397,6 +414,12 @@ function LLMSettings() {
|
||||
if (typeof ds === 'number' && ds > 0) setDsCtxWindow(ds);
|
||||
if (typeof ag === 'number' && ag > 0) setAgnesCtxWindow(ag);
|
||||
if (typeof mi === 'number' && mi > 0) setMimoCtxWindow(mi);
|
||||
if (typeof oa === 'number' && oa > 0) setOaCtxWindow(oa);
|
||||
if (typeof an === 'number' && an > 0) setAnthropicCtxWindow(an);
|
||||
setFbProvider((fbp as string) ?? '');
|
||||
setFbModel((fbm as string) ?? '');
|
||||
setFbApiKey((fbk as string) ?? '');
|
||||
setFbBaseURL((fbu as string) ?? '');
|
||||
} catch (err) {
|
||||
console.error('[SettingsModal]', err);
|
||||
} finally {
|
||||
@@ -423,8 +446,12 @@ function LLMSettings() {
|
||||
if (agnesCtxWindow != null && agnesCtxWindow > 0) useAgentStore.setState({ contextWindow: agnesCtxWindow });
|
||||
} else if (provider === 'mimo') {
|
||||
if (mimoCtxWindow != null && mimoCtxWindow > 0) useAgentStore.setState({ contextWindow: mimoCtxWindow });
|
||||
} else if (provider === 'openai') {
|
||||
if (oaCtxWindow != null && oaCtxWindow > 0) useAgentStore.setState({ contextWindow: oaCtxWindow });
|
||||
} else if (provider === 'anthropic') {
|
||||
if (anthropicCtxWindow != null && anthropicCtxWindow > 0) useAgentStore.setState({ contextWindow: anthropicCtxWindow });
|
||||
}
|
||||
}, [provider, numCtx, dsCtxWindow, agnesCtxWindow, mimoCtxWindow]);
|
||||
}, [provider, numCtx, dsCtxWindow, agnesCtxWindow, mimoCtxWindow, oaCtxWindow, anthropicCtxWindow]);
|
||||
|
||||
// ===== 字段级 inline 校验 =====
|
||||
// Base URL:非空时必须以 http:// 或 https:// 开头(避免漏写协议头导致发消息时报 Invalid URL)
|
||||
@@ -436,12 +463,16 @@ function LLMSettings() {
|
||||
const dsCtxError = !Number.isFinite(dsCtxWindow) || dsCtxWindow < 4096;
|
||||
const agnesCtxError = !Number.isFinite(agnesCtxWindow) || agnesCtxWindow < 4096;
|
||||
const mimoCtxError = !Number.isFinite(mimoCtxWindow) || mimoCtxWindow < 4096;
|
||||
const oaCtxError = !Number.isFinite(oaCtxWindow) || oaCtxWindow < 4096;
|
||||
const anthropicCtxError = !Number.isFinite(anthropicCtxWindow) || anthropicCtxWindow < 4096;
|
||||
|
||||
// 是否存在阻断保存的错误(API Key 为空只警告,不阻断 — 允许先填其他字段再回来填 key)
|
||||
const hasBlockingError = urlError || modelHasSpace || numCtxError ||
|
||||
(provider === 'deepseek' && dsCtxError) ||
|
||||
(provider === 'agnes' && agnesCtxError) ||
|
||||
(provider === 'mimo' && mimoCtxError);
|
||||
(provider === 'mimo' && mimoCtxError) ||
|
||||
(provider === 'openai' && oaCtxError) ||
|
||||
(provider === 'anthropic' && anthropicCtxError);
|
||||
|
||||
// 切换 Provider 时:清空 apiKey + 清空 model + 自动填充默认 URL
|
||||
// 不同 Provider 的 key/model 互不通用,避免用旧值调用新 API 导致 401 / model not found
|
||||
@@ -477,10 +508,6 @@ function LLMSettings() {
|
||||
return;
|
||||
}
|
||||
// v0.3.9: 批量保存,避免串行保存中间态触发 reloadAdapter 失败
|
||||
// 旧实现:串行 config:set 8 次,provider 切换后第 1 步会清空 apiKey,
|
||||
// 此时 reloadAdapter 读到空 apiKey 返回 false,前端 toast 报"配置不全",
|
||||
// 但所有字段实际已写入,第二次点保存才显示"已保存"。
|
||||
// 新实现:一次性传所有字段,后端先写入全部,最后统一 reloadAdapter 一次。
|
||||
const entries: Array<{ key: string; value: unknown }> = [
|
||||
{ key: 'llm.provider', value: provider },
|
||||
{ key: 'llm.model', value: model },
|
||||
@@ -490,6 +517,13 @@ function LLMSettings() {
|
||||
{ key: 'deepseek.contextWindow', value: dsCtxWindow },
|
||||
{ key: 'agnes.contextWindow', value: agnesCtxWindow },
|
||||
{ key: 'mimo.contextWindow', value: mimoCtxWindow },
|
||||
{ key: 'openai.contextWindow', value: oaCtxWindow },
|
||||
{ key: 'anthropic.contextWindow', value: anthropicCtxWindow },
|
||||
// P1: 故障转移 Provider(主 Provider 失败时切换)
|
||||
{ key: 'llm.fallbackProvider', value: fbProvider },
|
||||
{ key: 'llm.fallbackModel', value: fbModel },
|
||||
{ key: 'llm.fallbackApiKey', value: fbApiKey },
|
||||
{ key: 'llm.fallbackBaseURL', value: fbBaseURL },
|
||||
];
|
||||
const r = await setBatch(entries);
|
||||
if (r && !r.success) {
|
||||
@@ -524,6 +558,8 @@ function LLMSettings() {
|
||||
<MenuItem value="agnes">Agnes AI</MenuItem>
|
||||
<MenuItem value="mimo">MiMo (小米)</MenuItem>
|
||||
<MenuItem value="ollama">Ollama (本地)</MenuItem>
|
||||
<MenuItem value="openai">OpenAI</MenuItem>
|
||||
<MenuItem value="anthropic">Anthropic</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
@@ -540,7 +576,7 @@ function LLMSettings() {
|
||||
label="模型名称"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="如 deepseek-v4-pro、qwen3:latest"
|
||||
placeholder="如 deepseek-v4-pro、gpt-4o、claude-sonnet-4-5"
|
||||
error={modelHasSpace}
|
||||
helperText={modelHasSpace ? '模型名称不能包含空格' : ' '}
|
||||
/>
|
||||
@@ -615,6 +651,85 @@ function LLMSettings() {
|
||||
helperText={mimoCtxError ? '最小值为 4096' : '默认 1000000(1M),用于上下文压缩判断'}
|
||||
/>
|
||||
)}
|
||||
{provider === 'openai' && (
|
||||
<TextField
|
||||
size="small"
|
||||
label="上下文窗口 (contextWindow)"
|
||||
type="number"
|
||||
value={oaCtxWindow}
|
||||
onChange={(e) => setOaCtxWindow(Number(e.target.value) || 128000)}
|
||||
placeholder="如 128000、200000、1000000"
|
||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||
error={oaCtxError}
|
||||
helperText={oaCtxError ? '最小值为 4096' : 'gpt-4o 默认 128K,gpt-4.1 默认 1M'}
|
||||
/>
|
||||
)}
|
||||
{provider === 'anthropic' && (
|
||||
<TextField
|
||||
size="small"
|
||||
label="上下文窗口 (contextWindow)"
|
||||
type="number"
|
||||
value={anthropicCtxWindow}
|
||||
onChange={(e) => setAnthropicCtxWindow(Number(e.target.value) || 200000)}
|
||||
placeholder="如 200000"
|
||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||
error={anthropicCtxError}
|
||||
helperText={anthropicCtxError ? '最小值为 4096' : 'Claude 默认 200K'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ===== P1: 故障转移 Provider(主 Provider 请求失败时自动切换) ===== */}
|
||||
<Divider />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>故障转移(可选)</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
主 Provider 请求失败(重试耗尽或密钥失效)时自动切换到备用 Provider 重发。留空禁用。
|
||||
</Typography>
|
||||
<FormControl size="small"><InputLabel>备用 Provider</InputLabel>
|
||||
<Select value={fbProvider} label="备用 Provider" onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setFbProvider(v);
|
||||
setFbModel('');
|
||||
setFbApiKey('');
|
||||
setFbBaseURL(PROVIDER_URLS[v] ?? '');
|
||||
}}>
|
||||
<MenuItem value=""><em>禁用</em></MenuItem>
|
||||
<MenuItem value="deepseek">DeepSeek</MenuItem>
|
||||
<MenuItem value="agnes">Agnes AI</MenuItem>
|
||||
<MenuItem value="mimo">MiMo (小米)</MenuItem>
|
||||
<MenuItem value="ollama">Ollama (本地)</MenuItem>
|
||||
<MenuItem value="openai">OpenAI</MenuItem>
|
||||
<MenuItem value="anthropic">Anthropic</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{fbProvider && (
|
||||
<>
|
||||
<TextField
|
||||
size="small"
|
||||
label="备用 Base URL"
|
||||
value={fbBaseURL}
|
||||
onChange={(e) => setFbBaseURL(e.target.value)}
|
||||
placeholder="如 https://api.deepseek.com"
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="备用模型名称"
|
||||
value={fbModel}
|
||||
onChange={(e) => setFbModel(e.target.value)}
|
||||
placeholder="如 deepseek-v4-flash"
|
||||
/>
|
||||
{fbProvider !== 'ollama' && (
|
||||
<TextField
|
||||
size="small"
|
||||
label="备用 API Key"
|
||||
type={showFbKey ? 'text' : 'password'}
|
||||
value={fbApiKey}
|
||||
onChange={(e) => setFbApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
slotProps={{ input: { endAdornment: <IconButton size="small" onClick={() => setShowFbKey(!showFbKey)}>{showFbKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton> } }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save 按钮:批量提交,取消 onChange 实时落库 */}
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>
|
||||
@@ -1230,7 +1345,6 @@ function LogsSettings() {
|
||||
}
|
||||
} catch (e) {
|
||||
// 获取失败不阻塞 UI
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[LogsSettings] Failed to get app data path:', e);
|
||||
} finally {
|
||||
if (!cancelled) setLogPathLoading(false);
|
||||
|
||||
@@ -62,7 +62,6 @@ export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Elemen
|
||||
};
|
||||
|
||||
const color = TRACE_STATE_COLORS[step.state] ?? '#8b8fa7';
|
||||
const label = TRACE_STATE_LABELS[step.state] ?? step.state;
|
||||
const duration = step.completedAt ? step.completedAt - step.startedAt : null;
|
||||
|
||||
// 构建状态进度链(如 "思考 → 执行 → 观察")
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Box, Typography, Stack, Accordion, AccordionSummary, AccordionDetails,
|
||||
IconButton, Chip, Alert, Tooltip, Divider,
|
||||
} from '@mui/material';
|
||||
import { FolderOpen, RefreshCw, ChevronDown, FileText, Folder, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { FolderOpen, RefreshCw, ChevronDown, Folder, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { formatTime, formatFileSize } from '@renderer/lib/formatters';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user