feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s

硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。

P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层

P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
  存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
  原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
  上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}

P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)

Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。

验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
This commit is contained in:
2026-09-08 09:35:58 +08:00
parent 839860083f
commit 9b45c445bf
85 changed files with 5286 additions and 1158 deletions
+22
View File
@@ -173,6 +173,28 @@ export function ConfirmationDialog(): React.JSX.Element | null {
return cleanup;
}, [refreshPending]);
// ===== v0.8.1 P2-2: 批量确认事件 —— 主进程聚合(>=3 同类并行请求)时单事件
// 携带全部请求;复用 refreshPending 拉取完整列表(倒计时按最新请求重置) =====
useEffect(() => {
if (!window.metona?.tool?.onConfirmationRequestBatch) return;
const cleanup = window.metona.tool.onConfirmationRequestBatch((requests: unknown[]) => {
if (requests.length === 0) return;
const req = requests[0] as ConfirmationRequest;
refreshPending(req);
setRemember(false);
setAutoExecute(false);
const expires = Math.min(
...requests.map((r) => (r as ConfirmationRequest).expiresAt ?? 0).filter((e) => e > 0),
);
if (expires > 0) {
const remain = Math.max(0, expires - Date.now());
setRemainingMs(remain);
setInitialMs(remain);
}
});
return cleanup;
}, [refreshPending]);
// ===== 监听 Agent 状态变化:INIT(新 run 开始)或 TERMINATEDrun 结束/abort)时清空该会话的前端 state =====
// 解决:abort 场景下后端 clearPending() 清空了 Map,但前端 requests state 不会自动同步,
// 弹框会停留在已失效的请求上。用户操作后批量 IPC 返回 0 resolved,逻辑无害但 UX 差。
+197 -9
View File
@@ -26,6 +26,7 @@ import { Send, Paperclip, Square, X, FileText, Image as ImageIcon, AtSign } from
import Fuse from 'fuse.js';
import { nanoid } from 'nanoid';
import { useAgentStore } from '@renderer/stores/agent-store';
import { formatTokens } from '@renderer/lib/formatters';
import { useSessionStore } from '@renderer/stores/session-store';
import { useUIStore } from '@renderer/stores/ui-store';
import { formatFileSize } from '@renderer/lib/formatters';
@@ -109,6 +110,16 @@ export function ChatInput(): React.JSX.Element {
const [mentionIndex, setMentionIndex] = useState(0);
const [workspaceFiles, setWorkspaceFiles] = useState<string[]>([]);
const workspaceFilesLoadedRef = useRef(false);
// ===== v0.8.1 P1-4: MCP Prompts / Resources 对话内可用化 =====
/** 已连接 server 的 prompts(斜杠菜单数据源) */
const [mcpPrompts, setMcpPrompts] = useState<
Array<{ server: string; name: string; description?: string }>
>([]);
/** 已连接 server 的 resources@ 提及数据源) */
const [mcpResources, setMcpResources] = useState<
Array<{ server: string; uri: string; name: string }>
>([]);
const mcpContentsLoadedRef = useRef(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const sendMessage = useAgentStore((s) => s.sendMessage);
@@ -123,6 +134,10 @@ export function ChatInput(): React.JSX.Element {
// v0.5.4: 多模态总开关(llm.multimodalEnabled,设置/引导向导中配置)
const multimodalEnabled = useAgentStore((s) => s.multimodalEnabled);
const modelVisionCaps = useAgentStore((s) => s.modelVisionCaps);
// v0.8.1 P1-3: 输入框上下文占用指示条(UI/UX 设计预留项落地 —— 单次占用 /
// 设置面板「上下文长度」,60%/80% 变色;窗口未配置或无占用数据时隐藏)
const contextWindow = useAgentStore((s) => s.contextWindow);
const lastInputTokens = useAgentStore((s) => s.tokenUsage.lastInputTokens);
const imageGate = supportsImageUpload({
multimodalEnabled,
provider,
@@ -321,12 +336,18 @@ export function ChatInput(): React.JSX.Element {
if (i > 0 && /\w/.test(text[i - 1])) continue;
let j = i + 1;
let path = '';
while (j < text.length && /[A-Za-z0-9._\-/]/.test(text[j])) {
// v0.8.1 P1-4: token 集合扩展 ':' 以承载 @mcp:{server}:{uri} 引用
while (j < text.length && /[A-Za-z0-9._\-/:]/.test(text[j])) {
path += text[j];
j++;
}
const isMcpRef = path.startsWith('mcp:');
path = path.replace(/\.+$/, '');
if (path && (path.includes('/') || path.includes('.'))) out.push(path);
if (isMcpRef && path.length > 'mcp:'.length + 1) {
out.push(path);
} else if (path && (path.includes('/') || path.includes('.'))) {
out.push(path);
}
i = j - 1;
}
return [...new Set(out)];
@@ -375,6 +396,35 @@ export function ChatInput(): React.JSX.Element {
setShowSlashMenu(false);
return;
}
// v0.8.1 P1-4: /mcp:{server}:{prompt} — MCP Prompt 渲染填充输入框。
// v0.8.1 review 修复: cmd 已被 toLowerCase() —— server 名含大写时直接取
// 子串会与 MCPManager 的原始名 Map key 失配("not connected")。改为对
// mcpPrompts 清单做大小写不敏感匹配,反查原始 server/prompt 名。
if (cmd.startsWith('/mcp:')) {
const wanted = cmd.slice('/mcp:'.length); // 小写化后的 "server:prompt"
const match = mcpPrompts.find((p) => `${p.server}:${p.name}`.toLowerCase() === wanted);
if (match) {
const res = await window.metona?.mcp?.getPrompt?.(match.server, match.name);
if (res?.success && res.data?.text) {
setInput(res.data.text);
} else {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.warning(
t('input.slash.mcpPromptFailed', {
prompt: `${match.server}:${match.name}`,
reason: res?.error ?? '',
}),
),
)
.catch(() => {});
setInput('');
}
setAttachments([]);
setShowSlashMenu(false);
return;
}
}
// v0.3.0: /memory — 切换到详情面板的 Memory 标签
// v0.3.0 修复:确保详情面板可见,否则切换标签用户看不到
if (cmd === '/memory') {
@@ -430,6 +480,51 @@ export function ChatInput(): React.JSX.Element {
}> = [];
const mentionedPaths = extractMentions(messageContent).slice(0, 5);
for (const p of mentionedPaths) {
// v0.8.1 P1-4: MCP resource 引用分流 —— mcp:{server}:{uri} → resources/read
//(512KB 上限由主进程统一截断,二进制拒绝);其余走工作空间文件片段
if (p.startsWith('mcp:')) {
const rest = p.slice('mcp:'.length);
const sepIdx = rest.indexOf(':');
const mcpServer = sepIdx > 0 ? rest.slice(0, sepIdx) : '';
const mcpUri = sepIdx > 0 ? rest.slice(sepIdx + 1) : '';
try {
const res = await window.metona?.mcp?.readResource?.(mcpServer, mcpUri);
if (res?.success && res.data) {
let textContent = res.data.text;
if (res.data.truncated) {
textContent += t('input.text.truncatedNote', {
size: MAX_TEXT_ATTACHMENT_BYTES,
limit: formatFileSize(MAX_TEXT_ATTACHMENT_BYTES),
});
}
mentionInfos.push({
id: `mcp_${nanoid(6)}`,
name: p,
type: 'text',
size: res.data.text.length,
textContent,
truncated: res.data.truncated,
});
} else {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.warning(
t('input.mention.failed', { path: p, reason: res?.error ?? '' }),
),
)
.catch(() => {});
}
} catch (err) {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.warning(
t('input.mention.failed', { path: p, reason: (err as Error).message }),
),
)
.catch(() => {});
}
continue;
}
try {
const clip = await window.metona?.workspace?.readFileClip?.(p);
if (clip?.success && typeof clip.content === 'string') {
@@ -487,15 +582,18 @@ export function ChatInput(): React.JSX.Element {
}, [abort]);
// v0.8.0 P2-4: @ 联想候选(Fuse 模糊匹配;query 为空时按路径字典序取前 8 条)
// v0.8.1 P1-4: 候选池合并 MCP resourcestoken 形态 @mcp:{server}:{uri}
const mentionCandidates = useMemo(() => {
if (workspaceFiles.length === 0) return [];
if (!mentionQuery) return [...workspaceFiles].sort().slice(0, 8);
const fuse = new Fuse(workspaceFiles, { includeScore: true, threshold: 0.4 });
const mcpTokens = mcpResources.map((r) => `mcp:${r.server}:${r.uri}`);
const pool = [...workspaceFiles, ...mcpTokens];
if (pool.length === 0) return [];
if (!mentionQuery) return [...workspaceFiles].sort().slice(0, 8).concat(mcpTokens.slice(0, 4));
const fuse = new Fuse(pool, { includeScore: true, threshold: 0.4 });
return fuse
.search(mentionQuery)
.map((r) => r.item)
.slice(0, 8);
}, [workspaceFiles, mentionQuery]);
}, [workspaceFiles, mcpResources, mentionQuery]);
// v0.8.0 P2-4: 选中候选 —— 把 "@query" 片段替换为 "@path"
const applyMention = useCallback(
@@ -628,6 +726,37 @@ export function ChatInput(): React.JSX.Element {
})
.catch((err) => console.error('[ChatInput] listFiles failed:', err));
}
// v0.8.1 P1-4: 首次触发提及/斜杠时懒加载 MCP prompts/resources(失败静默
// —— server 不支持该能力时菜单自然不出现,不影响文件提及)
if (!mcpContentsLoadedRef.current) {
mcpContentsLoadedRef.current = true;
void window.metona?.mcp
?.listServers?.()
.then(async (servers) => {
const states = (servers ?? []) as Array<{ name: string; status: string }>;
const connected = states.filter((sv) => sv.status === 'connected');
const prompts: Array<{ server: string; name: string; description?: string }> = [];
const resources: Array<{ server: string; uri: string; name: string }> = [];
for (const sv of connected) {
try {
const contents = await window.metona?.mcp?.listServerContents?.(sv.name);
if (contents?.success && contents.data) {
for (const pr of contents.data.prompts ?? []) {
prompts.push({ server: sv.name, name: pr.name, description: pr.description });
}
for (const rs of contents.data.resources ?? []) {
resources.push({ server: sv.name, uri: rs.uri, name: rs.name || rs.uri });
}
}
} catch {
/* 单个 server 失败不影响其他 */
}
}
setMcpPrompts(prompts);
setMcpResources(resources);
})
.catch(() => {});
}
} else {
setMentionOpen(false);
setMentionQuery('');
@@ -636,7 +765,14 @@ export function ChatInput(): React.JSX.Element {
[],
);
const filteredCommands = SLASH_COMMANDS.filter((c) =>
// v0.8.1 P1-4: MCP prompt 动态斜杠命令(/mcp:{server}:{prompt})——
// 与内置命令合并展示;server 未提供 prompts 时自然为空
const mcpCommands = mcpPrompts.map((p) => ({
id: `mcp:${p.server}:${p.name}`,
label: `/mcp:${p.server}:${p.name}`,
description: () => p.description ?? t('input.slash.mcpPrompt'),
}));
const allCommands = [...SLASH_COMMANDS, ...mcpCommands].filter((c) =>
c.label.toLowerCase().includes(`/${slashFilter}`),
);
@@ -656,6 +792,58 @@ export function ChatInput(): React.JSX.Element {
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* v0.8.1 P1-3: 上下文占用指示条(最近一次 LLM 输入占用 / 设置面板「上下文长度」) */}
{contextWindow > 0 && lastInputTokens > 0 && (
<Stack
direction="row"
spacing={1}
sx={{ alignItems: 'center', px: 1.5, pt: 0.75, pb: 0.25 }}
>
<Typography
variant="caption"
sx={{
fontFamily: 'monospace',
fontSize: 10,
color:
lastInputTokens / contextWindow > 0.8
? 'error.main'
: lastInputTokens / contextWindow > 0.6
? 'warning.main'
: 'text.disabled',
}}
>
{t('input.contextIndicator', {
used: formatTokens(lastInputTokens),
total: formatTokens(contextWindow),
percent: ((lastInputTokens / contextWindow) * 100).toFixed(0),
})}
</Typography>
<Box
sx={{
flex: 1,
height: 3,
borderRadius: 2,
bgcolor: 'action.hover',
overflow: 'hidden',
}}
>
<Box
sx={{
height: '100%',
width: `${Math.min((lastInputTokens / contextWindow) * 100, 100)}%`,
bgcolor:
lastInputTokens / contextWindow > 0.8
? 'error.main'
: lastInputTokens / contextWindow > 0.6
? 'warning.main'
: 'info.main',
transition: 'width 300ms',
}}
/>
</Box>
</Stack>
)}
{/* 附件预览区 */}
{attachments.length > 0 && (
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: 'wrap', gap: 1 }}>
@@ -671,7 +859,7 @@ export function ChatInput(): React.JSX.Element {
{/* / 命令菜单 — v0.7.4 P3-7: 手写 div 弹层改为 MUI Paper+List(遵循 MUI 铁律:
禁止自写 UI 交互组件)。悬停/点击/键盘导航由 MUI 组件内建提供。 */}
{showSlashMenu && filteredCommands.length > 0 && (
{showSlashMenu && allCommands.length > 0 && (
<Paper
elevation={8}
sx={{
@@ -690,7 +878,7 @@ export function ChatInput(): React.JSX.Element {
}}
>
<List dense disablePadding>
{filteredCommands.map((cmd) => (
{allCommands.map((cmd) => (
<ListItemButton
key={cmd.id}
onClick={() => {
+6
View File
@@ -37,6 +37,8 @@ export function MessageList(): React.JSX.Element {
const messages = useAgentStore((s) => s.messages);
const isStreaming = useAgentStore((s) => s.isStreaming);
const currentSessionId = useAgentStore((s) => s.currentSessionId);
// v0.8.1 P2-3: 游标分页 —— 滚动到顶部触发向上加载
const loadOlderMessages = useAgentStore((s) => s.loadOlderMessages);
const virtuosoRef = useRef<VirtuosoHandle>(null);
// 用户是否处于列表底部(离开底部=上翻查看历史,此时流式输出不强制拉底)
@@ -104,6 +106,10 @@ export function MessageList(): React.JSX.Element {
atBottomStateChange={(atBottom) => {
atBottomRef.current = atBottom;
}}
// v0.8.1 P2-3: 滚动到顶部 → 游标加载更早消息(store 内部防重入 + 完整性判定)
startReached={() => {
void loadOlderMessages();
}}
itemContent={(index, msg) => (
<Box sx={{ maxWidth: 768, mx: 'auto', px: 2, pt: index === 0 ? 3 : 1.5, pb: 1.5 }}>
<MessageItem
+3 -1
View File
@@ -59,7 +59,9 @@ export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
} catch (err) {
console.error('[UserMessage] save failed:', err);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`保存失败:${(err as Error).message}`))
.then((mod) =>
mod.default.error(t('agent.error.saveFailed', { message: (err as Error).message })),
)
.catch(() => {});
// 保留编辑态,让用户重试或复制内容
}
+218 -2
View File
@@ -18,8 +18,16 @@ import {
Chip,
Alert,
InputAdornment,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Checkbox,
FormControlLabel,
CircularProgress,
} from '@mui/material';
import { Brain, Search, Trash2, ChevronDown } from 'lucide-react';
import { Brain, Search, Trash2, ChevronDown, Wand2 } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useUIStore } from '@renderer/stores/ui-store';
import { formatTime, truncate } from '@renderer/lib/formatters';
@@ -41,6 +49,15 @@ interface MemoryItem {
created_at?: number;
}
/** v0.8.1 P1-2: 维护动作(与主进程 MemoryMaintainer 的类型对齐) */
interface MaintenanceAction {
action: 'delete' | 'update';
section: string;
entry: string;
newEntry?: string;
reason?: string;
}
interface SearchResult {
id: string;
type: MemoryType;
@@ -103,6 +120,16 @@ export function MemoryViewer(): React.JSX.Element {
() => new Set<MemoryType>(MEMORY_TYPES),
);
// ===== v0.8.1 P1-2: 记忆整理(MEMORY.md 维护闭环) =====
const [maintaining, setMaintaining] = useState(false);
const [maintOpen, setMaintOpen] = useState(false);
const [maintActions, setMaintActions] = useState<MaintenanceAction[]>([]);
const [maintSelected, setMaintSelected] = useState<Set<number>>(new Set());
const [maintTotal, setMaintTotal] = useState(0);
/** v0.8.1 review (O2): 各分区条目数(应用选中动作后计算将变空的分区) */
const [maintSectionCounts, setMaintSectionCounts] = useState<Record<string, number>>({});
const [maintError, setMaintError] = useState<string | null>(null);
// Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle
const agentStatus = useAgentStore((s) => s.agentStatus);
const prevStatus = useRef(agentStatus);
@@ -212,6 +239,87 @@ export function MemoryViewer(): React.JSX.Element {
}
};
// ===== v0.8.1 P1-2: 记忆整理流程(阶段一分析 / 阶段二应用) =====
const handleAnalyze = async () => {
if (!window.metona?.memory?.analyzeMaintenance || maintaining) return;
setMaintaining(true);
setMaintError(null);
try {
const res = await window.metona.memory.analyzeMaintenance();
if (!res.success || !res.data) {
setMaintError(res.error ?? t('memory.maintain.failed'));
return;
}
setMaintActions(res.data.actions);
setMaintTotal(res.data.totalEntries);
setMaintSectionCounts(res.data.sectionEntryCounts ?? {});
// 默认全选(用户可取消勾选)
setMaintSelected(new Set(res.data.actions.map((_, i) => i)));
setMaintOpen(true);
} catch (err) {
setMaintError((err as Error).message ?? t('memory.maintain.failed'));
} finally {
setMaintaining(false);
}
};
const handleApply = async () => {
if (!window.metona?.memory?.applyMaintenance) return;
const chosen = maintActions.filter((_, i) => maintSelected.has(i));
if (chosen.length === 0) {
setMaintOpen(false);
return;
}
setMaintaining(true);
try {
const res = await window.metona.memory.applyMaintenance(chosen);
if (!res.success || !res.data) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(res.error ?? t('memory.maintain.failed')))
.catch(() => {});
return;
}
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.success(
t('memory.maintain.applied', {
applied: res.data!.applied,
skipped: res.data!.skipped,
}),
),
)
.catch(() => {});
setMaintOpen(false);
await loadMemories();
} catch (err) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error((err as Error).message ?? t('memory.maintain.failed')))
.catch(() => {});
} finally {
setMaintaining(false);
}
};
// v0.8.1 review (O2): 应用选中动作后将变空的分区(全部条目被删除且无 update 补充)
const emptyAfterApply = (() => {
const remaining: Record<string, number> = { ...maintSectionCounts };
for (const a of maintActions.filter((_, i) => maintSelected.has(i))) {
if (a.action === 'delete') remaining[a.section] = (remaining[a.section] ?? 1) - 1;
}
return Object.entries(remaining)
.filter(([, n]) => n <= 0)
.map(([sec]) => sec);
})();
const toggleMaintAction = (idx: number): void => {
setMaintSelected((prev) => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
};
const totalCount = memories.episodic.length + memories.semantic.length + memories.working.length;
// 搜索结果视图
@@ -332,7 +440,19 @@ export function MemoryViewer(): React.JSX.Element {
>
{t('memory.list.title')}
</Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
<Button
size="small"
variant="text"
onClick={handleAnalyze}
disabled={maintaining}
startIcon={
maintaining ? <CircularProgress size={11} color="inherit" /> : <Wand2 size={12} />
}
sx={{ ml: 'auto', fontSize: 10, minWidth: 0, px: 0.75, flexShrink: 0 }}
>
{t('memory.maintain.button')}
</Button>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10, flexShrink: 0 }}>
{t('memory.total', { count: totalCount })}
</Typography>
</Stack>
@@ -447,6 +567,102 @@ export function MemoryViewer(): React.JSX.Element {
})
)}
</Box>
{/* ===== v0.8.1 P1-2: 记忆整理建议弹框(两阶段:用户勾选后应用) ===== */}
<Dialog open={maintOpen} onClose={() => setMaintOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontSize: 14 }}>{t('memory.maintain.title')}</DialogTitle>
<DialogContent dividers>
{maintError && (
<Alert severity="error" sx={{ mb: 1, fontSize: 11 }}>
{maintError}
</Alert>
)}
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 1 }}>
{t('memory.maintain.summary', { total: maintTotal, actions: maintActions.length })}
</Typography>
{emptyAfterApply.length > 0 && (
<Typography variant="caption" sx={{ color: 'warning.main', display: 'block', mb: 1 }}>
{t('memory.maintain.emptySectionHint', { sections: emptyAfterApply.join('、') })}
</Typography>
)}
{maintActions.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.disabled', py: 2, display: 'block' }}>
{t('memory.maintain.empty')}
</Typography>
) : (
<Stack spacing={0.5}>
{maintActions.map((a, i) => (
<Box
key={`${a.action}-${i}`}
sx={{
px: 1,
py: 0.5,
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
}}
>
<FormControlLabel
control={
<Checkbox
size="small"
checked={maintSelected.has(i)}
onChange={() => toggleMaintAction(i)}
/>
}
label={
<Stack>
<Stack direction="row" sx={{ alignItems: 'center' }}>
<Chip
label={
a.action === 'delete'
? t('memory.maintain.delete')
: t('memory.maintain.merge')
}
size="small"
sx={{
height: 14,
fontSize: 9,
mr: 0.5,
bgcolor: (a.action === 'delete' ? '#ef4444' : '#22d3ee') + '22',
color: a.action === 'delete' ? '#ef4444' : '#22d3ee',
'& .MuiChip-label': { px: 0.5 },
}}
/>
<Typography variant="caption" sx={{ fontSize: 11 }}>
[{a.section}] {truncate(a.entry, 60)}
</Typography>
</Stack>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
{a.action === 'update' && a.newEntry
? `${truncate(a.newEntry, 60)}`
: ''}
{a.reason ? ` · ${a.reason}` : ''}
</Typography>
</Stack>
}
/>
</Box>
))}
</Stack>
)}
</DialogContent>
<DialogActions>
<Button size="small" onClick={() => setMaintOpen(false)}>
{t('common.cancel')}
</Button>
<Button
size="small"
variant="contained"
onClick={handleApply}
disabled={maintaining || maintSelected.size === 0}
>
{maintaining
? t('memory.maintain.applying')
: t('memory.maintain.apply', { count: maintSelected.size })}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
+14 -35
View File
@@ -56,7 +56,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
const [apiKey, setApiKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [workspacePath, setWorkspacePath] = useState('');
// 上下文窗口(联动 Provider:ollama 可空=由模型决定;其他 provider 默认值见 DEFAULT_CTX
// 上下文长度(v0.8.1: 全局单一配置 llm.contextWindow,跨 Provider 生效,不预填默认值
const [contextWindow, setContextWindow] = useState<number | null>(null);
// #48 修复: 启动时从 localStorage 恢复未完成的引导进度,避免中途退出后需重新填写
@@ -112,18 +112,10 @@ export function OnboardingWizard(): React.JSX.Element | null {
if (onboardingCompleted) return null;
// 各 Provider 上下文窗口默认值(与 SettingsModal 保持一致)
// ollama 返回 null(由模型决定),其他 provider 返回正整数
const DEFAULT_CTX: Record<string, number | null> = {
deepseek: 1_000_000,
agnes: 1_000_000,
mimo: 1_000_000,
ollama: null,
openai: 128_000,
anthropic: 200_000,
};
// 上下文窗口校验:ollama 允许空,最小 512;其他 provider 最小 4096
const ctxMin = provider === 'ollama' ? 512 : 4096;
// v0.8.1 硬性契约: 上下文长度是全局单一配置(llm.contextWindow),不再存在
// 分 Provider 默认值 —— 删除了旧的 DEFAULT_CTX 写死表(1M/128K/200K/4096)。
// 引导页不再预填任何窗口数值,留空时由设置面板种子默认值(131072)兜底。
const ctxMin = 512;
const ctxError =
contextWindow != null && (!Number.isFinite(contextWindow) || contextWindow < ctxMin);
@@ -149,13 +141,10 @@ export function OnboardingWizard(): React.JSX.Element | null {
entries.push({ key: 'llm.multimodalEnabled', value: multimodalEnabled });
if (workspacePath.trim())
entries.push({ key: 'workspace.path', value: workspacePath.trim() });
// 上下文窗口:根据 Provider 落库到对应 key
// - ollama: ollama.numCtx(允许 null=由模型决定)
// - deepseek/agnes/mimo: {provider}.contextWindow(必须有值且 >= 4096
if (provider === 'ollama') {
entries.push({ key: 'ollama.numCtx', value: contextWindow });
} else if (provider && contextWindow != null && contextWindow >= 4096) {
entries.push({ key: `${provider}.contextWindow`, value: contextWindow });
// v0.8.1: 上下文长度全局单一配置(llm.contextWindow,跨 Provider 生效;
// Ollama 场景由主进程同步为 num_ctx)—— 分 Provider 键已废除
if (contextWindow != null && contextWindow >= ctxMin) {
entries.push({ key: 'llm.contextWindow', value: contextWindow });
}
entries.push({ key: 'onboarding.completed', value: true });
@@ -259,8 +248,8 @@ export function OnboardingWizard(): React.JSX.Element | null {
onChange={(e) => {
const v = e.target.value;
setProvider(v);
// 联动默认上下文窗口(与 SettingsModal 默认值一致)
setContextWindow(DEFAULT_CTX[v] ?? null);
// v0.8.1: 上下文长度为全局单一配置,切换 Provider 不再预填
// 任何分 Provider 默认值(DEFAULT_CTX 写死表已删除)
// D-3 修复: 自动填充默认 URL(与 LLMSettings 行为一致 —
// 仅在 URL 为空或仍是某 Provider 的默认值时覆盖,用户自定义 URL 不动)
const currentUrl = baseURL.trim();
@@ -313,30 +302,20 @@ export function OnboardingWizard(): React.JSX.Element | null {
/>
<TextField
size="small"
label={
provider === 'ollama'
? t('onboarding.llm.ctx.ollama')
: t('onboarding.llm.ctx.window')
}
label={t('onboarding.llm.ctx.window')}
type="number"
value={contextWindow ?? ''}
onChange={(e) => {
const v = e.target.value;
setContextWindow(v === '' ? null : Number(v));
}}
placeholder={
provider === 'ollama'
? t('onboarding.llm.ctx.ollamaPlaceholder')
: t('onboarding.llm.ctx.placeholder')
}
placeholder={t('onboarding.llm.ctx.placeholder')}
slotProps={{ htmlInput: { min: ctxMin, step: ctxMin } }}
error={ctxError}
helperText={
ctxError
? t('onboarding.llm.ctx.minError', { min: ctxMin })
: provider === 'ollama'
? ' '
: t('onboarding.llm.ctx.helper')
: t('onboarding.llm.ctx.helper')
}
/>
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
+15
View File
@@ -30,6 +30,8 @@ export function AgentSettings() {
const [reflection, setReflection] = useConfig('agent.enableReflection', false);
const [confirmTimeout, setConfirmTimeout] = useConfig('agent.confirmationTimeoutMs', 120000);
const [toolExecTimeout, setToolExecTimeout] = useConfig('agent.toolExecutionTimeoutMs', 120000);
// v0.8.1 P1-1: 本地向量记忆嵌入模型(memory.embeddingModel;空 = 向量检索关闭)
const [embeddingModel, setEmbeddingModel] = useConfig('memory.embeddingModel', '');
const MAX_ITER_OPTIONS = [10, 20, 50, 85, 128, 256, 512];
@@ -133,6 +135,19 @@ export function AgentSettings() {
</Typography>
}
/>
{/* ===== v0.8.1 P1-1: 本地向量记忆(Ollama embedding 模型,空 = 关闭) ===== */}
<Typography variant="subtitle2" sx={{ fontWeight: 600, mt: 1 }}>
{t('settings.agent.memory.title')}
</Typography>
<TextField
size="small"
label={t('settings.agent.memory.embeddingModel')}
value={embeddingModel}
onChange={(e) => setEmbeddingModel(e.target.value)}
placeholder={t('settings.agent.memory.embeddingPlaceholder')}
helperText={t('settings.agent.memory.embeddingHelper')}
/>
</Stack>
);
}
+52 -1
View File
@@ -10,7 +10,8 @@
* - 启动时由 App.tsx 读取 ui.locale 并应用(默认 zh-CN
*/
import { Button, Stack, Typography, Box } from '@mui/material';
import { Button, Stack, Typography, Box, Switch, FormControlLabel } from '@mui/material';
import { useEffect, useState } from 'react';
import type { ThemeMode } from '@renderer/stores/ui-store';
import { useConfig } from './useConfig';
import { setLocale, getLocale, type Locale } from '@renderer/lib/i18n';
@@ -35,6 +36,8 @@ export function AppearanceSettings({
}) {
// v0.7.2 P4-15: 界面语言(ui.locale 全局配置;useConfig 已含失败回滚与竞态保护)
const [locale, setLocaleConfig] = useConfig<Locale>('ui.locale', 'zh-CN');
// ===== v0.8.1 P2-4: 开机自启(app.setLoginItemSettings 封装;读回真实生效状态) =====
const [openAtLogin, setOpenAtLogin] = useState(false);
const handleLocaleChange = (next: Locale): void => {
if (next === getLocale()) return;
@@ -42,6 +45,35 @@ export function AppearanceSettings({
void setLocale(next);
};
useEffect(() => {
window.metona?.app
?.getLoginItem?.()
.then((r) => {
if (r.success && r.data) setOpenAtLogin(r.data.openAtLogin);
})
.catch(() => {});
}, []);
const handleLoginItem = (enabled: boolean): void => {
setOpenAtLogin(enabled); // 乐观更新
window.metona?.app
?.setLoginItem?.(enabled)
.then((r) => {
if (r.success && r.data) {
// 以平台真实生效状态为准(Linux 不可用时保持 false
setOpenAtLogin(r.data.openAtLogin);
if (enabled && !r.data.openAtLogin) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.warning(t('settings.appearance.autoStart.unsupported')))
.catch(() => {});
}
} else {
setOpenAtLogin(!enabled); // 回滚
}
})
.catch(() => setOpenAtLogin(!enabled));
};
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
@@ -88,6 +120,25 @@ export function AppearanceSettings({
{t('settings.appearance.localeHelper')}
</Typography>
</Box>
{/* ===== v0.8.1 P2-4: 开机自启 ===== */}
<FormControlLabel
control={
<Switch
size="small"
checked={openAtLogin}
onChange={(e) => handleLoginItem(e.target.checked)}
/>
}
label={
<Stack>
<Typography variant="body2">{t('settings.appearance.autoStart')}</Typography>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
{t('settings.appearance.autoStartHelper')}
</Typography>
</Stack>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
</Stack>
);
}
+97 -199
View File
@@ -32,8 +32,6 @@ import { PROVIDER_LABELS } from '@renderer/lib/constants';
import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
import { PROVIDER_URLS } from './useConfig';
// v0.8.0 FEAT-1: 输出上限配置可见性门控(Provider 支持矩阵)
import { supportsOutputLimitConfig } from '@renderer/lib/model-capabilities';
/** v0.7.2 P3-9: 动态模型条目(渲染端子集,与 global.d.ts MetonaModelInfoLite 对齐) */
interface ModelOption {
@@ -41,8 +39,6 @@ interface ModelOption {
name?: string;
supportsToolCalling?: boolean;
supportsThinking?: boolean;
/** v0.8.0 FEAT-1: 模型输出上限(llm:listModels 元数据,用于钳制提示) */
maxOutputTokens?: number;
description?: string;
}
@@ -55,18 +51,15 @@ export function LLMSettings() {
const [model, setModel] = useState<string>('');
const [apiKey, setApiKey] = useState<string>('');
const [baseURL, setBaseURL] = useState<string>('');
const [numCtx, setNumCtx] = useState<number | null>(null);
// v0.8.0 FEAT-1: 最大输出上限(llm.maxTokens,按请求传给 APInull = 使用引擎默认
// v0.8.1 硬性契约: 上下文长度与最大输出上限是全局唯一合法配置
//llm.contextWindow / llm.maxTokens,跨 Provider/模型生效;代码中不存在任何写死值
const [contextWindow, setContextWindow] = useState<number | null>(131072);
const [maxOutputTokens, setMaxOutputTokens] = useState<number | null>(null);
// v0.8.1 P1-3: 成本估算单价(可选,每百万 tokens;未配置时成本显示隐藏)
const [priceInput, setPriceInput] = useState<number | null>(null);
const [priceOutput, setPriceOutput] = useState<number | null>(null);
// v0.8.0 P0-3: agent.enableThinking(模型不支持思考时的联动提示)
const [agentThinkingEnabled, setAgentThinkingEnabled] = useState(true);
// v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死)
const [dsCtxWindow, setDsCtxWindow] = useState<number>(1000000);
const [agnesCtxWindow, setAgnesCtxWindow] = useState<number>(1000000);
const [mimoCtxWindow, setMimoCtxWindow] = useState<number>(1000000);
// P3: OpenAI/Anthropic contextWindow
const [oaCtxWindow, setOaCtxWindow] = useState<number>(128000);
const [anthropicCtxWindow, setAnthropicCtxWindow] = useState<number>(200000);
// P1: 故障转移 Provider 配置
const [fbProvider, setFbProvider] = useState<string>('');
const [fbModel, setFbModel] = useState<string>('');
@@ -230,12 +223,11 @@ export function LLMSettings() {
window.metona.config.get('llm.model'),
window.metona.config.get('llm.apiKey'),
window.metona.config.get('llm.baseURL'),
window.metona.config.get('ollama.numCtx'),
window.metona.config.get('deepseek.contextWindow'),
window.metona.config.get('agnes.contextWindow'),
window.metona.config.get('mimo.contextWindow'),
window.metona.config.get('openai.contextWindow'),
window.metona.config.get('anthropic.contextWindow'),
// v0.8.1: 全局唯一「上下文长度」/「最大输出上限」/ 可选单价
window.metona.config.get('llm.contextWindow'),
window.metona.config.get('llm.maxTokens'),
window.metona.config.get('llm.priceInput'),
window.metona.config.get('llm.priceOutput'),
// P1: 故障转移配置
window.metona.config.get('llm.fallbackProvider'),
window.metona.config.get('llm.fallbackModel'),
@@ -243,23 +235,20 @@ export function LLMSettings() {
window.metona.config.get('llm.fallbackBaseURL'),
// v0.5.4: 多模态开关
window.metona.config.get('llm.multimodalEnabled'),
// v0.8.0 FEAT-1: 最大输出上限 + 思考开关(P0-3 模型能力联动提示)
window.metona.config.get('llm.maxTokens'),
// v0.8.0 P0-3: 思考开关(模型能力联动提示)
window.metona.config.get('agent.enableThinking'),
]);
if (cancelled) return;
const [p, m, k, u, nc, ds, ag, mi, oa, an, fbp, fbm, fbk, fbu, mm, mt, et] = results;
const [p, m, k, u, cw, mt, pi, po, fbp, fbm, fbk, fbu, mm, et] = results;
setProvider((p as string) ?? '');
setModel((m as string) ?? '');
setApiKey((k as string) ?? '');
setBaseURL((u as string) ?? '');
setNumCtx((nc as number | null) ?? null);
// v0.8.1 review: 用户清空(null)= 未配置语义,尊重之(不回填种子值)
setContextWindow(typeof cw === 'number' && cw > 0 ? cw : null);
if (typeof mt === 'number' && mt > 0) setMaxOutputTokens(mt);
if (typeof ds === 'number' && ds > 0) setDsCtxWindow(ds);
if (typeof ag === 'number' && ag > 0) setAgnesCtxWindow(ag);
if (typeof mi === 'number' && mi > 0) setMimoCtxWindow(mi);
if (typeof oa === 'number' && oa > 0) setOaCtxWindow(oa);
if (typeof an === 'number' && an > 0) setAnthropicCtxWindow(an);
if (typeof pi === 'number' && pi > 0) setPriceInput(pi);
if (typeof po === 'number' && po > 0) setPriceOutput(po);
setFbProvider((fbp as string) ?? '');
setFbModel((fbm as string) ?? '');
setFbApiKey((fbk as string) ?? '');
@@ -284,74 +273,36 @@ export function LLMSettings() {
useAgentStore.getState().setProvider(provider, model);
}, [provider, model]);
// v0.3.1: contextWindow 变化时同步到 Agent Store支持所有 Provider
// v0.8.1: 全局「上下文长度」变化时同步到 Agent Store输入框指示条/详情面板即时刷新
useEffect(() => {
if (provider === 'ollama') {
if (numCtx != null && numCtx > 0) useAgentStore.setState({ contextWindow: numCtx });
} else if (provider === 'deepseek') {
if (dsCtxWindow != null && dsCtxWindow > 0)
useAgentStore.setState({ contextWindow: dsCtxWindow });
} else if (provider === 'agnes') {
if (agnesCtxWindow != null && agnesCtxWindow > 0)
useAgentStore.setState({ contextWindow: agnesCtxWindow });
} else if (provider === 'mimo') {
if (mimoCtxWindow != null && mimoCtxWindow > 0)
useAgentStore.setState({ contextWindow: mimoCtxWindow });
} else if (provider === 'openai') {
if (oaCtxWindow != null && oaCtxWindow > 0)
useAgentStore.setState({ contextWindow: oaCtxWindow });
} else if (provider === 'anthropic') {
if (anthropicCtxWindow != null && anthropicCtxWindow > 0)
useAgentStore.setState({ contextWindow: anthropicCtxWindow });
if (contextWindow != null && contextWindow > 0) {
useAgentStore.getState().setContextWindow(contextWindow);
}
}, [
provider,
numCtx,
dsCtxWindow,
agnesCtxWindow,
mimoCtxWindow,
oaCtxWindow,
anthropicCtxWindow,
]);
}, [contextWindow]);
// ===== 字段级 inline 校验 =====
// Base URL:非空时必须以 http:// 或 https:// 开头(避免漏写协议头导致发消息时报 Invalid URL
const urlError = !!baseURL && !/^https?:\/\/.+/.test(baseURL);
// Model:非空时不允许包含空格(OpenAI API 会把空格后的部分当作额外参数)
const modelHasSpace = !!model && /\s/.test(model);
// contextWindow / numCtx:必须为有限正数且不低于最小值
const numCtxError = numCtx != null && (!Number.isFinite(numCtx) || numCtx < 512);
const dsCtxError = !Number.isFinite(dsCtxWindow) || dsCtxWindow < 4096;
const agnesCtxError = !Number.isFinite(agnesCtxWindow) || agnesCtxWindow < 4096;
const mimoCtxError = !Number.isFinite(mimoCtxWindow) || mimoCtxWindow < 4096;
const oaCtxError = !Number.isFinite(oaCtxWindow) || oaCtxWindow < 4096;
const anthropicCtxError = !Number.isFinite(anthropicCtxWindow) || anthropicCtxWindow < 4096;
// v0.8.0 FEAT-1: 最大输出上限校验(≥256;上限钳制由 adapter 按模型元信息执行)
// v0.8.1: 全局上下文长度 / 最大输出上限校验(有限正数,下限 512 / 256)
const ctxWindowError =
contextWindow != null && (!Number.isFinite(contextWindow) || contextWindow < 512);
const maxTokensError =
maxOutputTokens != null && (!Number.isFinite(maxOutputTokens) || maxOutputTokens < 256);
// v0.8.1 P1-3: 单价校验(可选,非负数)
const priceError =
(priceInput != null && (!Number.isFinite(priceInput) || priceInput < 0)) ||
(priceOutput != null && (!Number.isFinite(priceOutput) || priceOutput < 0));
// v0.8.0 FEAT-1: 当前模型的输出上限元信息(模型列表已加载时展示钳制提示)
const modelMeta = model ? modelOptions.find((o) => o.id === model) : undefined;
const modelMaxOutput = modelMeta?.maxOutputTokens;
const exceedsModelCap =
maxOutputTokens != null &&
typeof modelMaxOutput === 'number' &&
maxOutputTokens > modelMaxOutput;
// v0.8.0 P0-3: 模型不支持思考 + 全局思考开关开启 → 联动提示
const modelMeta = model ? modelOptions.find((o) => o.id === model) : undefined;
const thinkingUnsupportedByModel =
model != null && modelMeta?.supportsThinking === false && agentThinkingEnabled;
// 是否存在阻断保存的错误(API Key 为空只警告,不阻断 — 允许先填其他字段再回来填 key)
const hasBlockingError =
urlError ||
modelHasSpace ||
numCtxError ||
maxTokensError ||
(provider === 'deepseek' && dsCtxError) ||
(provider === 'agnes' && agnesCtxError) ||
(provider === 'mimo' && mimoCtxError) ||
(provider === 'openai' && oaCtxError) ||
(provider === 'anthropic' && anthropicCtxError);
urlError || modelHasSpace || ctxWindowError || maxTokensError || priceError;
// 切换 Provider 时:清空 apiKey + 清空 model + 自动填充默认 URL
// 不同 Provider 的 key/model 互不通用,避免用旧值调用新 API 导致 401 / model not found
@@ -398,17 +349,19 @@ export function LLMSettings() {
{ key: 'llm.baseURL', value: baseURL },
// v0.5.4: 多模态总开关
{ key: 'llm.multimodalEnabled', value: multimodalEnabled },
// v0.8.0 FEAT-1: 最大输出上限(按请求传给 API;清空 = 引擎默认 63488
// v0.8.1 review 修复: 清空输入 = 写入 null(未配置语义 —— 引擎跳过压缩
// 预算 / 输出上限参数不下发),不再静默回写种子默认值
{
key: 'llm.contextWindow',
value: contextWindow != null && contextWindow >= 512 ? contextWindow : null,
},
{
key: 'llm.maxTokens',
value: maxOutputTokens != null && maxOutputTokens >= 256 ? maxOutputTokens : 63488,
value: maxOutputTokens != null && maxOutputTokens >= 256 ? maxOutputTokens : null,
},
{ key: 'ollama.numCtx', value: numCtx },
{ key: 'deepseek.contextWindow', value: dsCtxWindow },
{ key: 'agnes.contextWindow', value: agnesCtxWindow },
{ key: 'mimo.contextWindow', value: mimoCtxWindow },
{ key: 'openai.contextWindow', value: oaCtxWindow },
{ key: 'anthropic.contextWindow', value: anthropicCtxWindow },
// v0.8.1 P1-3: 成本估算单价(可选,null = 未配置 → 成本显示隐藏)
{ key: 'llm.priceInput', value: priceInput },
{ key: 'llm.priceOutput', value: priceOutput },
// P1: 故障转移 Provider(主 Provider 失败时切换)
{ key: 'llm.fallbackProvider', value: fbProvider },
{ key: 'llm.fallbackModel', value: fbModel },
@@ -423,16 +376,6 @@ export function LLMSettings() {
} else {
// v0.5.4: 保存成功后同步多模态开关到 Agent Store(立即生效,控制上传入口)
useAgentStore.getState().setMultimodalEnabled(multimodalEnabled);
// v0.8.0 FEAT-1: 配置值超过模型上限 → 明示"将按模型上限生效"
//adapter 侧按 MODEL_INFO.maxOutputTokens 钳制,llm.maxTokens 经
// applyEngineConfigKey 热更新引擎,无需重启)
if (exceedsModelCap && typeof modelMaxOutput === 'number') {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.warning(t('llm.maxTokens.exceedCap', { cap: modelMaxOutput })),
)
.catch(() => {});
}
import('@metona-team/metona-toast')
.then((mod) => mod.default.success(t('llm.save.success')))
.catch(() => {});
@@ -573,37 +516,58 @@ export function LLMSettings() {
{t('llm.models.hint')}
</Typography>
</Stack>
{/* v0.8.0 P0-3: 模型不支持思考 × 全局思考开关 → 联动提示(adapter 侧已硬门控 */}
{/* v0.8.0 P0-3: 模型不支持思考 × 全局思考开关 → 联动提示(按用户配置发送,降级重试兜底 */}
{thinkingUnsupportedByModel && (
<Typography variant="caption" sx={{ color: 'warning.main', fontSize: 11 }}>
{t('llm.thinking.unsupported')}
</Typography>
)}
{/* v0.8.0 FEAT-1: 最大输出上限 —— 仅对支持输出上限参数的 Provider 显示
PROVIDER_OUTPUT_LIMIT_SUPPORT 矩阵门控;经 llm.maxTokens 热生效) */}
{supportsOutputLimitConfig(provider) && (
{/* v0.8.1 硬性契约: 全局唯一「最大输出上限」(llm.maxTokens)—— 对一切 Provider
生效,原样透传请求参数,无任何按模型钳制行为 */}
<TextField
size="small"
label={t('llm.maxTokens.label')}
type="number"
value={maxOutputTokens ?? ''}
onChange={(e) => {
const v = e.target.value;
setMaxOutputTokens(v === '' ? null : Number(v));
}}
slotProps={{ htmlInput: { min: 256, step: 256 } }}
error={maxTokensError}
helperText={maxTokensError ? t('llm.maxTokens.minError') : t('llm.maxTokens.helper')}
/>
{/* v0.8.1 P1-3: 成本估算单价(可选,每百万 tokens;两栏都留空则隐藏成本显示) */}
<Stack direction="row" spacing={1}>
<TextField
size="small"
label={t('llm.maxTokens.label')}
fullWidth
label={t('llm.price.input')}
type="number"
value={maxOutputTokens ?? ''}
value={priceInput ?? ''}
onChange={(e) => {
const v = e.target.value;
setMaxOutputTokens(v === '' ? null : Number(v));
setPriceInput(v === '' ? null : Number(v));
}}
slotProps={{ htmlInput: { min: 256, step: 256 } }}
error={maxTokensError}
helperText={
maxTokensError
? t('llm.maxTokens.minError')
: exceedsModelCap && typeof modelMaxOutput === 'number'
? t('llm.maxTokens.exceedCap', { cap: modelMaxOutput })
: typeof modelMaxOutput === 'number'
? `${t('llm.maxTokens.helper')} · ${t('llm.maxTokens.capInfo', { cap: modelMaxOutput })}`
: t('llm.maxTokens.helper')
}
slotProps={{ htmlInput: { min: 0, step: 'any' } }}
error={priceError}
helperText={t('llm.price.helper')}
/>
)}
<TextField
size="small"
fullWidth
label={t('llm.price.output')}
type="number"
value={priceOutput ?? ''}
onChange={(e) => {
const v = e.target.value;
setPriceOutput(v === '' ? null : Number(v));
}}
slotProps={{ htmlInput: { min: 0, step: 'any' } }}
error={priceError}
helperText={t('llm.price.helper')}
/>
</Stack>
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
<FormControlLabel
control={
@@ -651,20 +615,9 @@ export function LLMSettings() {
</>
)}
{provider === 'ollama' && (
<TextField
size="small"
label={t('llm.numCtx.label')}
type="number"
value={numCtx ?? ''}
onChange={(e) => {
const v = e.target.value;
setNumCtx(v === '' ? null : Number(v));
}}
placeholder={t('llm.numCtx.placeholder')}
slotProps={{ htmlInput: { min: 512, step: 512 } }}
error={numCtxError}
helperText={numCtxError ? t('llm.numCtx.error') : ' '}
/>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 11 }}>
{t('llm.ctxWindow.ollamaNote')}
</Typography>
)}
{/* ===== v0.7.2 P3-10: Ollama 模型下载(adapter.pullModel 首次接线 UI ===== */}
{provider === 'ollama' && (
@@ -735,20 +688,20 @@ export function LLMSettings() {
</Typography>
</Stack>
)}
{/* v0.3.1: DeepSeek/Agnes 上下文窗口配置(用于 Engine 压缩判断和 UI 显示,不传给 API) */}
{provider === 'deepseek' && (
<TextField
size="small"
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 ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.helper')}
/>
)}
{/* v0.8.1 硬性契约: 全局唯一「上下文长度」(llm.contextWindow)—— 对一切
Provider/模型生效;驱动引擎压缩预算与前端占用指示,Ollama 场景同时作为
num_ctx 下发。删除了旧的分 Provider 输入框。 */}
<TextField
size="small"
label={t('llm.ctxWindow.label')}
type="number"
value={contextWindow}
onChange={(e) => setContextWindow(Number(e.target.value) || 0)}
placeholder="如 32768、131072、1000000"
slotProps={{ htmlInput: { min: 512, step: 512 } }}
error={ctxWindowError}
helperText={ctxWindowError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.helper')}
/>
{/* v0.5.0: DeepSeek 账户余额显示 */}
{provider === 'deepseek' && (
@@ -801,61 +754,6 @@ export function LLMSettings() {
</Button>
</Stack>
)}
{provider === 'agnes' && (
<TextField
size="small"
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 ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.helper')}
/>
)}
{provider === 'mimo' && (
<TextField
size="small"
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 ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.mimoHelper')}
/>
)}
{provider === 'openai' && (
<TextField
size="small"
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 ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.oaHelper')}
/>
)}
{provider === 'anthropic' && (
<TextField
size="small"
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 ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.anthropicHelper')
}
/>
)}
{/* ===== P1: 故障转移 Provider(主 Provider 请求失败时自动切换) ===== */}
<Divider />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
+275 -35
View File
@@ -6,13 +6,219 @@
*/
import { useState, useEffect } from 'react';
import { Button, Stack, Typography, Checkbox, Divider, Chip, Box, TextField } from '@mui/material';
import {
Button,
Stack,
Typography,
Checkbox,
Divider,
Chip,
Box,
TextField,
Switch,
FormControlLabel,
} from '@mui/material';
import { alpha } from '@mui/material/styles';
import { useConfig } from './useConfig';
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
// ===== v0.8.1 P2-1: 工具自定义策略编辑器 =====
/** 单个工具的策略编辑状态(正则以字符串源编辑,保存时编译校验由主进程承担) */
interface ToolPolicyDraft {
denied: string;
allowed: string;
maxFrequency: string;
requireConfirmation: boolean;
}
/** 校验正则源列表(返回首个非法正则;用于保存前 inline 提示) */
function firstInvalidRegex(src: string): string | null {
if (!src.trim()) return null;
for (const item of src.split(',')) {
const pattern = item.trim();
if (!pattern) continue;
try {
new RegExp(pattern);
} catch {
return pattern;
}
}
return null;
}
function ToolPolicyEditor({
toolName,
onClose,
}: {
toolName: string;
onClose: () => void;
}): React.JSX.Element {
const [draft, setDraft] = useState<ToolPolicyDraft>({
denied: '',
allowed: '',
maxFrequency: '',
requireConfirmation: false,
});
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
window.metona?.config
?.get(`tools.${toolName}.policy`)
.then((raw) => {
if (cancelled) return;
if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
setDraft({
denied: Array.isArray(parsed.deniedPatterns)
? (parsed.deniedPatterns as string[]).join(', ')
: '',
allowed: Array.isArray(parsed.allowedPatterns)
? (parsed.allowedPatterns as string[]).join(', ')
: '',
maxFrequency:
typeof parsed.maxFrequency === 'number' ? String(parsed.maxFrequency) : '',
requireConfirmation: parsed.requireConfirmation === true,
});
} catch {
/* 损坏配置按空编辑 */
}
}
setLoaded(true);
})
.catch(() => setLoaded(true));
return () => {
cancelled = true;
};
}, [toolName]);
const deniedInvalid = firstInvalidRegex(draft.denied);
const allowedInvalid = firstInvalidRegex(draft.allowed);
const handleSave = async (): Promise<void> => {
if (deniedInvalid || allowedInvalid) return;
setSaving(true);
try {
// 空策略 = 清除覆盖(回退默认策略)
const empty =
!draft.denied.trim() &&
!draft.allowed.trim() &&
!draft.maxFrequency.trim() &&
!draft.requireConfirmation;
const policy = empty
? null
: JSON.stringify({
...(draft.denied.trim()
? {
deniedPatterns: draft.denied
.split(',')
.map((x) => x.trim())
.filter(Boolean),
}
: {}),
...(draft.allowed.trim()
? {
allowedPatterns: draft.allowed
.split(',')
.map((x) => x.trim())
.filter(Boolean),
}
: {}),
...(draft.maxFrequency.trim() ? { maxFrequency: Number(draft.maxFrequency) } : {}),
...(draft.requireConfirmation ? { requireConfirmation: true } : {}),
});
await window.metona?.config?.set(`tools.${toolName}.policy`, policy);
import('@metona-team/metona-toast')
.then((mod) => mod.default.success(t('settings.tools.policy.saved')))
.catch(() => {});
onClose();
} catch (err) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error((err as Error).message))
.catch(() => {});
} finally {
setSaving(false);
}
};
if (!loaded) return <Typography variant="caption">{t('common.loading')}</Typography>;
return (
<Stack spacing={1} sx={{ px: 1.5, py: 1.5, bgcolor: 'background.default', borderRadius: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{t('settings.tools.policy.title', { name: toolName })}
</Typography>
<TextField
size="small"
label={t('settings.tools.policy.denied')}
value={draft.denied}
onChange={(e) => setDraft((d) => ({ ...d, denied: e.target.value }))}
placeholder={t('settings.tools.policy.regexHint')}
error={deniedInvalid != null}
helperText={
deniedInvalid != null
? t('settings.tools.policy.badRegex', { pattern: deniedInvalid })
: ' '
}
slotProps={{ input: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
/>
<TextField
size="small"
label={t('settings.tools.policy.allowed')}
value={draft.allowed}
onChange={(e) => setDraft((d) => ({ ...d, allowed: e.target.value }))}
placeholder={t('settings.tools.policy.regexHint')}
error={allowedInvalid != null}
helperText={
allowedInvalid != null
? t('settings.tools.policy.badRegex', { pattern: allowedInvalid })
: ' '
}
slotProps={{ input: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
/>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<TextField
size="small"
fullWidth
type="number"
label={t('settings.tools.policy.frequency')}
value={draft.maxFrequency}
onChange={(e) => setDraft((d) => ({ ...d, maxFrequency: e.target.value }))}
slotProps={{ htmlInput: { min: 1 } }}
/>
<FormControlLabel
control={
<Switch
size="small"
checked={draft.requireConfirmation}
onChange={(e) => setDraft((d) => ({ ...d, requireConfirmation: e.target.checked }))}
/>
}
label={<Typography variant="caption">{t('settings.tools.policy.confirm')}</Typography>}
/>
</Stack>
<Stack direction="row" spacing={1} sx={{ justifyContent: 'flex-end' }}>
<Button size="small" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
size="small"
variant="contained"
onClick={handleSave}
disabled={saving || deniedInvalid != null || allowedInvalid != null}
>
{t('settings.tools.policy.save')}
</Button>
</Stack>
</Stack>
);
}
export function ToolsSettings() {
const [tools, setTools] = useState<
Array<{
@@ -24,6 +230,8 @@ export function ToolsSettings() {
}>
>([]);
const [autoExecList, setAutoExecList] = useState<string[]>([]);
// v0.8.1 P2-1: 展开策略编辑器的工具集合
const [policyEditors, setPolicyEditors] = useState<Set<string>>(new Set());
const loadTools = () => {
if (window.metona?.tools?.list) {
@@ -139,43 +347,75 @@ export function ToolsSettings() {
</Typography>
) : (
tools.map((tool) => (
<Stack
key={tool.name}
direction="row"
sx={{
py: 1,
px: 1.5,
borderRadius: 1.5,
bgcolor: 'secondary.main',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Stack direction="row" spacing={1} sx={{ minWidth: 0, flex: 1, alignItems: 'center' }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
{tool.name}
</Typography>
<Typography
variant="caption"
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
<Stack key={tool.name} spacing={0}>
<Stack
direction="row"
sx={{
py: 1,
px: 1.5,
borderRadius: 1.5,
bgcolor: 'secondary.main',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Stack
direction="row"
spacing={1}
sx={{ minWidth: 0, flex: 1, alignItems: 'center' }}
>
{tool.description.slice(0, 40)}
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
{tool.name}
</Typography>
<Typography
variant="caption"
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{tool.description.slice(0, 40)}
</Typography>
</Stack>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Chip
label={tool.riskLevel.toUpperCase()}
size="small"
color={riskColors[tool.riskLevel]}
variant="outlined"
sx={{ height: 18, fontSize: 9 }}
/>
<Button
size="small"
variant={policyEditors.has(tool.name) ? 'contained' : 'text'}
onClick={() =>
setPolicyEditors((prev) => {
const next = new Set(prev);
if (next.has(tool.name)) next.delete(tool.name);
else next.add(tool.name);
return next;
})
}
sx={{ minWidth: 0, fontSize: 10, px: 0.75 }}
>
{t('settings.tools.policy.button')}
</Button>
<Checkbox
checked={tool.enabled}
onChange={(e) => handleToggle(tool.name, e.target.checked)}
size="small"
/>
</Stack>
</Stack>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Chip
label={tool.riskLevel.toUpperCase()}
size="small"
color={riskColors[tool.riskLevel]}
variant="outlined"
sx={{ height: 18, fontSize: 9 }}
{policyEditors.has(tool.name) && (
<ToolPolicyEditor
toolName={tool.name}
onClose={() =>
setPolicyEditors((prev) => {
const next = new Set(prev);
next.delete(tool.name);
return next;
})
}
/>
<Checkbox
checked={tool.enabled}
onChange={(e) => handleToggle(tool.name, e.target.checked)}
size="small"
/>
</Stack>
)}
</Stack>
))
)}
+91
View File
@@ -6,6 +6,7 @@
*/
import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell } from '@mui/material';
import { useEffect, useState } from 'react';
import { Zap } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { formatTokens } from '@renderer/lib/formatters';
@@ -19,6 +20,47 @@ export function TokenUsage(): React.JSX.Element {
const currentIteration = useAgentStore((s) => s.currentIteration);
const contextWindow = useAgentStore((s) => s.contextWindow);
// ===== v0.8.1 P1-3: 成本估算(设置面板可选单价 llm.priceInput/llm.priceOutput
// 每百万 tokens;未配置时隐藏成本行 —— 单价是用户可编辑配置,代码零写死) =====
const [prices, setPrices] = useState<{ input: number | null; output: number | null }>({
input: null,
output: null,
});
useEffect(() => {
if (!window.metona?.config?.get) return;
let cancelled = false;
Promise.all([
window.metona.config.get('llm.priceInput'),
window.metona.config.get('llm.priceOutput'),
])
.then(([pi, po]) => {
if (cancelled) return;
setPrices({
input: typeof pi === 'number' && pi > 0 ? pi : null,
output: typeof po === 'number' && po > 0 ? po : null,
});
})
.catch(() => {});
const off = window.metona.config.onChanged?.((data) => {
if (data.key === 'llm.priceInput') {
setPrices((p) => ({
...p,
input: typeof data.value === 'number' && data.value > 0 ? data.value : null,
}));
}
if (data.key === 'llm.priceOutput') {
setPrices((p) => ({
...p,
output: typeof data.value === 'number' && data.value > 0 ? data.value : null,
}));
}
});
return () => {
cancelled = true;
off?.();
};
}, []);
const maxTokens = contextWindow;
// v0.3.18 修复: 上下文占用百分比改用 lastInputTokens(单次占用),而非累计 totalTokens
// 之前 totalTokens 是所有轮次累加值,除以单次窗口得出无意义的百分比
@@ -29,6 +71,17 @@ export function TokenUsage(): React.JSX.Element {
// 累计消耗的输入/输出占比(用于进度条展示累计消耗的构成)
const inputPercent =
tokenUsage.totalTokens > 0 ? (tokenUsage.inputTokens / tokenUsage.totalTokens) * 100 : 0;
// v0.8.1 P1-3: 缓存命中率(Provider 上报 cache 字段时才有意义)
const cacheHitPercent =
tokenUsage.cacheHitTokens != null && tokenUsage.inputTokens > 0
? (tokenUsage.cacheHitTokens / tokenUsage.inputTokens) * 100
: null;
// v0.8.1 P1-3: 估算成本(单价配置齐全时才展示;/1e6 = 每百万 tokens 单价)
const estimatedCost =
prices.input != null && prices.output != null
? (tokenUsage.inputTokens / 1e6) * prices.input +
(tokenUsage.outputTokens / 1e6) * prices.output
: null;
return (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
@@ -136,6 +189,44 @@ export function TokenUsage(): React.JSX.Element {
: '-'}
</TableCell>
</TableRow>
{cacheHitPercent != null && (
<TableRow>
<TableCell sx={{ color: 'info.main', fontSize: 11 }}>
{t('trace.token.cacheHit')}
</TableCell>
<TableCell
sx={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: 11,
color: 'info.main',
textAlign: 'right',
}}
>
{`${formatTokens(tokenUsage.cacheHitTokens ?? 0)} (${cacheHitPercent.toFixed(1)}%)`}
</TableCell>
</TableRow>
)}
{estimatedCost != null && (
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}>
{t('trace.token.cost')}
</TableCell>
<TableCell
sx={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: 11,
color: 'text.secondary',
textAlign: 'right',
}}
>
{estimatedCost < 0.01 && estimatedCost > 0
? `<$0.01`
: `$${estimatedCost.toFixed(2)}`}
</TableCell>
</TableRow>
)}
{tokenUsage.lastCompressedSaved > 0 && (
<TableRow>
<TableCell sx={{ color: 'success.main', fontSize: 11 }}>