feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(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:
@@ -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 开始)或 TERMINATED(run 结束/abort)时清空该会话的前端 state =====
|
||||
// 解决:abort 场景下后端 clearPending() 清空了 Map,但前端 requests state 不会自动同步,
|
||||
// 弹框会停留在已失效的请求上。用户操作后批量 IPC 返回 0 resolved,逻辑无害但 UX 差。
|
||||
|
||||
@@ -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 resources(token 形态 @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={() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {});
|
||||
// 保留编辑态,让用户重试或复制内容
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,按请求传给 API;null = 使用引擎默认)
|
||||
// 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 }}>
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user