feat: v0.7.2 安全收口 · 断链接线 · 观测补洞 — 230 用例扩充与全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m25s
CI / 产物编译验证 (push) Successful in 10m1s

P1 修复面收口: /clear 全链路根治(前端清空联动 DB messages+摘要游标+TRACE 快照,
IPC 语义改"操作完成"; 流式中拒绝); web_browser open 补 SSRF 校验(Chromium 旁路关闭,
与 web_fetch/http_request 同源 validateSSRF); MCP 工具结果纳入注入扫描(mcp_* 前缀
按网络来源同级 full 模式, 收敛 resolveScanMode 单点); Trace 落库/入 store 双重瘦身
(tool_result base64/超长字段剥离, metadata 防 MB 级膨胀); 文本附件 512KB 闸门
(file.slice 首段读取+truncated 标志随消息持久化+主进程附件提示感知截断);
单实例锁(requestSingleInstanceLock + second-instance 聚焦已有窗口)

P2 安全纵深: ConfirmationHook 多窗口化(确认请求/超时提示改全窗口广播,
getAllWindows 空时回退 mainWindow, fail-closed 判定升级双通道); mcp_servers.headers
全链路接线(safeParseHeaders 容错解析+SSE/StreamableHTTP requestInit 注入+IPC 逐项
校验+设置页 JSON 输入, 远程 MCP 鉴权头可用)

P3 断链接线: llm:listModels IPC(六家 adapter 动态模型发现首次接线, 配置完整性
前置校验); Ollama pullModel IPC+设置页下载卡片(进度/取消/能力徽标, v0.7.0 死代码
激活); 后台会话运行指示(sessionRunStates 图+Sidebar 状态点, 多会话并发可见);
IR 卫生(移除 THINKING_START/END 死枚举, constraints 标注预留)

P4 质量与文档: i18n 第二阶段(确认弹框/侧栏/状态栏/AgentMonitor/终止原因出层,
外观设置 zh-CN/en-US 切换, ui.locale 持久化, 渲染时求值规避异步注册); README/D1
文档对齐(http_request 风险等级/用例数/实现状态注记); 版本号 0.7.2

测试: 507 → 737 用例(+230, 11 个新文件)。覆盖补齐: context-builder/consolidator/
orchestrator/workspace.service/session-recorder/config-layering/secure-config/
network-proxy + IPC mcp/tasks/memory/app/data 域 + 渲染层 store 与流事件管线纯函数。
测试驱动修复: workspace.appendMemory 中文分区 \b 词边界失效(JS \b 不含 CJK),
固化条目恒追加文件末尾产生重复分区头 → (?=\n|$) 前瞻断言根治

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 687 通过 50 跳过;
Electron ABI 全量 737/737 零跳过
This commit is contained in:
2026-08-30 00:09:25 +08:00
parent 36e4b28d93
commit 26169b7be4
55 changed files with 5673 additions and 434 deletions
+36 -16
View File
@@ -8,8 +8,11 @@ import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell, Chip } f
import { Activity, Cpu, Bot } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useAgentStore, type AgentStatus } from '@renderer/stores/agent-store';
import { AGENT_STATUS_COLORS, AGENT_STATUS_LABELS, PROVIDER_LABELS } from '@renderer/lib/constants';
import { AGENT_STATUS_COLORS, agentStatusLabel, PROVIDER_LABELS } from '@renderer/lib/constants';
import { formatDuration } from '@renderer/lib/formatters';
// v0.7.2 P4-15: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
const STATUS_ICONS: Record<AgentStatus, typeof Activity> = {
idle: Activity,
@@ -40,12 +43,23 @@ const SUB_STATUS_COLORS: Record<SubAgentItem['status'], string> = {
error: '#f87171',
};
const SUB_STATUS_LABELS: Record<SubAgentItem['status'], string> = {
delegated: '已委派',
running: '运行中',
completed: '已完成',
error: '失败',
};
/**
* v0.7.2 P4-15: SubAgent 状态文案出层。
* 注意:t() 必须在渲染时求值而非模块加载时 —— i18next 的字典注册是异步的
* registerTranslations 经 ensureInit().then 落库),模块顶层固化会拿到 key 本体。
*/
function subStatusLabel(status: SubAgentItem['status']): string {
switch (status) {
case 'delegated':
return t('monitor.sub.delegated');
case 'running':
return t('monitor.sub.running');
case 'completed':
return t('monitor.sub.completed');
case 'error':
return t('monitor.sub.error');
}
}
export function AgentMonitor(): React.JSX.Element {
const agentStatus = useAgentStore((s) => s.agentStatus);
@@ -116,7 +130,7 @@ export function AgentMonitor(): React.JSX.Element {
const StatusIcon = STATUS_ICONS[agentStatus];
const statusColor = AGENT_STATUS_COLORS[agentStatus];
const statusLabel = AGENT_STATUS_LABELS[agentStatus];
const statusLabel = agentStatusLabel(agentStatus);
return (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
@@ -131,7 +145,7 @@ export function AgentMonitor(): React.JSX.Element {
color: 'text.secondary',
}}
>
Agent
{t('monitor.agentStatus')}
</Typography>
</Stack>
@@ -181,7 +195,7 @@ export function AgentMonitor(): React.JSX.Element {
<TableBody>
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11, width: '40%' }}>
Provider
{t('monitor.provider')}
</TableCell>
<TableCell
sx={{
@@ -200,7 +214,9 @@ export function AgentMonitor(): React.JSX.Element {
</TableCell>
</TableRow>
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}>
{t('monitor.model')}
</TableCell>
<TableCell
sx={{
fontFamily: 'monospace',
@@ -218,7 +234,9 @@ export function AgentMonitor(): React.JSX.Element {
</TableCell>
</TableRow>
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}>
{t('monitor.iteration')}
</TableCell>
<TableCell
sx={{
fontFamily: 'monospace',
@@ -233,7 +251,9 @@ export function AgentMonitor(): React.JSX.Element {
</TableRow>
{totalDuration > 0 && (
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}>
{t('monitor.totalDuration')}
</TableCell>
<TableCell
sx={{
fontFamily: 'monospace',
@@ -264,10 +284,10 @@ export function AgentMonitor(): React.JSX.Element {
color: 'text.secondary',
}}
>
SubAgent
{t('monitor.subAgentTasks')}
</Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
{subAgents.length}
{t('monitor.count', { count: subAgents.length })}
</Typography>
</Stack>
<Stack spacing={0.5} sx={{ maxHeight: 220, overflowY: 'auto' }}>
@@ -316,7 +336,7 @@ export function AgentMonitor(): React.JSX.Element {
</Typography>
)}
<Chip
label={SUB_STATUS_LABELS[sub.status]}
label={subStatusLabel(sub.status)}
size="small"
sx={{
height: 16,
+37 -28
View File
@@ -39,6 +39,9 @@ import { useSessionStore, type Session } from '@renderer/stores/session-store';
import { useAgentStore } from '@renderer/stores/agent-store';
import { formatRelativeTime } from '@renderer/lib/formatters';
import { LAYOUT } from '@renderer/lib/constants';
// v0.7.2 P4-15: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
import { ContextMenu, createContextMenuItems } from '@renderer/components/ContextMenu';
export function Sidebar(): React.JSX.Element {
@@ -48,7 +51,6 @@ export function Sidebar(): React.JSX.Element {
const loadSessionMessages = useAgentStore((s) => s.setCurrentSession);
const searchQuery = useSessionStore((s) => s.searchQuery);
const setSearchQuery = useSessionStore((s) => s.setSearchQuery);
const agentStatus = useAgentStore((s) => s.agentStatus);
// v0.5.0: 内容搜索结果(FTS5),按会话聚合 { sessionId → snippet }
const [contentMatches, setContentMatches] = useState<
Map<string, { snippet: string; matchCount: number }>
@@ -173,7 +175,7 @@ export function Sidebar(): React.JSX.Element {
console.error('[Sidebar] Failed to create session:', err);
import('@metona-team/metona-toast')
.then((mod) => {
mod.default.error('创建会话失败,请检查数据库状态');
mod.default.error(t('sidebar.toast.createFailed'));
})
.catch(() => {});
return; // 不创建本地假会话
@@ -184,7 +186,7 @@ export function Sidebar(): React.JSX.Element {
// 直接提示用户(正常构建下 preload 必然提供该 API,此分支仅在桥接损坏时触达)。
console.error('[Sidebar] window.metona.sessions.create is unavailable — IPC bridge broken');
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('IPC 桥不可用:无法创建会话'))
.then((mod) => mod.default.error(t('sidebar.toast.bridgeUnavailable')))
.catch(() => {});
};
@@ -210,14 +212,14 @@ export function Sidebar(): React.JSX.Element {
onClick={handleNewSession}
sx={{ mb: 1.5, fontSize: 12 }}
>
<Plus size={14} style={{ marginRight: 8 }} />
<Plus size={14} style={{ marginRight: 8 }} /> {t('sidebar.newSession')}
</Button>
<TextField
size="small"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索会话标题与内容..."
placeholder={t('sidebar.searchPlaceholder')}
slotProps={{
input: {
startAdornment: <Search size={14} style={{ color: '#8b8fa7', marginRight: 8 }} />,
@@ -235,7 +237,7 @@ export function Sidebar(): React.JSX.Element {
variant="caption"
sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.secondary' }}
>
{searchQuery ? '无匹配结果' : '暂无会话'}
{searchQuery ? t('sidebar.noMatch') : t('sidebar.empty')}
</Typography>
) : (
filteredSessions.map((session) => (
@@ -243,10 +245,6 @@ export function Sidebar(): React.JSX.Element {
key={session.id}
session={session}
isActive={session.id === currentSessionId}
isAgentActive={
session.id === currentSessionId &&
(agentStatus === 'thinking' || agentStatus === 'executing')
}
contentMatch={contentMatches.get(session.id)}
onClick={() => {
setCurrentSession(session.id);
@@ -287,13 +285,13 @@ function ArchivedSessionsPanel(): React.JSX.Element | null {
useSessionStore.getState().archiveSession(sessionId, false);
} else {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(r?.error ?? '恢复失败'))
.then((mod) => mod.default.error(r?.error ?? t('sidebar.toast.restoreFailed')))
.catch(() => {});
}
} catch (err) {
console.error('[Sidebar] Unarchive failed:', err);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('恢复失败'))
.then((mod) => mod.default.error(t('sidebar.toast.restoreFailed')))
.catch(() => {});
}
};
@@ -312,12 +310,12 @@ function ArchivedSessionsPanel(): React.JSX.Element | null {
<ListItemText
primary={
<Typography variant="body2" sx={{ fontSize: 12 }}>
{t('sidebar.archived')}
</Typography>
}
/>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
{archived.length}
{t('sidebar.count', { count: archived.length })}
</Typography>
</ListItemButton>
<Collapse in={expanded}>
@@ -358,7 +356,7 @@ function ArchivedSessionsPanel(): React.JSX.Element | null {
void handleUnarchive(s.id);
}}
>
{t('sidebar.restore')}
</Button>
</ListItemButton>
))}
@@ -371,17 +369,24 @@ function ArchivedSessionsPanel(): React.JSX.Element | null {
function SessionItem({
session,
isActive,
isAgentActive,
contentMatch,
onClick,
}: {
session: Session;
isActive: boolean;
isAgentActive: boolean;
contentMatch?: { snippet: string; matchCount: number };
onClick: () => void;
}) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// v0.7.2 P3-11: 运行状态判定升级 —— 当前会话沿用 agentStatus(即时反馈),
// 其他会话查 sessionRunStates 指示图(useAgentStream 全量维护)。
// 此前后台运行中的会话在 UI 上完全静默(消息流按当前会话过滤)。
const agentStatus = useAgentStore((s) => s.agentStatus);
const runState = useAgentStore((s) => s.sessionRunStates[session.id]);
const isAgentActive =
(isActive && (agentStatus === 'thinking' || agentStatus === 'executing')) ||
runState === 'thinking' ||
runState === 'executing';
// F-1 修复: 挂载会话右键菜单(重命名/置顶/归档/导出/删除)
// 此前 ContextMenu 的 session 分支约 200 行无任何触发点(死代码)
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
@@ -401,7 +406,7 @@ function SessionItem({
console.error('[Sidebar] Failed to delete session:', err);
import('@metona-team/metona-toast')
.then((mod) => {
mod.default.error('删除会话失败,请重试');
mod.default.error(t('sidebar.toast.deleteFailed'));
})
.catch(() => {});
// 不调用 removeSession,保留会话在 UI 中(与数据库状态一致)
@@ -485,7 +490,9 @@ function SessionItem({
<>
<Typography variant="caption" sx={{ fontSize: 10 }}>
{formatRelativeTime(session.updatedAt)}
{session.messageCount > 0 ? ` · ${session.messageCount}` : ''}
{session.messageCount > 0
? ` · ${t('sidebar.messageCount', { count: session.messageCount })}`
: ''}
</Typography>
{/* v0.5.0: 内容匹配摘要(FTS5 snippet,高亮标记为 [匹配] */}
{contentMatch && (
@@ -502,7 +509,9 @@ function SessionItem({
}}
title={contentMatch.snippet}
>
{contentMatch.matchCount > 1 ? `(${contentMatch.matchCount} 处) ` : ''}
{contentMatch.matchCount > 1
? t('sidebar.matchCount', { count: contentMatch.matchCount })
: ''}
{contentMatch.snippet}
</Typography>
)}
@@ -512,7 +521,7 @@ function SessionItem({
<IconButton
className="delete-btn"
size="small"
aria-label={`删除会话 ${session.title}`}
aria-label={t('sidebar.deleteSessionAria', { title: session.title })}
onClick={handleDelete}
sx={{
opacity: 0,
@@ -533,10 +542,10 @@ function SessionItem({
maxWidth="xs"
fullWidth
>
<DialogTitle sx={{ fontSize: 14 }}></DialogTitle>
<DialogTitle sx={{ fontSize: 14 }}>{t('sidebar.deleteTitle')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{session.title}
{t('sidebar.deleteBody', { title: session.title })}
</Typography>
</DialogContent>
<DialogActions>
@@ -545,10 +554,10 @@ function SessionItem({
onClick={() => setShowDeleteDialog(false)}
sx={{ color: 'text.secondary' }}
>
{t('common.cancel')}
</Button>
<Button size="small" color="error" variant="contained" onClick={confirmDelete}>
{t('common.delete')}
</Button>
</DialogActions>
</Dialog>
@@ -592,7 +601,7 @@ function ToolManagerPanel() {
console.error('[Sidebar]', err);
if (!cancelled) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('加载工具列表失败'))
.then((mod) => mod.default.error(t('sidebar.toast.toolsLoadFailed')))
.catch(() => {});
}
});
@@ -629,7 +638,7 @@ function ToolManagerPanel() {
<ListItemText
primary={
<Typography variant="body2" sx={{ fontSize: 12 }}>
{t('sidebar.toolManager')}
</Typography>
}
/>
@@ -637,7 +646,7 @@ function ToolManagerPanel() {
variant="caption"
sx={{ color: readyCount > 0 ? 'success.main' : 'text.disabled', fontSize: 10 }}
>
{readyCount}
{t('sidebar.toolsReady', { count: readyCount })}
</Typography>
</ListItemButton>
<Collapse in={expanded}>
+86 -29
View File
@@ -5,12 +5,24 @@
*/
import { useState, useEffect } from 'react';
import { Box, Typography, IconButton, Tooltip, Table, TableBody, TableRow, TableCell } from '@mui/material';
import {
Box,
Typography,
IconButton,
Tooltip,
Table,
TableBody,
TableRow,
TableCell,
} from '@mui/material';
import { Settings } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useUIStore } from '@renderer/stores/ui-store';
import { AGENT_STATUS_COLORS, AGENT_STATUS_LABELS, PROVIDER_LABELS } from '@renderer/lib/constants';
import { AGENT_STATUS_COLORS, agentStatusLabel, PROVIDER_LABELS } from '@renderer/lib/constants';
import { formatTokens } from '@renderer/lib/formatters';
// v0.7.2 P4-15: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
export function StatusBar(): React.JSX.Element {
const agentStatus = useAgentStore((s) => s.agentStatus);
@@ -28,40 +40,73 @@ export function StatusBar(): React.JSX.Element {
// M-28 修复: 添加 cancelled 标志防止组件卸载后 setState
let cancelled = false;
if (window.metona?.app?.getVersion) {
window.metona.app.getVersion().then((v) => {
if (!cancelled) setVersion(`v${v}`);
}).catch((err) => { console.error('[StatusBar]', err); });
window.metona.app
.getVersion()
.then((v) => {
if (!cancelled) setVersion(`v${v}`);
})
.catch((err) => {
console.error('[StatusBar]', err);
});
}
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
return (
<Box
component="footer"
sx={{
flexShrink: 0, display: 'flex', alignItems: 'center',
px: 2, height: 36, bgcolor: 'background.paper',
borderTop: 1, borderColor: 'divider', userSelect: 'none',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
px: 2,
height: 36,
bgcolor: 'background.paper',
borderTop: 1,
borderColor: 'divider',
userSelect: 'none',
}}
>
{/* 左侧:状态信息表格 */}
<Table size="small" sx={{
flex: 1, width: 'auto',
'& .MuiTableCell-root': { border: 0, py: 0, px: 1, fontSize: 11, fontWeight: 600, whiteSpace: 'nowrap' },
}}>
<Table
size="small"
sx={{
flex: 1,
width: 'auto',
'& .MuiTableCell-root': {
border: 0,
py: 0,
px: 1,
fontSize: 11,
fontWeight: 600,
whiteSpace: 'nowrap',
},
}}
>
<TableBody>
<TableRow>
<TableCell sx={{ width: 'auto', py: 0, px: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box
sx={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
width: 8,
height: 8,
borderRadius: '50%',
flexShrink: 0,
bgcolor: AGENT_STATUS_COLORS[agentStatus],
animation: (agentStatus === 'thinking' || agentStatus === 'executing') ? 'pulse 2s infinite' : 'none',
animation:
agentStatus === 'thinking' || agentStatus === 'executing'
? 'pulse 2s infinite'
: 'none',
}}
/>
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11, fontWeight: 600, lineHeight: 1 }}>
{AGENT_STATUS_LABELS[agentStatus]}
<Typography
variant="caption"
sx={{ color: 'text.secondary', fontSize: 11, fontWeight: 600, lineHeight: 1 }}
>
{agentStatusLabel(agentStatus)}
</Typography>
</Box>
</TableCell>
@@ -84,7 +129,7 @@ export function StatusBar(): React.JSX.Element {
{/* 右侧:版本 + 设置 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
{/* v0.6.4 P4-2: 版本号可点击 → 手动检查更新(feed 比对式) */}
<Tooltip title={`点击检查更新(当前 ${version}`}>
<Tooltip title={t('status.checkUpdate', { version })}>
<Typography
variant="caption"
component="button"
@@ -97,17 +142,22 @@ export function StatusBar(): React.JSX.Element {
if (result.status === 'available') {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.info(`发现新版本 ${result.latestVersion},点击此通知或设置中打开下载页`, {
onClick: result.downloadUrl
? () =>
void window.metona?.app?.openExternal(result.downloadUrl as string)
: undefined,
}),
mod.default.info(
t('status.newVersion', { version: result.latestVersion }),
{
onClick: result.downloadUrl
? () =>
void window.metona?.app?.openExternal(
result.downloadUrl as string,
)
: undefined,
},
),
)
.catch(() => {});
} else if (result.status === 'up-to-date') {
import('@metona-team/metona-toast')
.then((mod) => mod.default.success('已是最新版本'))
.then((mod) => mod.default.success(t('status.upToDate')))
.catch(() => {});
} else {
import('@metona-team/metona-toast')
@@ -122,15 +172,22 @@ export function StatusBar(): React.JSX.Element {
color: checkingUpdate ? 'warning.main' : 'text.disabled',
fontSize: 10,
cursor: 'pointer',
border: 0, p: 0, bgcolor: 'transparent', lineHeight: 1,
border: 0,
p: 0,
bgcolor: 'transparent',
lineHeight: 1,
'&:hover': { color: 'primary.main' },
}}
>
{checkingUpdate ? '检查中…' : version}
{checkingUpdate ? t('status.checking') : version}
</Typography>
</Tooltip>
<Tooltip title="设置 (Ctrl+,)">
<IconButton size="small" onClick={openSettings} sx={{ color: 'text.secondary', width: 28, height: 28 }}>
<Tooltip title={t('status.settings')}>
<IconButton
size="small"
onClick={openSettings}
sx={{ color: 'text.secondary', width: 28, height: 28 }}
>
<Settings size={14} />
</IconButton>
</Tooltip>