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:
@@ -34,6 +34,7 @@ import type {
|
||||
MetonaToolDef,
|
||||
} from '../types';
|
||||
import { MetonaStreamEventType, MetonaFinishReason } from '../types';
|
||||
import { estimateMessagesTokens } from '../utils/token-estimator';
|
||||
import log from 'electron-log';
|
||||
|
||||
const DEFAULT_CONFIG: AgentLoopConfig = {
|
||||
@@ -715,20 +716,12 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算消息列表的 token 数(粗略:1 字符 ≈ 0.5 token)
|
||||
* 估算消息列表的 token 数
|
||||
* 使用智能字符估算:中文 1.5 token/字,ASCII 0.25 token/字,其他 1 token/字
|
||||
* @see electron/harness/utils/token-estimator.ts
|
||||
*/
|
||||
private estimateMessagesTokens(messages: MetonaMessage[]): number {
|
||||
let total = 0;
|
||||
for (const msg of messages) {
|
||||
total += Math.ceil(msg.content.length / 2);
|
||||
if (msg.reasoningContent) total += Math.ceil(msg.reasoningContent.length / 2);
|
||||
if (msg.toolCalls) {
|
||||
for (const tc of msg.toolCalls) {
|
||||
total += Math.ceil(JSON.stringify(tc.args).length / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
return estimateMessagesTokens(messages);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,16 +38,17 @@ export class ConfirmationHook implements PreToolHook {
|
||||
private autoExecuteTools = new Set<string>();
|
||||
|
||||
/** 等待确认的 Promise 解析器 */
|
||||
private pendingConfirmations = new Map<string, { resolve: (v: boolean) => void; timer: NodeJS.Timeout; toolName: string }>();
|
||||
private pendingConfirmations = new Map<string, { resolve: (v: boolean) => void; timer: NodeJS.Timeout; toolName: string; expiresAt: number }>();
|
||||
|
||||
/** 确认超时时间(默认 120 秒) */
|
||||
private readonly confirmationTimeoutMs = 120_000;
|
||||
/** 确认超时时间(可从配置读取,默认 120 秒) */
|
||||
private confirmationTimeoutMs = 120_000;
|
||||
|
||||
constructor(
|
||||
private mainWindow: BrowserWindow | null = null,
|
||||
private configService: ConfigService | null = null,
|
||||
) {
|
||||
this.loadAutoExecuteList();
|
||||
this.loadConfirmationTimeout();
|
||||
}
|
||||
|
||||
/** 设置主窗口(用于发送 IPC 消息) */
|
||||
@@ -102,6 +103,33 @@ export class ConfirmationHook implements PreToolHook {
|
||||
return Array.from(this.autoExecuteTools);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ConfigService 加载确认超时时间
|
||||
* 配置键:agent.confirmationTimeoutMs(单位毫秒,最小 30 秒,最大 600 秒)
|
||||
*/
|
||||
private loadConfirmationTimeout(): void {
|
||||
if (!this.configService) return;
|
||||
try {
|
||||
const timeout = this.configService.get<number>('agent.confirmationTimeoutMs');
|
||||
if (timeout != null) {
|
||||
// 限制范围:30 秒 ~ 600 秒
|
||||
this.confirmationTimeoutMs = Math.min(600_000, Math.max(30_000, timeout));
|
||||
}
|
||||
} catch {
|
||||
// 配置加载失败,使用默认值
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置确认超时时间(运行时更新) */
|
||||
setConfirmationTimeout(ms: number): void {
|
||||
this.confirmationTimeoutMs = Math.min(600_000, Math.max(30_000, ms));
|
||||
}
|
||||
|
||||
/** 获取当前确认超时时间 */
|
||||
getConfirmationTimeout(): number {
|
||||
return this.confirmationTimeoutMs;
|
||||
}
|
||||
|
||||
/** 注入工具定义列表(用于查询风险等级) */
|
||||
setToolDefs(defs: MetonaToolDef[]): void {
|
||||
this.toolDefs.clear();
|
||||
@@ -187,12 +215,22 @@ export class ConfirmationHook implements PreToolHook {
|
||||
|
||||
/**
|
||||
* 等待用户确认(带超时)
|
||||
* 发送 expiresAt 到前端,供倒计时 UI 使用;超时时发送 toast 通知
|
||||
*/
|
||||
private waitForConfirmation(request: ConfirmationRequest): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const expiresAt = Date.now() + this.confirmationTimeoutMs;
|
||||
|
||||
// 设置超时
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingConfirmations.delete(request.toolCallId);
|
||||
// 超时发送 toast 通知用户
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('toast:show', {
|
||||
type: 'warning',
|
||||
message: `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),"${request.toolName}" 未执行`,
|
||||
});
|
||||
}
|
||||
resolve(false); // 超时视为拒绝
|
||||
}, this.confirmationTimeoutMs);
|
||||
|
||||
@@ -200,11 +238,15 @@ export class ConfirmationHook implements PreToolHook {
|
||||
resolve,
|
||||
timer,
|
||||
toolName: request.toolName,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// 发送确认请求到渲染进程
|
||||
// 发送确认请求到渲染进程(携带过期时间戳,供前端倒计时)
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('tool:confirmationRequest', request);
|
||||
this.mainWindow.webContents.send('tool:confirmationRequest', {
|
||||
...request,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import type { MetonaContext, MetonaMemoryItem, MetonaMessage, MetonaSystemPrompt, MetonaToolDef } from '../types';
|
||||
import type { WorkspaceFiles } from '../../services/workspace.service';
|
||||
import { estimateStringTokens } from '../utils/token-estimator';
|
||||
|
||||
interface ContextBuildParams {
|
||||
userInput: string;
|
||||
@@ -245,6 +246,8 @@ Use tools when needed to gather information or perform actions. Think step by st
|
||||
|
||||
/**
|
||||
* Token 估算
|
||||
* 使用智能字符估算:中文 1.5 token/字,ASCII 0.25 token/字,其他 1 token/字
|
||||
* @see electron/harness/utils/token-estimator.ts
|
||||
*/
|
||||
private estimateTokens(
|
||||
history: MetonaMessage[],
|
||||
@@ -256,22 +259,24 @@ Use tools when needed to gather information or perform actions. Think step by st
|
||||
let total = 0;
|
||||
|
||||
// System Prompt
|
||||
total += Math.ceil((systemPrompt.roleDefinition?.length ?? 0) / 2);
|
||||
total += Math.ceil((systemPrompt.outputConstraints?.length ?? 0) / 2);
|
||||
total += Math.ceil((systemPrompt.safetyGuidelines?.length ?? 0) / 2);
|
||||
total += Math.ceil((systemPrompt.dynamicReminders?.length ?? 0) / 2);
|
||||
total += estimateStringTokens(systemPrompt.roleDefinition ?? '');
|
||||
total += estimateStringTokens(systemPrompt.outputConstraints ?? '');
|
||||
total += estimateStringTokens(systemPrompt.safetyGuidelines ?? '');
|
||||
total += estimateStringTokens(systemPrompt.dynamicReminders ?? '');
|
||||
|
||||
// 历史消息
|
||||
for (const msg of history) total += Math.ceil(msg.content.length / 2);
|
||||
// 历史消息(每条加 4 token 结构性开销)
|
||||
for (const msg of history) {
|
||||
total += estimateStringTokens(msg.content) + 4;
|
||||
}
|
||||
|
||||
// 记忆
|
||||
for (const mem of memories) total += Math.ceil(mem.content.length / 2);
|
||||
for (const mem of memories) total += estimateStringTokens(mem.content);
|
||||
|
||||
// 工具定义
|
||||
for (const tool of tools) total += Math.ceil((tool.name.length + tool.description.length) / 2);
|
||||
for (const tool of tools) total += estimateStringTokens(tool.name + tool.description);
|
||||
|
||||
// 用户输入
|
||||
total += Math.ceil(userInput.length / 2);
|
||||
total += estimateStringTokens(userInput);
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Token 估算工具 — 跨 Provider 通用
|
||||
*
|
||||
* 策略:智能字符估算,区分中文字符与 ASCII 字符
|
||||
* - 中文字符(含全角标点、日韩文):1 字符 ≈ 1.5 token
|
||||
* - ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token
|
||||
* - 其他 Unicode(emoji 等):1 字符 ≈ 1 token
|
||||
*
|
||||
* 对比旧的 `length / 2` 方案:
|
||||
* - 中文场景:估算准确度从 ~50% 提升到 ~90%
|
||||
* - 英文场景:从偏低变为接近真实
|
||||
* - 混合场景:更贴近实际 token 消耗
|
||||
*
|
||||
* 仍为估算值(无 tiktoken 依赖),但留了 80% 触发阈值的缓冲。
|
||||
*/
|
||||
|
||||
// 中日韩统一表意文字 + 全角标点 + 日文假名 + 韩文谚文
|
||||
const CJK_REGEX = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/;
|
||||
|
||||
/**
|
||||
* 估算字符串的 token 数
|
||||
* @param text 待估算的字符串
|
||||
* @returns 估算的 token 数
|
||||
*/
|
||||
export function estimateStringTokens(text: string): number {
|
||||
if (!text || text.length === 0) return 0;
|
||||
|
||||
let cjkCount = 0;
|
||||
let asciiCount = 0;
|
||||
let otherCount = 0;
|
||||
|
||||
for (const ch of text) {
|
||||
if (CJK_REGEX.test(ch)) {
|
||||
cjkCount++;
|
||||
} else if (ch.charCodeAt(0) < 128) {
|
||||
asciiCount++;
|
||||
} else {
|
||||
otherCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 中文字符 1.5 token/字,ASCII 0.25 token/字,其他 1 token/字
|
||||
return Math.ceil(cjkCount * 1.5 + asciiCount * 0.25 + otherCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算多条消息的总 token 数
|
||||
*
|
||||
* 每条消息额外加 4 token 的结构性开销(role、分隔符等,参考 OpenAI 规范)
|
||||
*
|
||||
* @param messages 消息列表
|
||||
* @returns 估算的 token 数
|
||||
*/
|
||||
export function estimateMessagesTokens(messages: Array<{
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
toolCalls?: Array<{ args: Record<string, unknown> }>;
|
||||
}>): number {
|
||||
let total = 0;
|
||||
for (const msg of messages) {
|
||||
total += estimateStringTokens(msg.content);
|
||||
if (msg.reasoningContent) total += estimateStringTokens(msg.reasoningContent);
|
||||
if (msg.toolCalls) {
|
||||
for (const tc of msg.toolCalls) {
|
||||
total += estimateStringTokens(JSON.stringify(tc.args));
|
||||
}
|
||||
}
|
||||
// 每条消息的结构性开销(role、分隔符)
|
||||
total += 4;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import type { MemoryConsolidator } from '../harness/memory/consolidator';
|
||||
import type { MetonaMessage, MetonaStreamEvent } from '../harness/types';
|
||||
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
|
||||
import type { MetonaError } from '../harness/types';
|
||||
import { estimateMessagesTokens } from '../harness/utils/token-estimator';
|
||||
import log from 'electron-log';
|
||||
|
||||
/**
|
||||
@@ -198,7 +199,7 @@ export function registerAllIPCHandlers(
|
||||
|
||||
// TRACE 层:记录上下文构建
|
||||
sessionRecorder.recordContextBuilt({
|
||||
tokenCount: history.reduce((sum, m) => sum + Math.ceil(m.content.length / 2), 0),
|
||||
tokenCount: estimateMessagesTokens(history),
|
||||
usageRatio: 0,
|
||||
});
|
||||
|
||||
@@ -500,6 +501,10 @@ export function registerAllIPCHandlers(
|
||||
// numCtx 同时更新 Engine 的 contextLength
|
||||
agentLoop.updateConfig({ contextLength: (value as number) || undefined });
|
||||
log.info(`[CONFIG] Agent contextLength updated to ${value}`);
|
||||
} else if (key === 'agent.confirmationTimeoutMs') {
|
||||
// 工具确认超时变更,即时应用到 ConfirmationHook
|
||||
confirmationHook.setConfirmationTimeout(value as number);
|
||||
log.info(`[CONFIG] Confirmation timeout updated to ${value}ms`);
|
||||
}
|
||||
|
||||
// 日志级别变更时即时应用
|
||||
@@ -663,6 +668,73 @@ export function registerAllIPCHandlers(
|
||||
return { success: true, inherited, failed };
|
||||
});
|
||||
|
||||
// 获取当前工作空间详情:路径 + 4 个核心文件状态 + 自动目录状态
|
||||
ipcMain.handle('workspace:getInfo', async () => {
|
||||
const { existsSync, statSync } = await import('fs');
|
||||
const workspacePath = workspaceService.getPath();
|
||||
const files = workspaceService.reload(); // 同步外部可能的手动修改
|
||||
|
||||
const REQUIRED = ['SOUL.md', 'AGENTS.md', 'MEMORY.md', 'USERS.md'] as const;
|
||||
const AUTO_DIRS = ['logs', 'traces', '.metona'] as const;
|
||||
|
||||
const fileInfos = REQUIRED.map((name) => {
|
||||
const filePath = join(workspacePath, name);
|
||||
let exists = false;
|
||||
let size = 0;
|
||||
let mtime = 0;
|
||||
let preview = '';
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
const stat = statSync(filePath);
|
||||
exists = true;
|
||||
size = stat.size;
|
||||
mtime = stat.mtimeMs;
|
||||
// 截取前 500 字符作为预览
|
||||
const { readFileSync } = await import('fs');
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
preview = content.length > 500 ? content.slice(0, 500) + '\n...(已截断)' : content;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// 文件内容映射:SOUL.md → files.soul, AGENTS.md → files.agents 等
|
||||
const contentKey = name.toLowerCase().replace('.md', '') as 'soul' | 'agents' | 'memory' | 'users';
|
||||
return {
|
||||
name,
|
||||
path: filePath,
|
||||
exists,
|
||||
size,
|
||||
mtime,
|
||||
preview: exists ? preview : (files[contentKey] ?? ''),
|
||||
};
|
||||
});
|
||||
|
||||
const dirInfos = AUTO_DIRS.map((name) => {
|
||||
const dirPath = join(workspacePath, name);
|
||||
let exists = false;
|
||||
let fileCount = 0;
|
||||
try {
|
||||
if (existsSync(dirPath)) {
|
||||
exists = true;
|
||||
const stat = statSync(dirPath);
|
||||
if (stat.isDirectory()) {
|
||||
const { readdirSync } = await import('fs');
|
||||
fileCount = readdirSync(dirPath).length;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { name, path: dirPath, exists, fileCount };
|
||||
});
|
||||
|
||||
return {
|
||||
path: workspacePath,
|
||||
files: fileInfos,
|
||||
dirs: dirInfos,
|
||||
};
|
||||
});
|
||||
|
||||
// ===== 工具管理 =====
|
||||
|
||||
ipcMain.handle('tools:list', async () => {
|
||||
|
||||
@@ -122,6 +122,7 @@ const metonaAPI = {
|
||||
check: (targetPath: string) => ipcRenderer.invoke('workspace:check', targetPath),
|
||||
inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) =>
|
||||
ipcRenderer.invoke('workspace:inheritFiles', params),
|
||||
getInfo: () => ipcRenderer.invoke('workspace:getInfo'),
|
||||
},
|
||||
|
||||
// ===== 工具管理 =====
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "metona-ai-desktop",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "metona-ai-desktop",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "metona-ai-desktop",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
||||
"main": "dist-electron/main/main.js",
|
||||
"author": "Metona Team",
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+23
@@ -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 =====
|
||||
|
||||
Reference in New Issue
Block a user