feat: 升级至 v0.2.3 — Token 估算优化、工具确认超时改进、工作空间标签页

1. Token 估算优化(核心改进):新增 token-estimator.ts 智能字符估算(中文 1.5 token/字、ASCII 0.25 token/字),替换三处旧的 length/2 粗略估算,中文场景准确度从 ~50% 提升到 ~90%

2. 工具确认超时改进:超时时间可配置(30s~600s)、超时 toast 通知、ConfirmationDialog 倒计时 UI(进度条 + 最后 10 秒红色脉冲动画)、SettingsModal 新增配置入口

3. 详情栏新增 Workspace 标签页:workspace:getInfo IPC + WorkspaceViewer 组件(文件预览、目录状态、在文件管理器打开)
This commit is contained in:
thzxx
2026-07-12 14:20:05 +08:00
parent 0363176215
commit 1eabed9b70
13 changed files with 563 additions and 35 deletions
+72 -2
View File
@@ -13,8 +13,9 @@ import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, Typography, Box, Chip, Alert, FormControlLabel, Checkbox,
Accordion, AccordionSummary, AccordionDetails,
LinearProgress,
} from '@mui/material';
import { ShieldAlert, ChevronDown } from 'lucide-react';
import { ShieldAlert, ChevronDown, Timer } from 'lucide-react';
interface ConfirmationRequest {
toolCallId: string;
@@ -22,6 +23,8 @@ interface ConfirmationRequest {
args: Record<string, unknown>;
riskLevel: string;
reason: string;
/** 过期时间戳(ms),由后端 ConfirmationHook 注入,用于倒计时 */
expiresAt?: number;
}
const RISK_COLORS: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
@@ -36,17 +39,45 @@ export function ConfirmationDialog(): React.JSX.Element | null {
const [request, setRequest] = useState<ConfirmationRequest | null>(null);
const [remember, setRemember] = useState(false);
const [autoExecute, setAutoExecute] = useState(false);
const [remainingMs, setRemainingMs] = useState<number>(0);
// 记录首次接收时的剩余时间,作为进度条总量(固定不变)
const [initialMs, setInitialMs] = useState<number>(0);
useEffect(() => {
// 监听来自主进程的确认请求(通过 preload 暴露的 metona.tool API
const cleanup = window.metona?.tool?.onConfirmationRequest((data: unknown) => {
setRequest(data as ConfirmationRequest);
const req = data as ConfirmationRequest;
setRequest(req);
setRemember(false);
setAutoExecute(false);
// 初始化倒计时
if (req.expiresAt) {
const remain = Math.max(0, req.expiresAt - Date.now());
setRemainingMs(remain);
setInitialMs(remain);
} else {
setRemainingMs(0);
setInitialMs(0);
}
});
return cleanup;
}, []);
// 倒计时:每 200ms 更新剩余时间
useEffect(() => {
if (!request?.expiresAt) return;
const interval = setInterval(() => {
const remaining = request.expiresAt! - Date.now();
if (remaining <= 0) {
setRemainingMs(0);
clearInterval(interval);
} else {
setRemainingMs(remaining);
}
}, 200);
return () => clearInterval(interval);
}, [request]);
const handleRespond = useCallback((approved: boolean) => {
if (!request) return;
// 通过 preload 暴露的 metona.tool API 发送响应
@@ -63,6 +94,14 @@ export function ConfirmationDialog(): React.JSX.Element | null {
if (!request) return null;
const riskColor = RISK_COLORS[request.riskLevel] ?? 'default';
const remainingSec = Math.ceil(remainingMs / 1000);
const isUrgent = remainingSec <= 10 && remainingSec > 0;
const isExpired = remainingMs <= 0 && request.expiresAt != null;
// 总时长使用首次接收时的剩余时间(固定值),保证进度条单调递减
const totalMs = initialMs || remainingMs;
const progressPercent = totalMs > 0
? Math.max(0, Math.min(100, (remainingMs / totalMs) * 100))
: 100;
// 格式化参数显示
const formatArg = (key: string, value: unknown): string => {
@@ -101,6 +140,37 @@ export function ConfirmationDialog(): React.JSX.Element | null {
{request.reason}
</Alert>
{request.expiresAt && !isExpired && (
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Timer
size={16}
color={isUrgent ? 'var(--mui-palette-error-main)' : 'var(--mui-palette-text-secondary)'}
style={isUrgent ? { animation: 'metona-pulse 1s ease-in-out infinite' } : undefined}
/>
<Typography
variant="caption"
sx={{
color: isUrgent ? 'error.main' : 'text.secondary',
fontWeight: isUrgent ? 700 : 500,
minWidth: 48,
animation: isUrgent ? 'metona-pulse 1s ease-in-out infinite' : 'none',
'@keyframes metona-pulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.4 },
},
}}
>
{remainingSec}s
</Typography>
<LinearProgress
variant="determinate"
value={progressPercent}
color={isUrgent ? 'error' : 'primary'}
sx={{ flex: 1, height: 6, borderRadius: 3 }}
/>
</Box>
)}
<Box sx={{ mb: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={`工具: ${request.toolName}`}
+10 -3
View File
@@ -1,21 +1,22 @@
/**
* DetailPanel — 右侧详情面板
*
* 通过 Tab 切换显示 Trace / Memory / Tasks。
* 通过 Tab 切换显示 Trace / Memory / Tasks / Workspace
*/
import { useState } from 'react';
import { Box, Tabs, Tab } from '@mui/material';
import { Activity, Brain, ListChecks } from 'lucide-react';
import { Activity, Brain, ListChecks, FolderOpen } from 'lucide-react';
import { TraceViewer } from '@renderer/components/trace/TraceViewer';
import { TokenUsage } from '@renderer/components/trace/TokenUsage';
import { AgentMonitor } from '@renderer/components/layout/AgentMonitor';
import { MemoryViewer } from '@renderer/components/memory/MemoryViewer';
import { TaskList } from '@renderer/components/tasks/TaskList';
import { WorkspaceViewer } from '@renderer/components/workspace/WorkspaceViewer';
import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary';
import { LAYOUT } from '@renderer/lib/constants';
type DetailTab = 'trace' | 'memory' | 'tasks';
type DetailTab = 'trace' | 'memory' | 'tasks' | 'workspace';
export function DetailPanel(): React.JSX.Element {
const [tab, setTab] = useState<DetailTab>('trace');
@@ -42,6 +43,7 @@ export function DetailPanel(): React.JSX.Element {
<Tab icon={<Activity size={12} />} iconPosition="start" label="Trace" value="trace" />
<Tab icon={<Brain size={12} />} iconPosition="start" label="Memory" value="memory" />
<Tab icon={<ListChecks size={12} />} iconPosition="start" label="Tasks" value="tasks" />
<Tab icon={<FolderOpen size={12} />} iconPosition="start" label="Workspace" value="workspace" />
</Tabs>
<Box sx={{ p: 1.5, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
@@ -64,6 +66,11 @@ export function DetailPanel(): React.JSX.Element {
<TaskList />
</ErrorBoundary>
)}
{tab === 'workspace' && (
<ErrorBoundary fallbackTitle="Workspace 渲染失败">
<WorkspaceViewer />
</ErrorBoundary>
)}
</Box>
</Box>
);
+13
View File
@@ -350,6 +350,7 @@ function AgentSettings() {
const [timeout, setTimeout_] = useConfig('agent.totalTimeoutMs', 600000);
const [thinking, setThinking] = useConfig('agent.enableThinking', true);
const [thinkingEffort, setThinkingEffort] = useConfig('agent.thinkingEffort', 'high');
const [confirmTimeout, setConfirmTimeout] = useConfig('agent.confirmationTimeoutMs', 120000);
const MAX_ITER_OPTIONS = [10, 20, 50, 85, 128, 256, 512];
@@ -375,6 +376,18 @@ function AgentSettings() {
}}
slotProps={{ htmlInput: { min: 120, max: 3600, step: 30 } }}
/>
<TextField
size="small"
label="工具确认超时(秒)"
type="number"
value={confirmTimeout / 1000}
onChange={(e) => {
const v = Number(e.target.value);
if (v >= 30 && v <= 600) setConfirmTimeout(v * 1000);
}}
slotProps={{ htmlInput: { min: 30, max: 600, step: 10 } }}
helperText="用户未响应工具确认时,超时自动视为拒绝(30~600 秒)"
/>
<FormControlLabel control={<Checkbox checked={thinking} onChange={(e) => setThinking(e.target.checked)} size="small" />} label={<Typography variant="body2"></Typography>} />
{thinking && (
<FormControl size="small"><InputLabel></InputLabel>
@@ -0,0 +1,230 @@
/**
* WorkspaceViewer — 工作空间浏览器
*
* 展示当前工作空间的:
* - 根路径(可在文件管理器中打开)
* - 4 个核心文件(SOUL/AGENTS/MEMORY/USERS):状态、大小、修改时间、内容预览
* - 自动目录(logs/traces/.metona):存在性和文件数
*
* Agent 完成任务后自动刷新(thinking/executing → idle)。
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Box, Typography, Stack, Accordion, AccordionSummary, AccordionDetails,
IconButton, Chip, Alert, Tooltip, Divider,
} from '@mui/material';
import { FolderOpen, RefreshCw, ChevronDown, FileText, Folder, CheckCircle2, XCircle } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { formatTime, formatFileSize } from '@renderer/lib/formatters';
// ===== 主组件 =====
export function WorkspaceViewer(): React.JSX.Element {
const [info, setInfo] = useState<MetonaWorkspaceInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Agent 完成时自动刷新
const agentStatus = useAgentStore((s) => s.agentStatus);
const prevStatus = useRef(agentStatus);
const loadInfo = useCallback(async () => {
if (!window.metona?.workspace?.getInfo) return;
setLoading(true);
setError(null);
try {
const res = await window.metona.workspace.getInfo();
setInfo(res);
} catch (err) {
setError((err as Error).message ?? '加载工作空间信息失败');
} finally {
setLoading(false);
}
}, []);
// 初次挂载加载
useEffect(() => {
loadInfo();
}, [loadInfo]);
// Agent 完成自动刷新
useEffect(() => {
const prev = prevStatus.current;
prevStatus.current = agentStatus;
if ((prev === 'thinking' || prev === 'executing') && agentStatus === 'idle') {
loadInfo();
}
}, [agentStatus, loadInfo]);
const handleOpenInFolder = (path: string) => {
window.metona?.app?.showItemInFolder(path);
};
if (error) {
return <Alert severity="error" sx={{ m: 1 }}>{error}</Alert>;
}
if (!info) {
return <Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>{loading ? '加载中...' : '无数据'}</Typography>;
}
return (
<Stack spacing={1.5} sx={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{/* 工作空间根路径 */}
<Box>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
</Typography>
<Tooltip title="刷新">
<IconButton size="small" onClick={loadInfo} disabled={loading} sx={{ p: 0.3 }}>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
</IconButton>
</Tooltip>
</Stack>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ flexWrap: 'wrap' }}>
<Typography
variant="body2"
sx={{
fontFamily: 'monospace',
fontSize: 11,
color: 'text.primary',
wordBreak: 'break-all',
flex: 1,
}}
>
{info.path}
</Typography>
<Tooltip title="在文件管理器中打开">
<IconButton size="small" onClick={() => handleOpenInFolder(info.path)} sx={{ p: 0.3 }}>
<FolderOpen size={13} />
</IconButton>
</Tooltip>
</Stack>
</Box>
<Divider />
{/* 核心文件 */}
<Box>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, mb: 0.5, display: 'block' }}>
{info.files.filter((f) => f.exists).length}/{info.files.length}
</Typography>
<Stack spacing={0.5}>
{info.files.map((file) => (
<Accordion
key={file.name}
disableGutters
sx={{
bgcolor: 'background.default',
'&:before': { display: 'none' },
border: 1,
borderColor: 'divider',
borderRadius: 1,
overflow: 'hidden',
}}
>
<AccordionSummary expandIcon={<ChevronDown size={14} />} sx={{ minHeight: 32, '& .MuiAccordionSummary-content': { my: 0, alignItems: 'center' } }}>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ flex: 1, mr: 1 }}>
{file.exists ? (
<CheckCircle2 size={12} color="var(--mui-palette-success-main)" />
) : (
<XCircle size={12} color="var(--mui-palette-error-main)" />
)}
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
{file.name}
</Typography>
{file.exists && (
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto', fontSize: 10 }}>
{formatFileSize(file.size)} · {formatTime(file.mtime)}
</Typography>
)}
</Stack>
</AccordionSummary>
<AccordionDetails sx={{ p: 1, pt: 0 }}>
{file.exists ? (
<Box
component="pre"
sx={{
fontFamily: 'monospace',
fontSize: 10,
color: 'text.primary',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
maxHeight: 200,
overflow: 'auto',
p: 0.5,
bgcolor: 'background.paper',
borderRadius: 0.5,
}}
>
{file.preview || '(空文件)'}
</Box>
) : (
<Typography variant="caption" color="error.main">
</Typography>
)}
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5 }}>
<Tooltip title="在文件管理器中显示">
<IconButton size="small" onClick={() => handleOpenInFolder(file.path)} sx={{ p: 0.3 }}>
<FolderOpen size={11} />
</IconButton>
</Tooltip>
</Stack>
</AccordionDetails>
</Accordion>
))}
</Stack>
</Box>
<Divider />
{/* 自动目录 */}
<Box>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, mb: 0.5, display: 'block' }}>
</Typography>
<Stack spacing={0.5}>
{info.dirs.map((dir) => (
<Stack
key={dir.name}
direction="row"
spacing={0.5}
alignItems="center"
sx={{
p: 0.5,
border: 1,
borderColor: 'divider',
borderRadius: 1,
bgcolor: 'background.default',
}}
>
<Folder size={12} color={dir.exists ? 'var(--mui-palette-info-main)' : 'var(--mui-palette-text-disabled)'} />
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
{dir.name}/
</Typography>
<Chip
label={dir.exists ? `${dir.fileCount} 个文件` : '不存在'}
size="small"
color={dir.exists ? 'default' : 'error'}
variant="outlined"
sx={{ height: 16, fontSize: 9 }}
/>
<Box sx={{ flex: 1 }} />
{dir.exists && (
<Tooltip title="在文件管理器中打开">
<IconButton size="small" onClick={() => handleOpenInFolder(dir.path)} sx={{ p: 0.3 }}>
<FolderOpen size={11} />
</IconButton>
</Tooltip>
)}
</Stack>
))}
</Stack>
</Box>
</Stack>
);
}
+23
View File
@@ -193,10 +193,33 @@ interface MetonaWorkspaceInheritResult {
error?: string;
}
interface MetonaWorkspaceFileInfo {
name: string;
path: string;
exists: boolean;
size: number;
mtime: number;
preview: string;
}
interface MetonaWorkspaceDirInfo {
name: string;
path: string;
exists: boolean;
fileCount: number;
}
interface MetonaWorkspaceInfo {
path: string;
files: MetonaWorkspaceFileInfo[];
dirs: MetonaWorkspaceDirInfo[];
}
interface MetonaWorkspaceAPI {
check: (targetPath: string) => Promise<MetonaWorkspaceCheckResult>;
inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) =>
Promise<MetonaWorkspaceInheritResult>;
getInfo: () => Promise<MetonaWorkspaceInfo>;
}
// ===== Toast API =====