feat: v0.7.2 安全收口 · 断链接线 · 观测补洞 — 230 用例扩充与全量回归
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:
@@ -43,6 +43,9 @@ import {
|
||||
} from '@mui/material';
|
||||
import { ShieldAlert, ChevronDown, Timer, RefreshCw } from 'lucide-react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
// v0.7.2 P4-15: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
interface ConfirmationRequest {
|
||||
toolCallId: string;
|
||||
@@ -390,8 +393,12 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
// 综合风险文案
|
||||
const batchReason =
|
||||
totalCount > 1
|
||||
? `检测到 ${totalCount} 个并行工具调用请求确认(涉及 ${grouped.length} 个不同工具)。综合最高风险:${highestRisk}`
|
||||
: (requests[0]?.reason ?? 'Agent 正在请求执行工具');
|
||||
? t('confirm.batchReason', {
|
||||
count: totalCount,
|
||||
groups: grouped.length,
|
||||
risk: highestRisk,
|
||||
})
|
||||
: (requests[0]?.reason ?? t('confirm.defaultReason'));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -421,10 +428,11 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ShieldAlert size={20} color="var(--mui-palette-warning-main)" />
|
||||
<Typography variant="h6" component="span">
|
||||
工具执行确认 {totalCount > 1 && `(${totalCount} 个并行请求)`}
|
||||
{t('confirm.title')}
|
||||
{totalCount > 1 && ` ${t('confirm.parallelSuffix', { count: totalCount })}`}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Tooltip title="刷新 pending 列表">
|
||||
<Tooltip title={t('confirm.refreshList')}>
|
||||
<IconButton size="small" onClick={() => refreshPending()}>
|
||||
<RefreshCw size={16} />
|
||||
</IconButton>
|
||||
@@ -441,13 +449,14 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||
本会话中已记住拒绝的工具({Math.ceil(rememberedDenials[0].expiresInSeconds / 60)}{' '}
|
||||
分钟后自动恢复询问):
|
||||
{t('confirm.rememberedDenials', {
|
||||
minutes: Math.ceil(rememberedDenials[0].expiresInSeconds / 60),
|
||||
})}
|
||||
</Typography>
|
||||
{rememberedDenials.map((d) => (
|
||||
<Chip
|
||||
key={d.toolName}
|
||||
label={`${d.toolName} — 重新询问`}
|
||||
label={`${d.toolName} — ${t('confirm.askAgain')}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
@@ -481,7 +490,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{totalCount > 1 ? '最早过期 ' : '剩余 '}
|
||||
{totalCount > 1 ? t('confirm.earliestExpiry') : t('confirm.remaining')}
|
||||
{remainingSec}s
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
@@ -496,13 +505,13 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
{/* 选择控制条 */}
|
||||
<Box sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button size="small" onClick={handleSelectAll} disabled={allSelected}>
|
||||
全选
|
||||
{t('confirm.selectAll')}
|
||||
</Button>
|
||||
<Button size="small" onClick={handleDeselectAll} disabled={selectedCount === 0}>
|
||||
全不选
|
||||
{t('confirm.deselectAll')}
|
||||
</Button>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
|
||||
已选 {selectedCount} / {totalCount}
|
||||
{t('confirm.selectedCount', { selected: selectedCount, total: totalCount })}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -618,7 +627,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
))}
|
||||
{Object.keys(req.args).length === 0 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
(无参数)
|
||||
{t('confirm.noArgs')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
@@ -645,7 +654,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
}
|
||||
label={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
在本次会话中记住此决定(同工具不再询问)
|
||||
{t('confirm.rememberSession')}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
@@ -667,18 +676,16 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
}
|
||||
label={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
永久自动执行选中工具(跨会话不再询问,可在设置中关闭)
|
||||
{t('confirm.autoExecuteLabel')}
|
||||
{selectedCount > 0 && autoExecute && (
|
||||
<Box component="span" sx={{ color: 'warning.main', ml: 1 }}>
|
||||
· 将应用到{' '}
|
||||
{
|
||||
new Set(
|
||||
{t('confirm.autoExecuteApplies', {
|
||||
count: new Set(
|
||||
Array.from(selectedIds)
|
||||
.map((id) => requests.find((r) => r.toolCallId === id)?.toolName)
|
||||
.filter(Boolean) as string[],
|
||||
).size
|
||||
}{' '}
|
||||
个工具
|
||||
).size,
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
@@ -693,9 +700,9 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
color="error"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
title={isExpired ? '已超时,点击立即清理全部请求' : '拒绝全部请求'}
|
||||
title={isExpired ? t('confirm.denyAllExpiredTitle') : t('confirm.denyAllTitle')}
|
||||
>
|
||||
拒绝全部 ({totalCount})
|
||||
{t('confirm.denyAll', { count: totalCount })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRespond(true, false)}
|
||||
@@ -703,9 +710,15 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={isExpired || allSelected}
|
||||
title={isExpired ? '已超时(批准不再生效)' : allSelected ? '已全部选中,请使用"批准选中"' : '批准全部请求'}
|
||||
title={
|
||||
isExpired
|
||||
? t('confirm.approveAllExpiredTitle')
|
||||
: allSelected
|
||||
? t('confirm.approveAllDisabledTitle')
|
||||
: t('confirm.approveAllTitle')
|
||||
}
|
||||
>
|
||||
批准全部
|
||||
{t('confirm.approveAll')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRespond(true, true)}
|
||||
@@ -714,13 +727,13 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
size="small"
|
||||
autoFocus
|
||||
disabled={isExpired || selectedCount === 0}
|
||||
title={isExpired ? '已超时,将自动拒绝;如需重新触发请等待 Agent 重试' : undefined}
|
||||
title={isExpired ? t('confirm.expiredHint') : undefined}
|
||||
>
|
||||
{isExpired
|
||||
? '已超时'
|
||||
? t('confirm.expired')
|
||||
: selectedCount === totalCount
|
||||
? `确认执行 (${selectedCount})`
|
||||
: `批准选中 (${selectedCount})`}
|
||||
? t('confirm.confirmExecution', { count: selectedCount })
|
||||
: t('confirm.approveSelected', { count: selectedCount })}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
@@ -739,19 +752,18 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
fullWidth
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogTitle sx={{ fontSize: 14 }}>确认永久自动执行?</DialogTitle>
|
||||
<DialogTitle sx={{ fontSize: 14 }}>{t('confirm.autoExecConfirmTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
勾选后,选中的工具将永久自动执行(跨会话不再询问),包括未来的潜在危险操作。此设置可在「设置
|
||||
→ 工具管理」中关闭。
|
||||
{t('confirm.autoExecConfirmBody')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1, color: 'warning.main', fontWeight: 600 }}>
|
||||
确定要启用吗?
|
||||
{t('confirm.autoExecConfirmQuestion')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmAutoExecute(false)} color="inherit">
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -763,7 +775,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
color="warning"
|
||||
variant="contained"
|
||||
>
|
||||
确认自动执行
|
||||
{t('confirm.autoExecConfirmYes')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
@@ -19,19 +19,9 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Copy,
|
||||
Quote,
|
||||
Edit,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Eye,
|
||||
Code,
|
||||
ExternalLink,
|
||||
Pin,
|
||||
Archive,
|
||||
FileDown,
|
||||
} from 'lucide-react';
|
||||
// v0.7.2 清理: Eye/Code/ExternalLink 是 v0.6.4 死分支清理的遗留 —— 分支删除时
|
||||
// 对应图标未同步移除。此处仅保留现存两个分支(message/session)实际使用的图标。
|
||||
import { Copy, Quote, Edit, Trash2, RotateCcw, Pin, Archive, FileDown } from 'lucide-react';
|
||||
import { create } from 'zustand';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
|
||||
@@ -35,6 +35,14 @@ const SLASH_COMMANDS = [
|
||||
];
|
||||
|
||||
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
/**
|
||||
* v0.7.2 A5: 文本附件内容上限(512KB)。
|
||||
* 此前文本文件全文读入 textContent 并内联进 LLM 消息,无任何大小闸门 ——
|
||||
* 一个 10MB 日志即可制造超长请求(token 爆炸 + IPC 载荷膨胀)。
|
||||
* 超限文件只读取首段内容并附加截断标记(truncated 随消息持久化,
|
||||
* 主进程附件提示同步告知 LLM 内容不完整)。
|
||||
*/
|
||||
const MAX_TEXT_ATTACHMENT_BYTES = 512 * 1024;
|
||||
const TEXT_EXTENSIONS = [
|
||||
'txt',
|
||||
'md',
|
||||
@@ -77,6 +85,7 @@ interface Attachment {
|
||||
type: 'image' | 'text' | 'other';
|
||||
preview?: string; // 图片 base64 data URL
|
||||
textContent?: string; // 文本文件内容
|
||||
truncated?: boolean; // v0.7.2 A5: 文本附件超 512KB 被截断
|
||||
}
|
||||
|
||||
export function ChatInput(): React.JSX.Element {
|
||||
@@ -143,13 +152,31 @@ export function ChatInput(): React.JSX.Element {
|
||||
});
|
||||
attachment.preview = await compressImage(dataUri, 1024, 0.7);
|
||||
} else if (type === 'text') {
|
||||
// 文本文件读取内容
|
||||
attachment.textContent = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(new Error('文本文件读取失败'));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
// v0.7.2 A5: 文本附件大小闸门 —— 超过 512KB 时只读取首段内容(file.slice
|
||||
// 避免 FileReader 全量读入大文件),附加截断标记并告知 LLM 内容不完整。
|
||||
if (file.size > MAX_TEXT_ATTACHMENT_BYTES) {
|
||||
attachment.truncated = true;
|
||||
const headBlob = file.slice(0, MAX_TEXT_ATTACHMENT_BYTES);
|
||||
attachment.textContent = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(new Error('文本文件读取失败'));
|
||||
reader.readAsText(headBlob);
|
||||
});
|
||||
attachment.textContent += `\n\n[... 文件内容已截断:原始大小 ${formatFileSize(file.size)},仅包含前 ${formatFileSize(MAX_TEXT_ATTACHMENT_BYTES)}。如需完整内容请分段处理 ...]`;
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.warning(`文本附件 "${file.name}" 超过 512KB,已截断为前 512KB`),
|
||||
)
|
||||
.catch(() => {});
|
||||
} else {
|
||||
attachment.textContent = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(new Error('文本文件读取失败'));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return attachment;
|
||||
@@ -271,7 +298,9 @@ export function ChatInput(): React.JSX.Element {
|
||||
if (trimmed.startsWith('/')) {
|
||||
const cmd = trimmed.split(' ')[0].toLowerCase();
|
||||
if (cmd === '/clear') {
|
||||
useAgentStore.getState().clearMessages();
|
||||
// v0.7.2 A1: clearMessages 已升级为先清 DB(messages + 摘要游标 + TRACE 快照)
|
||||
// 再清前端 state;流式进行中由 store 侧拒绝并 toast 提示
|
||||
await useAgentStore.getState().clearMessages();
|
||||
setInput('');
|
||||
setAttachments([]);
|
||||
setShowSlashMenu(false);
|
||||
@@ -330,6 +359,7 @@ export function ChatInput(): React.JSX.Element {
|
||||
size: att.file.size,
|
||||
preview: att.preview,
|
||||
textContent: att.type === 'text' ? att.textContent : undefined,
|
||||
truncated: att.truncated, // v0.7.2 A5: 文本附件截断标记(随消息持久化)
|
||||
}));
|
||||
|
||||
// 图片加入 images 数组
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,11 +2,25 @@
|
||||
* AppearanceSettings — 外观设置 Tab
|
||||
*
|
||||
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
|
||||
* 功能:主题切换(深色/浅色/跟随系统)。
|
||||
* 功能:主题切换(深色/浅色/跟随系统)+ 界面语言切换(v0.7.2 P4-15)。
|
||||
*
|
||||
* 语言切换契约:
|
||||
* - 偏好持久化在 ui.locale(全局配置层,跨工作空间共享)
|
||||
* - 切换即时生效(setLocale 触发 i18next languageChanged)
|
||||
* - 启动时由 App.tsx 读取 ui.locale 并应用(默认 zh-CN)
|
||||
*/
|
||||
|
||||
import { Button, Stack, Typography, Box } from '@mui/material';
|
||||
import type { ThemeMode } from '@renderer/stores/ui-store';
|
||||
import { useConfig } from './useConfig';
|
||||
import { setLocale, getLocale, type Locale } from '@renderer/lib/i18n';
|
||||
// 字典含注册副作用,须在 t() 使用前 import
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
const LOCALES: Array<{ value: Locale; label: string }> = [
|
||||
{ value: 'zh-CN', label: '简体中文' },
|
||||
{ value: 'en-US', label: 'English' },
|
||||
];
|
||||
|
||||
export function AppearanceSettings({
|
||||
theme,
|
||||
@@ -15,6 +29,15 @@ export function AppearanceSettings({
|
||||
theme: ThemeMode;
|
||||
setTheme: (t: ThemeMode) => void;
|
||||
}) {
|
||||
// v0.7.2 P4-15: 界面语言(ui.locale 全局配置;useConfig 已含失败回滚与竞态保护)
|
||||
const [locale, setLocaleConfig] = useConfig<Locale>('ui.locale', 'zh-CN');
|
||||
|
||||
const handleLocaleChange = (next: Locale): void => {
|
||||
if (next === getLocale()) return;
|
||||
setLocaleConfig(next);
|
||||
void setLocale(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
@@ -37,6 +60,27 @@ export function AppearanceSettings({
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1, display: 'block' }}>
|
||||
界面语言
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{LOCALES.map((l) => (
|
||||
<Button
|
||||
key={l.value}
|
||||
variant={locale === l.value ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
onClick={() => handleLocaleChange(l.value)}
|
||||
>
|
||||
{l.label}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', mt: 1, display: 'block' }}>
|
||||
切换即时生效;已迁移的界面(系统消息 / 工具确认弹框 / 侧边栏 / 状态栏 / Agent
|
||||
状态区)将跟随所选语言显示
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
* 功能:主 Provider / 故障转移 Provider / 上下文窗口配置,批量保存。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
@@ -26,6 +30,15 @@ import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { PROVIDER_LABELS } from '@renderer/lib/constants';
|
||||
import { PROVIDER_URLS } from './useConfig';
|
||||
|
||||
/** v0.7.2 P3-9: 动态模型条目(渲染端子集,与 global.d.ts MetonaModelInfoLite 对齐) */
|
||||
interface ModelOption {
|
||||
id: string;
|
||||
name?: string;
|
||||
supportsToolCalling?: boolean;
|
||||
supportsThinking?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function LLMSettings() {
|
||||
// 改为本地 state + Save 按钮统一提交,避免 onChange 实时落库导致:
|
||||
// 1. 改 Base URL 时被回滚卡死(useConfig seqRef 机制与连续输入冲突)
|
||||
@@ -64,6 +77,101 @@ export function LLMSettings() {
|
||||
const [balanceError, setBalanceError] = useState<string | null>(null);
|
||||
const [balanceLoading, setBalanceLoading] = useState(false);
|
||||
|
||||
// ===== v0.7.2 P3-9: 动态模型列表 =====
|
||||
const [modelOptions, setModelOptions] = useState<ModelOption[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const [modelsLoaded, setModelsLoaded] = useState(false);
|
||||
|
||||
// ===== v0.7.2 P3-10: Ollama 模型下载 =====
|
||||
const [pullModelName, setPullModelName] = useState('');
|
||||
const [pulling, setPulling] = useState(false);
|
||||
const [pullStatus, setPullStatus] = useState<{ label: string; percent: number | null } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
if (!window.metona?.llm?.listModels) return;
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
try {
|
||||
const r = await window.metona.llm.listModels();
|
||||
if (r.success && r.data) {
|
||||
setModelOptions(r.data);
|
||||
setModelsLoaded(true);
|
||||
} else {
|
||||
setModelOptions([]);
|
||||
setModelsError(r.error ?? '获取模型列表失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setModelOptions([]);
|
||||
setModelsError((err as Error).message);
|
||||
} finally {
|
||||
setModelsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePull = useCallback(async () => {
|
||||
const name = pullModelName.trim();
|
||||
if (!name || pulling || !window.metona?.llm?.pullModel) return;
|
||||
setPulling(true);
|
||||
setPullStatus({ label: '连接 Ollama 服务…', percent: null });
|
||||
try {
|
||||
const r = await window.metona.llm.pullModel(name);
|
||||
if (r.success) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.success(`模型 ${name} 下载完成`))
|
||||
.catch(() => {});
|
||||
} else if (!r.aborted) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`模型下载失败:${r.error ?? '未知错误'}`))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`模型下载失败:${(err as Error).message}`))
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
// onOllamaPullEnded 也会复位(时序竞态下双保险),此处兜底同步复位
|
||||
setPulling(false);
|
||||
setPullStatus(null);
|
||||
}
|
||||
}, [pullModelName, pulling]);
|
||||
|
||||
const handlePullCancel = useCallback(async () => {
|
||||
if (!window.metona?.llm?.cancelPullModel) return;
|
||||
try {
|
||||
await window.metona.llm.cancelPullModel();
|
||||
} catch (err) {
|
||||
console.error('[LLMSettings] cancelPullModel failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 订阅下载进度/结束事件(全局单任务广播,无需按 model 过滤)
|
||||
const pullingRef = useRef(false);
|
||||
pullingRef.current = pulling;
|
||||
useEffect(() => {
|
||||
if (!window.metona?.llm?.onOllamaPullProgress) return;
|
||||
const offProgress = window.metona.llm.onOllamaPullProgress((d) => {
|
||||
const percent =
|
||||
d.total != null && d.completed != null && d.total > 0
|
||||
? Math.min(100, (d.completed / d.total) * 100)
|
||||
: null;
|
||||
setPullStatus({ label: d.status, percent });
|
||||
});
|
||||
const offEnded = window.metona.llm.onOllamaPullEnded(() => {
|
||||
if (!pullingRef.current) return;
|
||||
setPulling(false);
|
||||
setPullStatus(null);
|
||||
// 本地库新增了模型 — 刷新已加载的列表(未加载过则保持未加载语义)
|
||||
void loadModels();
|
||||
});
|
||||
return () => {
|
||||
offProgress();
|
||||
offEnded();
|
||||
};
|
||||
}, [loadModels]);
|
||||
|
||||
const loadBalance = useCallback(async () => {
|
||||
if (!window.metona?.llm?.getBalance) return;
|
||||
setBalanceLoading(true);
|
||||
@@ -280,6 +388,9 @@ export function LLMSettings() {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.success('配置已保存'))
|
||||
.catch(() => {});
|
||||
// v0.7.2 P3-9: 保存成功后配置与引擎 adapter 一致 —— 若列表已加载过则静默刷新,
|
||||
// 保证候选列表与刚保存的 Provider/BaseURL 同源
|
||||
if (modelsLoaded) void loadModels();
|
||||
}
|
||||
} catch (err) {
|
||||
import('@metona-team/metona-toast')
|
||||
@@ -334,15 +445,86 @@ export function LLMSettings() {
|
||||
error={urlError}
|
||||
helperText={urlError ? '需以 http:// 或 https:// 开头' : ' '}
|
||||
/>
|
||||
<TextField
|
||||
{/* v0.7.2 P3-9: 模型名称 — 自由输入 + 动态模型列表候选(freeSolo) */}
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
size="small"
|
||||
label="模型名称"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="如 deepseek-v4-pro、gpt-4o、claude-sonnet-4-5"
|
||||
error={modelHasSpace}
|
||||
helperText={modelHasSpace ? '模型名称不能包含空格' : ' '}
|
||||
disableClearable
|
||||
options={modelOptions}
|
||||
getOptionLabel={(option) => (typeof option === 'string' ? option : option.id)}
|
||||
inputValue={model}
|
||||
onInputChange={(_, newValue) => setModel(newValue)}
|
||||
loading={modelsLoading}
|
||||
renderOption={(props, option) => {
|
||||
const { key, ...optionProps } = props as { key: string } & Record<string, unknown>;
|
||||
const opt = option as ModelOption;
|
||||
return (
|
||||
<Box component="li" key={key} {...optionProps}>
|
||||
<Stack sx={{ minWidth: 0 }}>
|
||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{opt.id}
|
||||
</Typography>
|
||||
{opt.supportsToolCalling === true && (
|
||||
<Chip
|
||||
label="tools"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 16, fontSize: 9 }}
|
||||
/>
|
||||
)}
|
||||
{opt.supportsThinking === true && (
|
||||
<Chip
|
||||
label="thinking"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 16, fontSize: 9 }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{opt.description && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 10 }}>
|
||||
{opt.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
size="small"
|
||||
label="模型名称"
|
||||
placeholder="如 deepseek-v4-pro、gpt-4o、claude-sonnet-4-5"
|
||||
error={modelHasSpace}
|
||||
helperText={
|
||||
modelHasSpace
|
||||
? '模型名称不能包含空格'
|
||||
: modelsError
|
||||
? modelsError
|
||||
: modelsLoaded
|
||||
? ' '
|
||||
: '可手动输入,或点击下方按钮获取模型列表'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={loadModels}
|
||||
disabled={modelsLoading}
|
||||
startIcon={modelsLoading ? <CircularProgress size={12} /> : undefined}
|
||||
sx={{ minWidth: 120, fontSize: 11 }}
|
||||
>
|
||||
获取模型列表
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
|
||||
基于「已保存」的配置查询(修改后请先保存)
|
||||
</Typography>
|
||||
</Stack>
|
||||
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
@@ -406,6 +588,75 @@ export function LLMSettings() {
|
||||
helperText={numCtxError ? '最小值为 512' : ' '}
|
||||
/>
|
||||
)}
|
||||
{/* ===== v0.7.2 P3-10: Ollama 模型下载(adapter.pullModel 首次接线 UI) ===== */}
|
||||
{provider === 'ollama' && (
|
||||
<Stack
|
||||
spacing={1}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'secondary.main',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||
下载 Ollama 模型
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
value={pullModelName}
|
||||
onChange={(e) => setPullModelName(e.target.value)}
|
||||
placeholder="模型名,如 qwen3:8b"
|
||||
disabled={pulling}
|
||||
slotProps={{ input: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
|
||||
/>
|
||||
{pulling ? (
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
onClick={handlePullCancel}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={handlePull}
|
||||
disabled={!pullModelName.trim()}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
{pulling && pullStatus && (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<LinearProgress
|
||||
variant={pullStatus.percent != null ? 'determinate' : 'indeterminate'}
|
||||
value={pullStatus.percent ?? undefined}
|
||||
sx={{ flex: 1, height: 6, borderRadius: 3 }}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
noWrap
|
||||
sx={{ minWidth: 140, textAlign: 'right', color: 'text.secondary', fontSize: 10 }}
|
||||
>
|
||||
{pullStatus.percent != null ? `${pullStatus.percent.toFixed(0)}% · ` : ''}
|
||||
{pullStatus.label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
|
||||
从 Ollama 服务拉取模型(大模型下载耗时取决于网络,可随时取消);完成后获取模型列表即可见
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
{/* v0.3.1: DeepSeek/Agnes 上下文窗口配置(用于 Engine 压缩判断和 UI 显示,不传给 API) */}
|
||||
{provider === 'deepseek' && (
|
||||
<TextField
|
||||
|
||||
@@ -30,6 +30,8 @@ export function MCPSettings() {
|
||||
// v0.4.1: 新增传输方式选择(stdio / streamable-http)与 URL 字段
|
||||
const [newTransport, setNewTransport] = useState<'stdio' | 'streamable-http'>('stdio');
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
// v0.7.2 P2-8: 远程传输自定义请求头(JSON 对象字符串,如 {"Authorization": "Bearer xxx"})
|
||||
const [newHeaders, setNewHeaders] = useState('');
|
||||
// L-11 修复: 用 MUI Dialog 替换浏览器原生 confirm(),保持 UI 一致性
|
||||
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
|
||||
|
||||
@@ -64,6 +66,29 @@ export function MCPSettings() {
|
||||
if (!newName.trim()) return;
|
||||
if (newTransport === 'stdio' && !newCommand.trim()) return;
|
||||
if (newTransport === 'streamable-http' && !newUrl.trim()) return;
|
||||
|
||||
// v0.7.2 P2-8: 自定义请求头解析 —— 留空视为匿名连接;
|
||||
// 非空时必须是 JSON 对象(值统一转为字符串,最终由 IPC 层逐项校验)
|
||||
let headers: Record<string, string> | undefined;
|
||||
if (newTransport !== 'stdio' && newHeaders.trim()) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(newHeaders.trim());
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('需要 JSON 对象');
|
||||
}
|
||||
headers = Object.fromEntries(
|
||||
Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [k, String(v)]),
|
||||
);
|
||||
} catch {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.error('自定义请求头需为合法 JSON 对象,如 {"Authorization": "Bearer xxx"}'),
|
||||
)
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const config =
|
||||
newTransport === 'stdio'
|
||||
@@ -78,6 +103,7 @@ export function MCPSettings() {
|
||||
name: newName.trim(),
|
||||
transport: 'streamable-http' as const,
|
||||
url: newUrl.trim(),
|
||||
headers,
|
||||
enabled: true,
|
||||
};
|
||||
const r = await window.metona?.mcp?.addServer(config);
|
||||
@@ -86,6 +112,7 @@ export function MCPSettings() {
|
||||
setNewCommand('');
|
||||
setNewArgs('');
|
||||
setNewUrl('');
|
||||
setNewHeaders('');
|
||||
setNewTransport('stdio');
|
||||
setShowAdd(false);
|
||||
loadServers();
|
||||
@@ -295,12 +322,24 @@ export function MCPSettings() {
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
placeholder="Streamable HTTP URL(如 https://example.com/mcp)"
|
||||
/>
|
||||
<>
|
||||
<TextField
|
||||
size="small"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
placeholder="Streamable HTTP URL(如 https://example.com/mcp)"
|
||||
/>
|
||||
{/* v0.7.2 P2-8: 远程传输自定义请求头(鉴权/网关路由)—— 留空匿名连接 */}
|
||||
<TextField
|
||||
size="small"
|
||||
value={newHeaders}
|
||||
onChange={(e) => setNewHeaders(e.target.value)}
|
||||
placeholder='自定义请求头 (JSON,可选),如 {"Authorization": "Bearer xxx"}'
|
||||
multiline
|
||||
minRows={2}
|
||||
slotProps={{ input: { sx: { fontFamily: 'monospace', fontSize: 11 } } }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user