feat: v0.7.3 成本收口 · 状态一致 · 死账清理 — Prompt Cache 根治 + SSRF DNS Pinning + 87 用例扩充全量回归
P1 修复面收口: Prompt Cache 根治(日期/记忆/附件三类易变内容出 system 入用户消息 前置块 user-context.ts, system 跨 run 字节级稳定; Anthropic system 块数组化 + cache_control ephemeral 断言, DeepSeek 自动缓存前缀命中 — 多轮对话输入 token 成本降数量级); 编辑重发/重新生成幽灵 Trace 双侧根治(DB truncateMessagesAfter 同步过滤 metadata.traceSteps + 前端 trimTraceStepsByAnchor 镜像, 严格小于锚点 时间戳, 同毫秒等值判废); sessions:deleteMessage 死通道全链路删除(渲染层零调用 + message_count 漂移面); Ollama vision 能力门控全链路(MetonaModelInfo .supportsVision 贯穿 adapter/IPC/store/UI, model-capabilities.ts 三道判定纯函数, 未知保守放行); 记忆固化节流(consolidation-policy 纯函数: 总开关 + 内容门控 [回答>=200字符或存在成功工具调用] + 会话级 10 分钟频率窗口, 三 memory.* 配置键) P2 安全纵深: SSRF DNS Pinning 关闭 rebinding 窗口(ssrf-guard 重构 resolvePublicAddresses 单源; ssrf-dispatcher 以 undici Agent.connect.lookup 钉死校验 IP, TLS SNI 保持原域名, 一次性 dispatcher 用后即毁; 代理激活显式 退化为仅入口校验); web_fetch 重写手动逐跳重定向循环(每跳先校验后连接, 替代 redirect:follow 内核跟跳的中间跳裸奔, 上限 5 跳); http_request 换用 pinned fetch; web_search 可达性预检加固(私有 URL 零请求 + 不跟跳, 3xx 视为 可达); Agent 浏览器 CORS 通配收紧为 Origin 回显 + Vary: Origin; ConfirmationHook.forgetSession 会话终态清理(会话删除/abort 联动/SubAgent 终结三处接线, 根治 rememberedDecisions 泄漏) P3 架构还债: agent.enableReflection 死配置全链路接线(main→shared→引擎→ Orchestrator→设置开关, REFLECTING 状态真实可达); AgentLoopConfig.timeoutMs 死字段删除; MemoryManager.cleanupExpired 挂入健康检查周期(expires_at 回收 管道真实化); buildSafeEnv 收敛 utils/safe-env.ts 单源(run_command 与 MCP stdio 共用, 终结双实现漂移); Trace 生命周期治理(metadata 只保留最近 20 个 run — keepRecentRuns 纯函数; JSONL 录制启动自动清理保留 200 个 + 设置页 手动清理); SLO/健康快照可视化(app:healthSnapshot IPC + 设置页只读卡片 + 审计链一键校验) P4 能力演进: 会话标题 LLM 自动生成(TitleGenerator — 每会话幂等/并发重入复用 同一 Promise/自定义标题不覆盖/失败静默回退, Sidebar 经 config:changed 实时 刷新); MCP 自动重连(5s/15s/60s 退避最多 3 次, reconnecting 状态机, teardownConnection 内部拆除保留簿记 — 用户断开/开关关闭即时取消, 设置页 显示第 N/3 次); 死循环检测 ABAB 乒乓模式(最近4轮 A→B→A→B 交替判定, 补齐 docs 第五章"两状态反复切换"检测契约); i18n 第三阶段(ChatInput/LLMSettings/ OnboardingWizard/MemoryViewer 主链路文案出层, zh-CN + en-US 双字典补齐) 测试: 737 → 824 用例(+87, 新增 8 个测试文件 + 扩展 3 个)。新覆盖: user-context 分组/空值收缩/拼接契约、context-builder 字节级稳定性、Anthropic cache_control 四态、consolidation-policy 九路判定矩阵、ssrf-dispatcher(pinned lookup/重定向 解析/IP 校验)、forget-session 会话隔离、trace-lifecycle run 淘汰、 trace-trim 严格小于边界、safe-env 净化矩阵、mcp-reconnect 退避状态机 (fake timers)、title-generator 并发重入、SQLite 侧 truncate×TRACE 联动 (Electron ABI)。测试驱动修复: GIT_*/ 注释终止块注释、重连计数被自身重试 前置断开重置(拆 teardownConnection 保留簿记)、TitleGenerator 幂等占位与 并发去重的检查顺序竞态(去重先于幂等) 版本: 0.7.3; README 同步(配置表新增 agent.enableReflection/memory.*/mcp.autoReconnect) 回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 771 通过 53 跳过 (better-sqlite3 ABI); Electron ABI 全量 824/824 零跳过
This commit is contained in:
@@ -26,12 +26,19 @@ import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { formatFileSize } from '@renderer/lib/formatters';
|
||||
import { PROVIDER_LABELS } from '@renderer/lib/constants';
|
||||
// v0.7.3 P1-4: 图片上传门控纯函数(总开关 × DeepSeek 命名防线 × Ollama 能力探测)
|
||||
import { supportsImageUpload } from '@renderer/lib/model-capabilities';
|
||||
// v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
const SLASH_COMMANDS = [
|
||||
{ id: 'tool', label: '/tool', description: '选择工具' },
|
||||
{ id: 'memory', label: '/memory', description: '搜索记忆' },
|
||||
{ id: 'clear', label: '/clear', description: '清空会话' },
|
||||
{ id: 'export', label: '/export', description: '导出 Markdown' },
|
||||
// v0.7.3 P4-3: 描述文案渲染时求值(i18next 字典注册是异步的,模块顶层固化会拿到 key 本体)
|
||||
const SLASH_COMMANDS: Array<{ id: string; label: string; description: () => string }> = [
|
||||
{ id: 'tool', label: '/tool', description: () => t('input.slash.tool') },
|
||||
{ id: 'memory', label: '/memory', description: () => t('input.slash.memory') },
|
||||
{ id: 'clear', label: '/clear', description: () => t('input.slash.clear') },
|
||||
{ id: 'export', label: '/export', description: () => t('input.slash.export') },
|
||||
];
|
||||
|
||||
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
@@ -106,15 +113,14 @@ export function ChatInput(): React.JSX.Element {
|
||||
const model = useAgentStore((s) => s.model);
|
||||
// v0.5.4: 多模态总开关(llm.multimodalEnabled,设置/引导向导中配置)
|
||||
const multimodalEnabled = useAgentStore((s) => s.multimodalEnabled);
|
||||
|
||||
/**
|
||||
* v0.5.4: 图片上传双重判断 — 总开关 × 模型能力
|
||||
* - 开关关闭:即使模型支持多模态也不能上传(显式控制)
|
||||
* - 模型能力:DeepSeek 仅 vision 系列支持;其他五家 Provider 均支持
|
||||
*/
|
||||
const modelSupportsImages =
|
||||
provider !== 'deepseek' || (model.length > 0 && model.includes('vision'));
|
||||
const supportsImages = multimodalEnabled && modelSupportsImages;
|
||||
const modelVisionCaps = useAgentStore((s) => s.modelVisionCaps);
|
||||
const imageGate = supportsImageUpload({
|
||||
multimodalEnabled,
|
||||
provider,
|
||||
model,
|
||||
visionCaps: modelVisionCaps,
|
||||
});
|
||||
const supportsImages = imageGate.allowed;
|
||||
|
||||
// 草稿自动保存
|
||||
useEffect(() => {
|
||||
@@ -163,11 +169,12 @@ export function ChatInput(): React.JSX.Element {
|
||||
reader.onerror = () => reject(new Error('文本文件读取失败'));
|
||||
reader.readAsText(headBlob);
|
||||
});
|
||||
attachment.textContent += `\n\n[... 文件内容已截断:原始大小 ${formatFileSize(file.size)},仅包含前 ${formatFileSize(MAX_TEXT_ATTACHMENT_BYTES)}。如需完整内容请分段处理 ...]`;
|
||||
attachment.textContent += t('input.text.truncatedNote', {
|
||||
size: formatFileSize(file.size),
|
||||
limit: formatFileSize(MAX_TEXT_ATTACHMENT_BYTES),
|
||||
});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.warning(`文本附件 "${file.name}" 超过 512KB,已截断为前 512KB`),
|
||||
)
|
||||
.then((mod) => mod.default.warning(t('input.text.truncated', { name: file.name })))
|
||||
.catch(() => {});
|
||||
} else {
|
||||
attachment.textContent = await new Promise<string>((resolve, reject) => {
|
||||
@@ -196,9 +203,15 @@ export function ChatInput(): React.JSX.Element {
|
||||
if (filtered.length < fileArray.length && !supportsImages) {
|
||||
const skipped = fileArray.length - filtered.length;
|
||||
// v0.5.4: 区分拒绝原因 — 开关未开启 vs 当前模型不支持
|
||||
const reason = multimodalEnabled
|
||||
? `当前模型不支持图片(${provider} 需多模态模型),已跳过 ${skipped} 个图片文件`
|
||||
: `多模态未开启(设置 → LLM 配置),已跳过 ${skipped} 个图片文件`;
|
||||
const reason =
|
||||
imageGate.reason === 'disabled'
|
||||
? t('input.image.skipped.toggle', { count: skipped })
|
||||
: imageGate.reason === 'probed-no-vision'
|
||||
? t('input.image.skipped.probe', { count: skipped })
|
||||
: t('input.image.skipped.model', {
|
||||
provider: PROVIDER_LABELS[provider] ?? provider,
|
||||
count: skipped,
|
||||
});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => {
|
||||
mod.default.warning(reason);
|
||||
@@ -218,7 +231,7 @@ export function ChatInput(): React.JSX.Element {
|
||||
if (successful.length === 0) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => {
|
||||
mod.default.error('文件读取失败,请检查文件是否损坏或被锁定');
|
||||
mod.default.error(t('input.file.readFailed'));
|
||||
})
|
||||
.catch(() => {});
|
||||
return;
|
||||
@@ -227,7 +240,7 @@ export function ChatInput(): React.JSX.Element {
|
||||
const failedCount = filtered.length - successful.length;
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => {
|
||||
mod.default.warning(`${failedCount} 个文件读取失败,已跳过`);
|
||||
mod.default.warning(t('input.file.partialFailed', { count: failedCount }));
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -522,7 +535,7 @@ export function ChatInput(): React.JSX.Element {
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{cmd.label}</span>
|
||||
<span style={{ color: 'var(--text-secondary, #64748b)', fontSize: 11 }}>
|
||||
{cmd.description}
|
||||
{cmd.description()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -554,9 +567,9 @@ export function ChatInput(): React.JSX.Element {
|
||||
placeholder={
|
||||
configLoaded
|
||||
? toolsReady
|
||||
? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)'
|
||||
: '工具加载中...'
|
||||
: '正在加载配置...'
|
||||
? t('input.placeholder.ready')
|
||||
: t('input.placeholder.toolsLoading')
|
||||
: t('input.placeholder.configLoading')
|
||||
}
|
||||
disabled={isStreaming || !configLoaded || !toolsReady}
|
||||
multiline
|
||||
@@ -608,7 +621,7 @@ export function ChatInput(): React.JSX.Element {
|
||||
onClick={handleAbort}
|
||||
sx={{ height: 28, fontSize: 12 }}
|
||||
>
|
||||
<Square size={12} style={{ marginRight: 6 }} /> 中断
|
||||
<Square size={12} style={{ marginRight: 6 }} /> {t('input.abort')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -627,7 +640,7 @@ export function ChatInput(): React.JSX.Element {
|
||||
: 0.5,
|
||||
}}
|
||||
>
|
||||
<Send size={12} style={{ marginRight: 6 }} /> 发送
|
||||
<Send size={12} style={{ marginRight: 6 }} /> {t('input.send')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -96,6 +96,19 @@ export function Sidebar(): React.JSX.Element {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// v0.7.3 P4-1: 主进程 LLM 标题生成完成后经 config:changed 广播
|
||||
// (合成 key:session.title.<sessionId>),此处消费并刷新侧栏标题
|
||||
useEffect(() => {
|
||||
if (!window.metona?.config?.onChanged) return;
|
||||
const unsubscribe = window.metona.config.onChanged((data) => {
|
||||
const match = /^session\.title\.(.+)$/.exec(data.key);
|
||||
if (match && typeof data.value === 'string' && data.value) {
|
||||
useSessionStore.getState().updateSession(match[1], { title: data.value });
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// v0.5.0: 搜索词变化时防抖查询会话内容(FTS5 全文搜索,300ms 防抖减少 IPC 频率)
|
||||
useEffect(() => {
|
||||
const q = searchQuery.trim();
|
||||
|
||||
@@ -7,13 +7,25 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Stack, Accordion, AccordionSummary, AccordionDetails,
|
||||
IconButton, TextField, Chip, Alert, InputAdornment,
|
||||
Box,
|
||||
Typography,
|
||||
Stack,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
IconButton,
|
||||
TextField,
|
||||
Chip,
|
||||
Alert,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import { Brain, Search, Trash2, ChevronDown } from 'lucide-react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { formatTime, truncate } from '@renderer/lib/formatters';
|
||||
// v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
// ===== 类型 =====
|
||||
|
||||
@@ -43,11 +55,13 @@ interface SearchResult {
|
||||
|
||||
const MEMORY_TYPES: MemoryType[] = ['episodic', 'semantic', 'working'];
|
||||
|
||||
const MEMORY_TYPE_LABELS: Record<MemoryType, string> = {
|
||||
episodic: '情景记忆',
|
||||
semantic: '语义记忆',
|
||||
working: '工作记忆',
|
||||
// v0.7.3 P4-3: 类型标签渲染时求值(i18next 字典注册是异步的,模块顶层固化会拿到 key 本体)
|
||||
const MEMORY_TYPE_LABEL_KEYS: Record<MemoryType, string> = {
|
||||
episodic: 'memory.type.episodic',
|
||||
semantic: 'memory.type.semantic',
|
||||
working: 'memory.type.working',
|
||||
};
|
||||
const memoryTypeLabel = (type: MemoryType): string => t(MEMORY_TYPE_LABEL_KEYS[type]);
|
||||
|
||||
const MEMORY_TYPE_COLORS: Record<MemoryType, string> = {
|
||||
episodic: '#22d3ee',
|
||||
@@ -73,7 +87,9 @@ function getId(item: MemoryItem): string {
|
||||
|
||||
export function MemoryViewer(): React.JSX.Element {
|
||||
const [memories, setMemories] = useState<Record<MemoryType, MemoryItem[]>>({
|
||||
episodic: [], semantic: [], working: [],
|
||||
episodic: [],
|
||||
semantic: [],
|
||||
working: [],
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
|
||||
@@ -108,7 +124,7 @@ export function MemoryViewer(): React.JSX.Element {
|
||||
// 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果
|
||||
if (loadReqIdRef.current !== reqId) return;
|
||||
if (!res.success) {
|
||||
setError(res.error ?? '加载记忆失败');
|
||||
setError(res.error ?? t('memory.loadFailed'));
|
||||
return;
|
||||
}
|
||||
const data = res.data ?? {};
|
||||
@@ -119,7 +135,7 @@ export function MemoryViewer(): React.JSX.Element {
|
||||
});
|
||||
} catch (err) {
|
||||
if (loadReqIdRef.current !== reqId) return;
|
||||
setError((err as Error).message ?? '加载记忆失败');
|
||||
setError((err as Error).message ?? t('memory.loadFailed'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -127,7 +143,9 @@ export function MemoryViewer(): React.JSX.Element {
|
||||
useEffect(() => {
|
||||
loadMemories();
|
||||
// cleanup: 使当前请求失效(防止卸载后 setState)
|
||||
return () => { loadReqIdRef.current++; };
|
||||
return () => {
|
||||
loadReqIdRef.current++;
|
||||
};
|
||||
}, [loadMemories]);
|
||||
|
||||
// Agent 完成自动刷新
|
||||
@@ -161,7 +179,7 @@ export function MemoryViewer(): React.JSX.Element {
|
||||
setSearchResults(results);
|
||||
} catch (err) {
|
||||
if (searchReqIdRef.current !== reqId) return;
|
||||
setError((err as Error).message ?? '搜索失败');
|
||||
setError((err as Error).message ?? t('memory.loadFailed'));
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
// 审计补充修复: 无条件清理 searching 状态,避免被 loadMemories 取代时卡死
|
||||
@@ -181,29 +199,42 @@ export function MemoryViewer(): React.JSX.Element {
|
||||
const res = await window.metona.memory.delete(type, id);
|
||||
if (!res.success) {
|
||||
// 用户主动操作失败应用 toast(与项目惯例一致),不污染加载/搜索的 Alert
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.error(res.error ?? '删除失败')).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(res.error ?? t('memory.deleteFailed')))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
await loadMemories();
|
||||
} catch (err) {
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.error((err as Error).message ?? '删除失败')).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error((err as Error).message ?? t('memory.deleteFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const totalCount =
|
||||
memories.episodic.length + memories.semantic.length + memories.working.length;
|
||||
const totalCount = memories.episodic.length + memories.semantic.length + memories.working.length;
|
||||
|
||||
// 搜索结果视图
|
||||
if (searchResults !== null) {
|
||||
return (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||
<Box
|
||||
sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
|
||||
<Brain size={14} style={{ color: '#a855f7' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
|
||||
记忆搜索
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{t('memory.search.title')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
|
||||
{searchResults.length} 条结果
|
||||
{t('memory.results', { count: searchResults.length })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -212,9 +243,15 @@ export function MemoryViewer(): React.JSX.Element {
|
||||
fullWidth
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
|
||||
placeholder="搜索记忆..."
|
||||
sx={{ mb: 1.5, flexShrink: 0, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 } }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
}}
|
||||
placeholder={t('memory.search.placeholder')}
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
flexShrink: 0,
|
||||
'& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 },
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
@@ -223,24 +260,49 @@ export function MemoryViewer(): React.JSX.Element {
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchQuery ? (
|
||||
<InputAdornment position="end" sx={{ cursor: 'pointer' }} onClick={handleClearSearch}>
|
||||
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.secondary' }}>清除</Typography>
|
||||
<InputAdornment
|
||||
position="end"
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.secondary' }}>
|
||||
{t('memory.search.clear')}
|
||||
</Typography>
|
||||
</InputAdornment>
|
||||
) : null,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 1, py: 0.5, fontSize: 11, flexShrink: 0 }}>{error}</Alert>}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 1, py: 0.5, fontSize: 11, flexShrink: 0 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.5, minHeight: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{searching ? (
|
||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled' }}>
|
||||
搜索中...
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ textAlign: 'center', py: 2, color: 'text.disabled' }}
|
||||
>
|
||||
{t('memory.searching')}
|
||||
</Typography>
|
||||
) : searchResults.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled' }}>
|
||||
无匹配结果
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ textAlign: 'center', py: 2, color: 'text.disabled' }}
|
||||
>
|
||||
{t('memory.search.empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
searchResults.map((r) => (
|
||||
@@ -254,14 +316,24 @@ export function MemoryViewer(): React.JSX.Element {
|
||||
|
||||
// 全量列表视图
|
||||
return (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||
<Box
|
||||
sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
|
||||
<Brain size={14} style={{ color: '#a855f7' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
|
||||
记忆库
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{t('memory.list.title')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
|
||||
{totalCount} 条
|
||||
{t('memory.total', { count: totalCount })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -270,9 +342,15 @@ export function MemoryViewer(): React.JSX.Element {
|
||||
fullWidth
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
|
||||
placeholder="搜索记忆..."
|
||||
sx={{ mb: 1.5, flexShrink: 0, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 } }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
}}
|
||||
placeholder={t('memory.search.placeholder')}
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
flexShrink: 0,
|
||||
'& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 },
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
@@ -284,11 +362,18 @@ export function MemoryViewer(): React.JSX.Element {
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 1, py: 0.5, fontSize: 11, flexShrink: 0 }}>{error}</Alert>}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 1, py: 0.5, fontSize: 11, flexShrink: 0 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{totalCount === 0 ? (
|
||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}
|
||||
>
|
||||
暂无记忆数据
|
||||
</Typography>
|
||||
) : (
|
||||
@@ -320,16 +405,31 @@ export function MemoryViewer(): React.JSX.Element {
|
||||
>
|
||||
<AccordionSummary expandIcon={<ChevronDown size={14} />}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', width: '100%' }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: MEMORY_TYPE_COLORS[type], flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: 11, color: 'text.primary' }}>
|
||||
{MEMORY_TYPE_LABELS[type]}
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: MEMORY_TYPE_COLORS[type],
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 600, fontSize: 11, color: 'text.primary' }}
|
||||
>
|
||||
{memoryTypeLabel(type)}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={items.length}
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 'auto', mr: 0.5, height: 16, fontSize: 10,
|
||||
bgcolor: 'action.hover', color: 'text.secondary',
|
||||
ml: 'auto',
|
||||
mr: 0.5,
|
||||
height: 16,
|
||||
fontSize: 10,
|
||||
bgcolor: 'action.hover',
|
||||
color: 'text.secondary',
|
||||
'& .MuiChip-label': { px: 0.5 },
|
||||
}}
|
||||
/>
|
||||
@@ -366,17 +466,22 @@ function MemoryItemRow({
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1, py: 0.75, borderRadius: 1,
|
||||
bgcolor: 'background.default', border: '1px solid', borderColor: 'divider',
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.default',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:hover .delete-btn': { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={MEMORY_TYPE_LABELS[type]}
|
||||
label={memoryTypeLabel(type)}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 14, fontSize: 9,
|
||||
height: 14,
|
||||
fontSize: 9,
|
||||
bgcolor: MEMORY_TYPE_COLORS[type] + '22',
|
||||
color: MEMORY_TYPE_COLORS[type],
|
||||
'& .MuiChip-label': { px: 0.5 },
|
||||
@@ -387,7 +492,8 @@ function MemoryItemRow({
|
||||
label={`I ${(item.importance ?? 0).toFixed(2)}`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 14, fontSize: 9,
|
||||
height: 14,
|
||||
fontSize: 9,
|
||||
bgcolor: importanceColor(item.importance) + '22',
|
||||
color: importanceColor(item.importance),
|
||||
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
|
||||
@@ -406,12 +512,26 @@ function MemoryItemRow({
|
||||
className="delete-btn"
|
||||
size="small"
|
||||
onClick={() => onDelete(type, getId(item))}
|
||||
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }}
|
||||
sx={{
|
||||
opacity: 0,
|
||||
transition: 'opacity 150ms',
|
||||
p: 0.25,
|
||||
'&:hover': { color: 'error.main' },
|
||||
}}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ fontSize: 11, color: 'text.primary', display: 'block', lineHeight: 1.4, wordBreak: 'break-word' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
color: 'text.primary',
|
||||
display: 'block',
|
||||
lineHeight: 1.4,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{truncate(item.content, 100)}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -428,17 +548,22 @@ function MemorySearchItem({
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1, py: 0.75, borderRadius: 1,
|
||||
bgcolor: 'background.default', border: '1px solid', borderColor: 'divider',
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.default',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:hover .delete-btn': { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={MEMORY_TYPE_LABELS[item.type]}
|
||||
label={memoryTypeLabel(item.type)}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 14, fontSize: 9,
|
||||
height: 14,
|
||||
fontSize: 9,
|
||||
bgcolor: MEMORY_TYPE_COLORS[item.type] + '22',
|
||||
color: MEMORY_TYPE_COLORS[item.type],
|
||||
'& .MuiChip-label': { px: 0.5 },
|
||||
@@ -448,8 +573,10 @@ function MemorySearchItem({
|
||||
label={`R ${(item.relevanceScore ?? 0).toFixed(2)}`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 14, fontSize: 9,
|
||||
bgcolor: '#22d3ee22', color: '#22d3ee',
|
||||
height: 14,
|
||||
fontSize: 9,
|
||||
bgcolor: '#22d3ee22',
|
||||
color: '#22d3ee',
|
||||
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
|
||||
}}
|
||||
/>
|
||||
@@ -457,14 +584,18 @@ function MemorySearchItem({
|
||||
label={`I ${(item.importance ?? 0).toFixed(2)}`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 14, fontSize: 9,
|
||||
height: 14,
|
||||
fontSize: 9,
|
||||
bgcolor: importanceColor(item.importance) + '22',
|
||||
color: importanceColor(item.importance),
|
||||
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
|
||||
}}
|
||||
/>
|
||||
{item.createdAt > 0 && (
|
||||
<Typography variant="caption" sx={{ ml: 'auto', fontSize: 9, color: 'text.disabled', fontFamily: 'monospace' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ ml: 'auto', fontSize: 9, color: 'text.disabled', fontFamily: 'monospace' }}
|
||||
>
|
||||
{formatTime(item.createdAt)}
|
||||
</Typography>
|
||||
)}
|
||||
@@ -473,12 +604,26 @@ function MemorySearchItem({
|
||||
size="small"
|
||||
aria-label="删除该记忆"
|
||||
onClick={() => onDelete(item.type, item.id)}
|
||||
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }}
|
||||
sx={{
|
||||
opacity: 0,
|
||||
transition: 'opacity 150ms',
|
||||
p: 0.25,
|
||||
'&:hover': { color: 'error.main' },
|
||||
}}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ fontSize: 11, color: 'text.primary', display: 'block', lineHeight: 1.4, wordBreak: 'break-word' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
color: 'text.primary',
|
||||
display: 'block',
|
||||
lineHeight: 1.4,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{truncate(item.content, 100)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -27,8 +27,19 @@ import { ArrowRight, ArrowLeft, CheckCircle, Eye, EyeOff } from 'lucide-react';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { PROVIDER_URLS } from '@renderer/components/settings/useConfig';
|
||||
// v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
const STEPS = ['欢迎', '配置 LLM', '自定义 Agent', '工作空间', '开始使用'];
|
||||
// v0.7.3 P4-3: 步骤文案渲染时求值(i18next 字典注册是异步的,模块顶层固化会拿到 key 本体)
|
||||
const STEP_KEYS = [
|
||||
'onboarding.step.welcome',
|
||||
'onboarding.step.llm',
|
||||
'onboarding.step.agent',
|
||||
'onboarding.step.workspace',
|
||||
'onboarding.step.done',
|
||||
] as const;
|
||||
const STEPS = STEP_KEYS.map((k) => k); // 供长度/索引判断
|
||||
|
||||
// #48 修复: Onboarding 进度持久化 key
|
||||
const ONBOARDING_PROGRESS_KEY = 'onboarding_progress';
|
||||
@@ -152,7 +163,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
if (r && !r.success) {
|
||||
console.error('[OnboardingWizard]', 'Batch config save failed:', r.error);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r.error ?? '配置保存失败,请重试'))
|
||||
.then((mod) => mod.default.error(r.error ?? t('onboarding.toast.saveFailed')))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
@@ -177,7 +188,11 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
console.error('[OnboardingWizard]', 'Failed to save configuration:', err);
|
||||
// 用户主动操作失败必须有反馈,否则向导不关闭、用户卡死
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`保存配置失败:${(err as Error).message}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(
|
||||
t('onboarding.toast.saveFailedWithReason', { message: (err as Error).message }),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -194,9 +209,9 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
alternativeLabel
|
||||
sx={{ '& .MuiStepLabel-label': { fontSize: 11 } }}
|
||||
>
|
||||
{STEPS.map((s) => (
|
||||
<Step key={s}>
|
||||
<StepLabel>{s}</StepLabel>
|
||||
{STEP_KEYS.map((key) => (
|
||||
<Step key={key}>
|
||||
<StepLabel>{t(key)}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
@@ -219,21 +234,21 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
sx={{ width: 64, height: 64, mx: 'auto', mb: 2, borderRadius: 2 }}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
欢迎使用 MetonaAI Desktop
|
||||
{t('onboarding.welcome.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
生产级通用 AI Agent 智能体桌面应用,支持多轮对话、工具调用、记忆系统和 MCP 协议集成。
|
||||
{t('onboarding.welcome.body')}
|
||||
</Typography>
|
||||
<Typography variant="caption">让我们花 1 分钟完成初始配置。</Typography>
|
||||
<Typography variant="caption">{t('onboarding.welcome.hint')}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
配置 LLM Provider
|
||||
{t('onboarding.llm.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
选择 Provider 并填写 API 信息。Base URL 和模型名称支持任意输入。
|
||||
{t('onboarding.llm.body')}
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<FormControl size="small">
|
||||
@@ -279,11 +294,11 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="API Key"
|
||||
label={t('llm.apiKey.label')}
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-...(本地模型可留空)"
|
||||
placeholder={t('onboarding.llm.apiKey.placeholder')}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
@@ -299,7 +314,9 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
<TextField
|
||||
size="small"
|
||||
label={
|
||||
provider === 'ollama' ? '上下文长度 (num_ctx)' : '上下文窗口 (contextWindow)'
|
||||
provider === 'ollama'
|
||||
? t('onboarding.llm.ctx.ollama')
|
||||
: t('onboarding.llm.ctx.window')
|
||||
}
|
||||
type="number"
|
||||
value={contextWindow ?? ''}
|
||||
@@ -309,17 +326,17 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
}}
|
||||
placeholder={
|
||||
provider === 'ollama'
|
||||
? '默认由模型决定(如 2048、4096、128000)'
|
||||
: '如 64000、128000、1000000'
|
||||
? t('onboarding.llm.ctx.ollamaPlaceholder')
|
||||
: t('onboarding.llm.ctx.placeholder')
|
||||
}
|
||||
slotProps={{ htmlInput: { min: ctxMin, step: ctxMin } }}
|
||||
error={ctxError}
|
||||
helperText={
|
||||
ctxError
|
||||
? `最小值为 ${ctxMin}`
|
||||
? t('onboarding.llm.ctx.minError', { min: ctxMin })
|
||||
: provider === 'ollama'
|
||||
? ' '
|
||||
: '用于上下文压缩判断,不传给 API'
|
||||
: t('onboarding.llm.ctx.helper')
|
||||
}
|
||||
/>
|
||||
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
|
||||
@@ -333,9 +350,9 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
}
|
||||
label={
|
||||
<Stack>
|
||||
<Typography variant="body2">启用多模态(图片输入)</Typography>
|
||||
<Typography variant="body2">{t('onboarding.llm.multimodal.label')}</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||
开启后可在输入框上传图片;DeepSeek 需 vision 系列模型
|
||||
{t('onboarding.llm.multimodal.helper')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
@@ -347,10 +364,10 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
{step === 2 && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
自定义 Agent
|
||||
{t('onboarding.agent.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
编辑工作空间中的 SOUL.md 文件来定义 Agent 的身份和性格。
|
||||
{t('onboarding.agent.body')}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -365,11 +382,10 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ color: '#e1e4ed' }}>SOUL.md</strong> — 定义 Agent
|
||||
的身份、性格、核心价值观
|
||||
<strong style={{ color: '#e1e4ed' }}>{t('onboarding.agent.soulLabel')}</strong>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.7 }}>
|
||||
此步骤可稍后在工作空间目录中完成。
|
||||
{t('onboarding.agent.soulHint')}
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -377,17 +393,17 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
{step === 3 && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
工作空间
|
||||
{t('onboarding.workspace.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
选择工作空间目录,或使用默认路径。
|
||||
{t('onboarding.workspace.body')}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2, alignItems: 'center' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
value={workspacePath}
|
||||
onChange={(e) => setWorkspacePath(e.target.value)}
|
||||
placeholder="~/MetonaWorkspaces/default/"
|
||||
placeholder={t('onboarding.workspace.placeholder')}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
@@ -401,17 +417,17 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
} catch (err) {
|
||||
console.error('[OnboardingWizard]', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error('选择文件夹失败'))
|
||||
.then((mod) => mod.default.error(t('onboarding.toast.folderFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
选择文件夹
|
||||
{t('onboarding.workspace.pick')}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
包含 SOUL.md、MEMORY.md 两个必需文件,首次打开时自动创建。
|
||||
{t('onboarding.workspace.helper')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -419,12 +435,12 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<CheckCircle size={48} style={{ color: '#34d399', margin: '0 auto 16px' }} />
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
配置完成!
|
||||
{t('onboarding.done.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
|
||||
MetonaAI Desktop 已准备就绪。开始与你的 AI Agent 对话吧!
|
||||
{t('onboarding.done.body')}
|
||||
</Typography>
|
||||
<Typography variant="caption">按 Ctrl+Enter 发送消息,输入 / 查看命令列表</Typography>
|
||||
<Typography variant="caption">{t('onboarding.done.hint')}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
@@ -445,7 +461,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
size="small"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
>
|
||||
上一步
|
||||
{t('onboarding.prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
@@ -453,7 +469,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
onClick={handleNext}
|
||||
size="small"
|
||||
>
|
||||
{step === STEPS.length - 1 ? '开始使用' : '下一步'}
|
||||
{step === STEPS.length - 1 ? t('onboarding.finish') : t('onboarding.next')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
|
||||
@@ -23,6 +23,8 @@ export function AgentSettings() {
|
||||
const [timeout, setTimeout_] = useConfig('agent.totalTimeoutMs', 600000);
|
||||
const [thinking, setThinking] = useConfig('agent.enableThinking', true);
|
||||
const [thinkingEffort, setThinkingEffort] = useConfig('agent.thinkingEffort', 'high');
|
||||
// v0.7.3 P3-1: REFLECTING 状态开关(引擎侧 agent.enableReflection,默认关闭)
|
||||
const [reflection, setReflection] = useConfig('agent.enableReflection', false);
|
||||
const [confirmTimeout, setConfirmTimeout] = useConfig('agent.confirmationTimeoutMs', 120000);
|
||||
const [toolExecTimeout, setToolExecTimeout] = useConfig('agent.toolExecutionTimeoutMs', 120000);
|
||||
|
||||
@@ -107,6 +109,27 @@ export function AgentSettings() {
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
{/* v0.7.3 P3-1: enableReflection 接线(此前为死配置)— 引擎 REFLECTING 状态开关 */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={reflection}
|
||||
onChange={(e) => setReflection(e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
启用反思阶段(REFLECTING)
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ display: 'block', color: 'text.disabled', fontSize: 10 }}
|
||||
>
|
||||
每轮工具执行后进入反思状态,存在失败结果时记录告警(不阻断执行)
|
||||
</Typography>
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ import {
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { PROVIDER_LABELS } from '@renderer/lib/constants';
|
||||
// v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
import { PROVIDER_URLS } from './useConfig';
|
||||
|
||||
/** v0.7.2 P3-9: 动态模型条目(渲染端子集,与 global.d.ts MetonaModelInfoLite 对齐) */
|
||||
@@ -99,9 +102,11 @@ export function LLMSettings() {
|
||||
if (r.success && r.data) {
|
||||
setModelOptions(r.data);
|
||||
setModelsLoaded(true);
|
||||
// v0.7.3 P1-4: 同步能力探测缓存(Ollama supportsVision → 上传门控数据源)
|
||||
useAgentStore.getState().applyModelCapabilities(r.data);
|
||||
} else {
|
||||
setModelOptions([]);
|
||||
setModelsError(r.error ?? '获取模型列表失败');
|
||||
setModelsError(r.error ?? t('llm.models.failed'));
|
||||
}
|
||||
} catch (err) {
|
||||
setModelOptions([]);
|
||||
@@ -115,21 +120,21 @@ export function LLMSettings() {
|
||||
const name = pullModelName.trim();
|
||||
if (!name || pulling || !window.metona?.llm?.pullModel) return;
|
||||
setPulling(true);
|
||||
setPullStatus({ label: '连接 Ollama 服务…', percent: null });
|
||||
setPullStatus({ label: t('llm.pull.title'), percent: null });
|
||||
try {
|
||||
const r = await window.metona.llm.pullModel(name);
|
||||
if (r.success) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.success(`模型 ${name} 下载完成`))
|
||||
.then((mod) => mod.default.success(t('llm.pull.done', { name })))
|
||||
.catch(() => {});
|
||||
} else if (!r.aborted) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`模型下载失败:${r.error ?? '未知错误'}`))
|
||||
.then((mod) => mod.default.error(t('llm.pull.failed', { message: r.error ?? 'unknown' })))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`模型下载失败:${(err as Error).message}`))
|
||||
.then((mod) => mod.default.error(t('llm.pull.failed', { message: (err as Error).message })))
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
// onOllamaPullEnded 也会复位(时序竞态下双保险),此处兜底同步复位
|
||||
@@ -182,7 +187,7 @@ export function LLMSettings() {
|
||||
setBalance(r.data);
|
||||
} else {
|
||||
setBalance(null);
|
||||
setBalanceError(r.error ?? '查询失败');
|
||||
setBalanceError(r.error ?? t('llm.balance.queryFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
setBalance(null);
|
||||
@@ -344,7 +349,7 @@ export function LLMSettings() {
|
||||
const handleSave = async () => {
|
||||
if (hasBlockingError) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error('请修正表单中的错误后再保存'))
|
||||
.then((mod) => mod.default.error(t('llm.save.blockedToast')))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
@@ -353,7 +358,7 @@ export function LLMSettings() {
|
||||
const setBatch = window.metona?.config?.setBatch;
|
||||
if (!setBatch) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error('配置 API 不可用'))
|
||||
.then((mod) => mod.default.error(t('llm.api.unavailable')))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
@@ -380,13 +385,13 @@ export function LLMSettings() {
|
||||
const r = await setBatch(entries);
|
||||
if (r && !r.success) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r.error ?? '配置保存失败'))
|
||||
.then((mod) => mod.default.error(r.error ?? t('llm.save.failed.generic')))
|
||||
.catch(() => {});
|
||||
} else {
|
||||
// v0.5.4: 保存成功后同步多模态开关到 Agent Store(立即生效,控制上传入口)
|
||||
useAgentStore.getState().setMultimodalEnabled(multimodalEnabled);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.success('配置已保存'))
|
||||
.then((mod) => mod.default.success(t('llm.save.success')))
|
||||
.catch(() => {});
|
||||
// v0.7.2 P3-9: 保存成功后配置与引擎 adapter 一致 —— 若列表已加载过则静默刷新,
|
||||
// 保证候选列表与刚保存的 Provider/BaseURL 同源
|
||||
@@ -405,10 +410,10 @@ export function LLMSettings() {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
LLM 配置
|
||||
{t('llm.title')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
加载中...
|
||||
{t('llm.loading')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
@@ -419,13 +424,13 @@ export function LLMSettings() {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
LLM 配置
|
||||
{t('llm.title')}
|
||||
</Typography>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Provider</InputLabel>
|
||||
<InputLabel>{t('llm.provider.label')}</InputLabel>
|
||||
<Select
|
||||
value={provider}
|
||||
label="Provider"
|
||||
label={t('llm.provider.label')}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
>
|
||||
<MenuItem value="deepseek">DeepSeek</MenuItem>
|
||||
@@ -438,12 +443,12 @@ export function LLMSettings() {
|
||||
</FormControl>
|
||||
<TextField
|
||||
size="small"
|
||||
label="API Base URL"
|
||||
label={t('llm.baseURL')}
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
placeholder="如 https://api.deepseek.com"
|
||||
error={urlError}
|
||||
helperText={urlError ? '需以 http:// 或 https:// 开头' : ' '}
|
||||
helperText={urlError ? t('llm.baseURL.error') : ' '}
|
||||
/>
|
||||
{/* v0.7.2 P3-9: 模型名称 — 自由输入 + 动态模型列表候选(freeSolo) */}
|
||||
<Autocomplete
|
||||
@@ -495,17 +500,17 @@ export function LLMSettings() {
|
||||
<TextField
|
||||
{...params}
|
||||
size="small"
|
||||
label="模型名称"
|
||||
placeholder="如 deepseek-v4-pro、gpt-4o、claude-sonnet-4-5"
|
||||
label={t('llm.model.label')}
|
||||
placeholder={t('llm.model.placeholder')}
|
||||
error={modelHasSpace}
|
||||
helperText={
|
||||
modelHasSpace
|
||||
? '模型名称不能包含空格'
|
||||
? t('llm.model.space')
|
||||
: modelsError
|
||||
? modelsError
|
||||
: modelsLoaded
|
||||
? ' '
|
||||
: '可手动输入,或点击下方按钮获取模型列表'
|
||||
: t('llm.models.hint.unloaded')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -519,10 +524,10 @@ export function LLMSettings() {
|
||||
startIcon={modelsLoading ? <CircularProgress size={12} /> : undefined}
|
||||
sx={{ minWidth: 120, fontSize: 11 }}
|
||||
>
|
||||
获取模型列表
|
||||
{t('llm.models.load')}
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
|
||||
基于「已保存」的配置查询(修改后请先保存)
|
||||
{t('llm.models.hint')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
|
||||
@@ -536,10 +541,9 @@ export function LLMSettings() {
|
||||
}
|
||||
label={
|
||||
<Stack>
|
||||
<Typography variant="body2">启用多模态(图片输入)</Typography>
|
||||
<Typography variant="body2">{t('llm.multimodal.label')}</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||
开启后可在输入框上传图片;DeepSeek 需 vision
|
||||
系列模型。历史会话图片会随上下文回传(最近 10 张)
|
||||
{t('llm.multimodal.helper')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
@@ -549,7 +553,7 @@ export function LLMSettings() {
|
||||
<>
|
||||
<TextField
|
||||
size="small"
|
||||
label="API Key"
|
||||
label={t('llm.apiKey.label')}
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
@@ -557,7 +561,7 @@ export function LLMSettings() {
|
||||
error={apiKeyEmpty}
|
||||
helperText={
|
||||
apiKeyEmpty
|
||||
? `必填,未填 ${PROVIDER_LABELS[provider] ?? provider} 的 API Key 会 401`
|
||||
? t('llm.apiKey.required', { provider: PROVIDER_LABELS[provider] ?? provider })
|
||||
: ' '
|
||||
}
|
||||
slotProps={{
|
||||
@@ -575,17 +579,17 @@ export function LLMSettings() {
|
||||
{provider === 'ollama' && (
|
||||
<TextField
|
||||
size="small"
|
||||
label="上下文长度 (num_ctx)"
|
||||
label={t('llm.numCtx.label')}
|
||||
type="number"
|
||||
value={numCtx ?? ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setNumCtx(v === '' ? null : Number(v));
|
||||
}}
|
||||
placeholder="默认由模型决定(如 2048、4096、128000)"
|
||||
placeholder={t('llm.numCtx.placeholder')}
|
||||
slotProps={{ htmlInput: { min: 512, step: 512 } }}
|
||||
error={numCtxError}
|
||||
helperText={numCtxError ? '最小值为 512' : ' '}
|
||||
helperText={numCtxError ? t('llm.numCtx.error') : ' '}
|
||||
/>
|
||||
)}
|
||||
{/* ===== v0.7.2 P3-10: Ollama 模型下载(adapter.pullModel 首次接线 UI) ===== */}
|
||||
@@ -601,7 +605,7 @@ export function LLMSettings() {
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||
下载 Ollama 模型
|
||||
{t('llm.pull.title')}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<TextField
|
||||
@@ -609,7 +613,7 @@ export function LLMSettings() {
|
||||
fullWidth
|
||||
value={pullModelName}
|
||||
onChange={(e) => setPullModelName(e.target.value)}
|
||||
placeholder="模型名,如 qwen3:8b"
|
||||
placeholder={t('llm.pull.placeholder')}
|
||||
disabled={pulling}
|
||||
slotProps={{ input: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
|
||||
/>
|
||||
@@ -621,7 +625,7 @@ export function LLMSettings() {
|
||||
onClick={handlePullCancel}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
取消
|
||||
{t('llm.pull.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -631,7 +635,7 @@ export function LLMSettings() {
|
||||
disabled={!pullModelName.trim()}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
下载
|
||||
{t('llm.pull.download')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -653,7 +657,7 @@ export function LLMSettings() {
|
||||
</Stack>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
|
||||
从 Ollama 服务拉取模型(大模型下载耗时取决于网络,可随时取消);完成后获取模型列表即可见
|
||||
{t('llm.pull.helper')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
@@ -661,14 +665,14 @@ export function LLMSettings() {
|
||||
{provider === 'deepseek' && (
|
||||
<TextField
|
||||
size="small"
|
||||
label="上下文窗口 (contextWindow)"
|
||||
label={t('llm.ctxWindow.label')}
|
||||
type="number"
|
||||
value={dsCtxWindow}
|
||||
onChange={(e) => setDsCtxWindow(Number(e.target.value) || 1000000)}
|
||||
placeholder="如 64000、128000、1000000"
|
||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||
error={dsCtxError}
|
||||
helperText={dsCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'}
|
||||
helperText={dsCtxError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.helper')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -688,17 +692,20 @@ export function LLMSettings() {
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
账户余额
|
||||
{t('llm.balance.label')}
|
||||
</Typography>
|
||||
{balanceLoading ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
查询中...
|
||||
{t('llm.balance.querying')}
|
||||
</Typography>
|
||||
) : balance ? (
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
|
||||
{balance.totalBalance} {balance.currency}
|
||||
<Typography component="span" variant="caption" sx={{ color: 'text.disabled', ml: 1 }}>
|
||||
(赠送 {balance.grantedBalance} + 充值 {balance.toppedUpBalance})
|
||||
{t('llm.balance.granted', {
|
||||
granted: balance.grantedBalance,
|
||||
topped: balance.toppedUpBalance,
|
||||
})}
|
||||
</Typography>
|
||||
</Typography>
|
||||
) : (
|
||||
@@ -706,7 +713,7 @@ export function LLMSettings() {
|
||||
variant="caption"
|
||||
sx={{ color: balanceError ? 'error.main' : 'text.disabled' }}
|
||||
>
|
||||
{balanceError ?? '未查询(需已保存 API Key)'}
|
||||
{balanceError ?? t('llm.balance.unqueried')}
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
@@ -716,76 +723,78 @@ export function LLMSettings() {
|
||||
disabled={balanceLoading || !apiKey.trim()}
|
||||
sx={{ ml: 'auto', minWidth: 64, fontSize: 11 }}
|
||||
>
|
||||
刷新
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
{provider === 'agnes' && (
|
||||
<TextField
|
||||
size="small"
|
||||
label="上下文窗口 (contextWindow)"
|
||||
label={t('llm.ctxWindow.label')}
|
||||
type="number"
|
||||
value={agnesCtxWindow}
|
||||
onChange={(e) => setAgnesCtxWindow(Number(e.target.value) || 1000000)}
|
||||
placeholder="如 64000、128000、1000000"
|
||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||
error={agnesCtxError}
|
||||
helperText={agnesCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'}
|
||||
helperText={agnesCtxError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.helper')}
|
||||
/>
|
||||
)}
|
||||
{provider === 'mimo' && (
|
||||
<TextField
|
||||
size="small"
|
||||
label="上下文窗口 (contextWindow)"
|
||||
label={t('llm.ctxWindow.label')}
|
||||
type="number"
|
||||
value={mimoCtxWindow}
|
||||
onChange={(e) => setMimoCtxWindow(Number(e.target.value) || 1000000)}
|
||||
placeholder="如 65536、131072、1000000"
|
||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||
error={mimoCtxError}
|
||||
helperText={mimoCtxError ? '最小值为 4096' : '默认 1000000(1M),用于上下文压缩判断'}
|
||||
helperText={mimoCtxError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.mimoHelper')}
|
||||
/>
|
||||
)}
|
||||
{provider === 'openai' && (
|
||||
<TextField
|
||||
size="small"
|
||||
label="上下文窗口 (contextWindow)"
|
||||
label={t('llm.ctxWindow.label')}
|
||||
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'}
|
||||
helperText={oaCtxError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.oaHelper')}
|
||||
/>
|
||||
)}
|
||||
{provider === 'anthropic' && (
|
||||
<TextField
|
||||
size="small"
|
||||
label="上下文窗口 (contextWindow)"
|
||||
label={t('llm.ctxWindow.label')}
|
||||
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'}
|
||||
helperText={
|
||||
anthropicCtxError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.anthropicHelper')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ===== P1: 故障转移 Provider(主 Provider 请求失败时自动切换) ===== */}
|
||||
<Divider />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
故障转移(可选)
|
||||
{t('llm.fallback.title')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
主 Provider 请求失败(重试耗尽或密钥失效)时自动切换到备用 Provider 重发。留空禁用。
|
||||
{t('llm.fallback.helper')}
|
||||
</Typography>
|
||||
<FormControl size="small">
|
||||
<InputLabel>备用 Provider</InputLabel>
|
||||
<InputLabel>{t('llm.fallback.provider')}</InputLabel>
|
||||
<Select
|
||||
value={fbProvider}
|
||||
label="备用 Provider"
|
||||
label={t('llm.fallback.provider')}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setFbProvider(v);
|
||||
@@ -795,7 +804,7 @@ export function LLMSettings() {
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>禁用</em>
|
||||
<em>{t('llm.fallback.disabled')}</em>
|
||||
</MenuItem>
|
||||
<MenuItem value="deepseek">DeepSeek</MenuItem>
|
||||
<MenuItem value="agnes">Agnes AI</MenuItem>
|
||||
@@ -809,14 +818,14 @@ export function LLMSettings() {
|
||||
<>
|
||||
<TextField
|
||||
size="small"
|
||||
label="备用 Base URL"
|
||||
label={t('llm.fallback.baseURL')}
|
||||
value={fbBaseURL}
|
||||
onChange={(e) => setFbBaseURL(e.target.value)}
|
||||
placeholder="如 https://api.deepseek.com"
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="备用模型名称"
|
||||
label={t('llm.fallback.model')}
|
||||
value={fbModel}
|
||||
onChange={(e) => setFbModel(e.target.value)}
|
||||
placeholder="如 deepseek-v4-flash"
|
||||
@@ -824,7 +833,7 @@ export function LLMSettings() {
|
||||
{fbProvider !== 'ollama' && (
|
||||
<TextField
|
||||
size="small"
|
||||
label="备用 API Key"
|
||||
label={t('llm.fallback.apiKey')}
|
||||
type={showFbKey ? 'text' : 'password'}
|
||||
value={fbApiKey}
|
||||
onChange={(e) => setFbApiKey(e.target.value)}
|
||||
@@ -852,11 +861,11 @@ export function LLMSettings() {
|
||||
disabled={saving || hasBlockingError}
|
||||
startIcon={saving ? <CircularProgress size={12} /> : undefined}
|
||||
>
|
||||
{saving ? '保存中...' : '保存配置'}
|
||||
{saving ? t('llm.saving') : t('llm.save')}
|
||||
</Button>
|
||||
{hasBlockingError && (
|
||||
<Typography variant="caption" sx={{ color: 'error.main', fontSize: 11 }}>
|
||||
请修正表单错误后再保存
|
||||
{t('llm.save.blocked')}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -22,11 +22,12 @@ import {
|
||||
InputLabel,
|
||||
FormControl,
|
||||
} from '@mui/material';
|
||||
import { Folder, Copy } from 'lucide-react';
|
||||
import { Folder, Copy, RefreshCw } from 'lucide-react';
|
||||
import { useConfig } from './useConfig';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
import { formatFileSize } from '@renderer/lib/formatters';
|
||||
|
||||
export function LogsSettings() {
|
||||
const [logLevel, setLogLevel] = useConfig('logging.level', 'info');
|
||||
@@ -315,6 +316,12 @@ export function LogsSettings() {
|
||||
{clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'}
|
||||
</Button>
|
||||
|
||||
{/* v0.7.3 P3-3: JSONL 录制文件生命周期(统计 + 手动清理) */}
|
||||
<TraceFilesPanel />
|
||||
|
||||
{/* v0.7.3 P3-4: SLO / 健康快照可视化(只读 + 手动刷新) */}
|
||||
<HealthSnapshotPanel />
|
||||
|
||||
{/* L-11 修复(审计补充): 清理数据确认 Dialog(替代原生 confirm()) */}
|
||||
<Dialog
|
||||
open={confirmClear !== null}
|
||||
@@ -340,3 +347,251 @@ export function LogsSettings() {
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== v0.7.3 P3-3: JSONL 录制文件统计与清理 =====
|
||||
|
||||
function TraceFilesPanel(): React.JSX.Element {
|
||||
const [stats, setStats] = useState<{ count: number; totalBytes: number } | null>(null);
|
||||
const [pruning, setPruning] = useState(false);
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const r = await window.metona?.logs?.traceStats();
|
||||
if (r?.success && r.data) setStats(r.data);
|
||||
} catch (err) {
|
||||
console.error('[LogsSettings] traceStats failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadStats();
|
||||
}, []);
|
||||
|
||||
const handlePrune = async () => {
|
||||
setPruning(true);
|
||||
try {
|
||||
const r = await window.metona?.logs?.pruneTraceFiles();
|
||||
if (r?.success) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.success(`已清理 ${r.data?.deleted ?? 0} 个旧录制文件(保留最近 200 个)`),
|
||||
)
|
||||
.catch(() => {});
|
||||
void loadStats();
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r?.error ?? '清理失败'))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`清理失败:${(err as Error).message}`))
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
setPruning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
会话录制文件(TRACE JSONL)
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>
|
||||
{stats
|
||||
? `${stats.count} 个文件 · 共 ${formatFileSize(stats.totalBytes)}(保留策略:最近 200 个,启动时自动清理)`
|
||||
: '统计中...'}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<RefreshCw size={14} />}
|
||||
onClick={() => void loadStats()}
|
||||
>
|
||||
刷新统计
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={handlePrune}
|
||||
disabled={pruning || !stats || stats.count === 0}
|
||||
>
|
||||
{pruning ? '清理中...' : '清理旧录制文件'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== v0.7.3 P3-4: SLO / 健康快照可视化 =====
|
||||
|
||||
function HealthSnapshotPanel(): React.JSX.Element {
|
||||
const [snapshot, setSnapshot] = useState<MetonaHealthSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [chainResult, setChainResult] = useState<{
|
||||
valid: boolean;
|
||||
verified: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
const loadSnapshot = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await window.metona?.app?.getHealthSnapshot();
|
||||
if (r?.success && r.data) setSnapshot(r.data);
|
||||
} catch (err) {
|
||||
console.error('[LogsSettings] healthSnapshot failed:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadSnapshot();
|
||||
}, []);
|
||||
|
||||
const handleVerifyChain = async () => {
|
||||
try {
|
||||
const r = await window.metona?.audit?.verifyChain();
|
||||
if (r?.success) {
|
||||
setChainResult({
|
||||
valid: r.valid,
|
||||
verified: r.verifiedRecords,
|
||||
total: r.totalRecords,
|
||||
});
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r?.error ?? '校验失败'))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`校验失败:${(err as Error).message}`))
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const fmtPct = (v: number): string => `${(v * 100).toFixed(2)}%`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack direction="row" sx={{ alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
运行健康(SLO / 健康检查)
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ ml: 'auto', fontSize: 10, minWidth: 60 }}
|
||||
startIcon={<RefreshCw size={12} />}
|
||||
onClick={() => void loadSnapshot()}
|
||||
disabled={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Stack>
|
||||
{!snapshot ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 11 }}>
|
||||
{loading ? '加载中...' : '暂无快照'}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'secondary.main',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={0.5}>
|
||||
<HealthRow
|
||||
label="请求错误率(5 分钟窗口)"
|
||||
value={`${fmtPct(snapshot.slo.errorRate)}(${snapshot.slo.totalRequests} 次请求)`}
|
||||
warn={snapshot.slo.violated}
|
||||
/>
|
||||
<HealthRow
|
||||
label="延迟 P50 / P95 / P99"
|
||||
value={`${Math.round(snapshot.slo.percentiles['P50'] ?? 0)} / ${Math.round(
|
||||
snapshot.slo.percentiles['P95'] ?? 0,
|
||||
)} / ${Math.round(snapshot.slo.percentiles['P99'] ?? 0)} ms`}
|
||||
/>
|
||||
<HealthRow
|
||||
label="SLO 燃烧速率(目标 99.9%)"
|
||||
value={snapshot.slo.burnRate.toFixed(2)}
|
||||
warn={snapshot.slo.violated}
|
||||
/>
|
||||
<HealthRow
|
||||
label="健康检查"
|
||||
value={
|
||||
snapshot.health
|
||||
? snapshot.health.healthy
|
||||
? '全部通过'
|
||||
: `异常:${snapshot.health.checks
|
||||
.filter((c) => !c.healthy)
|
||||
.map((c) => c.name)
|
||||
.join(', ')}`
|
||||
: '启动后尚未执行(周期 60s)'
|
||||
}
|
||||
warn={snapshot.health ? !snapshot.health.healthy : false}
|
||||
/>
|
||||
{chainResult && (
|
||||
<HealthRow
|
||||
label="审计日志链校验"
|
||||
value={
|
||||
chainResult.valid
|
||||
? `通过(${chainResult.verified}/${chainResult.total} 条)`
|
||||
: `已篡改!首个异常记录 #${chainResult.total}`
|
||||
}
|
||||
warn={!chainResult.valid}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ mt: 1, fontSize: 10, minWidth: 100 }}
|
||||
onClick={handleVerifyChain}
|
||||
>
|
||||
校验审计日志链
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthRow({
|
||||
label,
|
||||
value,
|
||||
warn,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
warn?: boolean;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Stack direction="row" sx={{ alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
ml: 'auto',
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
color: warn ? 'error.main' : 'text.primary',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,9 +131,16 @@ export function MCPSettings() {
|
||||
const statusColors: Record<string, string> = {
|
||||
connected: 'success.main',
|
||||
connecting: 'warning.main',
|
||||
// v0.7.3 P4-2: reconnecting —— 自动重连排程中(信息态,非故障)
|
||||
reconnecting: 'info.main',
|
||||
disconnected: 'text.secondary',
|
||||
error: 'error.main',
|
||||
};
|
||||
/** v0.7.3 P4-2: reconnecting 状态显示第 N/3 次尝试 */
|
||||
const statusLabel = (s: { status: string; reconnectAttempt?: number }): string =>
|
||||
s.status === 'reconnecting' && s.reconnectAttempt
|
||||
? `${s.status} (${s.reconnectAttempt}/3)`
|
||||
: s.status;
|
||||
|
||||
// L-11 修复: 确认移除 MCP 服务
|
||||
// 审计补充修复: 添加 try/catch,避免 removeServer reject 时 Dialog 卡死无法关闭
|
||||
@@ -185,7 +192,7 @@ export function MCPSettings() {
|
||||
{s.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: statusColors[s.status] }}>
|
||||
{s.status}
|
||||
{statusLabel(s)}
|
||||
</Typography>
|
||||
{s.toolCount > 0 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
|
||||
Reference in New Issue
Block a user