feat: v0.7.3 成本收口 · 状态一致 · 死账清理 — Prompt Cache 根治 + SSRF DNS Pinning + 87 用例扩充全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m47s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 9m55s

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:
2026-08-30 09:44:43 +08:00
parent 26169b7be4
commit ebe45482b0
68 changed files with 4568 additions and 664 deletions
+42 -29
View File
@@ -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>
+13
View File
@@ -96,6 +96,19 @@ export function Sidebar(): React.JSX.Element {
};
}, []);
// v0.7.3 P4-1: 主进程 LLM 标题生成完成后经 config:changed 广播
// (合成 keysession.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();
+206 -61
View File
@@ -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>
+52 -36
View File
@@ -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.mdMEMORY.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
View File
@@ -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>
);
}
+70 -61
View File
@@ -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' : '默认 10000001M),用于上下文压缩判断'}
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 默认 128Kgpt-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>
+256 -1
View File
@@ -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>
);
}
+8 -1
View File
@@ -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' }}>