Files
metona-ollama-desktop/src/renderer/components/workspace-panel.ts
T
紫影233 bd3a06bfaf v0.16.7: 引擎修复 + 工具面板统一 + AGENT.md 改为仅工作空间加载
核心引擎修复:
- 状态转换表补全 THINKING/PARSING/EXECUTING -> COMPRESSING,修复紧急压缩成为死代码的 P1 问题
- 新增跨轮次死循环检测器(软性提示 + 硬性熔断),防止模型陷入重复工具调用死循环
- handleCompressing 空响应回到 THINKING 而非 REFLECTING,避免错误终止
- executeHooks 添加 .catch() 防止未处理的 Promise 拒绝
- ALWAYS_PARALLEL 移除 git 和 browser_evaluate(有副作用的工具不应并行)
- thinking fallback:content 为空但有 thinking 时,用 [推理过程] 作为 content 保留上下文
- 8个写类工具添加专用格式化器(含 success + message 字段)
- 清理死代码:3个未使用函数 + 3个未使用 import

工具面板统一:
- 10个工具独立下拉框统一为1个全局执行模式选择器
- FIFO 队列防止并行 showToolConfirm 导致静默取消
- delete_file 支持 paths 数组参数批量删除

工具定义与实现一致性修复:
- run_command 移除未使用的 timeout 参数,描述改为"超时可配置"
- list_directory 添加 2000 条截断逻辑 + filter_extension 参数
- calculator 正则移除 ^ 字符(parser 用 ** 替代)
- search_files/tree/web_search/fetch_top 描述与实现对齐

消息传递修复:
- trimByTokenLimit 改为原子组选择(assistant+tool_calls 与后续 tool 消息作为一组)
- 历史工具结果复用 formatToolResultForModel,与当前格式一致

AGENT.md 加载策略变更:
- 删除内置 AGENT.md 文件
- 仅从工作空间加载:有则注入,无则跳过

其他修复:
- 修复初始化失败 "Cannot convert undefined or null to object"(saveSetting null 导致 JSON.parse 陷阱)
- 修复工作空间命令行标签页 idle 状态残留导致样式错乱

版本号: 0.16.5 -> 0.16.7
2026-07-14 16:26:43 +08:00

1147 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Workspace Panel — 右侧工作空间面板
* 包含命令行和文件两个 Tab
*/
import { logInfo, logError, logDebug } from '../services/log-service.js';
import { escapeHtml, formatSize } from '../utils/utils.js';
// ── 工具卡片类型 ──
interface ToolCallRecord {
name: string;
arguments: Record<string, unknown>;
result: Record<string, unknown> | null;
status: string;
timestamp: number;
}
// ── 状态 ──
interface TerminalSession {
id: string;
title: string;
lines: TerminalLine[];
running: boolean;
autoScroll: boolean;
}
interface TerminalLine {
type: 'stdout' | 'stderr' | 'system';
text: string;
}
interface FileNode {
name: string;
type: 'file' | 'directory';
size: number | null;
modified: string;
expanded?: boolean;
children?: FileNode[];
loaded?: boolean;
}
let panelVisible = true; // 常驻显示,不可关闭
let activeTab: 'terminal' | 'tools' | 'files' = 'tools';
let terminal: TerminalSession | null = null; // 唯一终端
let workspaceDir = '';
let currentFileDir = '';
let fileTree: FileNode[] = [];
let previewFile: { name: string; content: string; path: string } | null = null;
let _counter = 0;
/** P0-2: 终端增量渲染 — 记录已渲染行数,避免全量重建 */
let _termRenderedCount = 0;
/** 终端容器 DOM 缓存,避免重复 querySelector */
let _termContainer: HTMLElement | null = null;
// ── 空闲状态轮转贴士 ──
const IDLE_TIPS = [
'💡 可以让我读取文件、搜索代码、执行命令',
'💡 试试说"帮我看看这个目录里有什么"',
'💡 我可以联网搜索并抓取网页内容',
'💡 支持 Git 操作:状态查看、提交、分支管理',
'💡 我能记住你说过的重要信息,跨会话保留',
'💡 上传图片让我分析,支持 Vision 模型',
'💡 说"搜索..."我会自动联网查找最新信息',
'💡 我会从成功经验中学习,越用越聪明',
'💡 可以让我对比文件差异、批量替换文本',
'💡 支持浏览器控制,帮你操作网页',
];
let tipIndex = Math.floor(Math.random() * IDLE_TIPS.length);
let tipTimer: ReturnType<typeof setInterval> | null = null;
/** 清空工具卡片(供外部调用) */
export function clearToolCardsExternal(): void {
toolCards = [];
if (activeTab === 'tools') renderToolCalls();
updateToolsHint();
}
/** 清空终端(供外部调用) */
export function clearTerminalExternal(): void {
if (terminal) {
terminal.lines = [];
terminal.running = false;
}
currentAiCommand = null;
_termRenderedCount = 0; // P0-2: 重置增量计数器
if (_termContainer) _termContainer.innerHTML = '';
if (activeTab === 'terminal') renderTerminal();
updateStopBtnState();
updateHint();
}
/** 工具调用卡片列表 */
let toolCards: ToolCallRecord[] = [];
/** 当前正在运行的 AI 命令(用于用户手动终止时通知 AI) */
let currentAiCommand: string | null = null;
/** 终止通知回调(由外部设置) */
let onToolTerminated: ((command: string) => void) | null = null;
function genId(): string {
return `ws_${Date.now()}_${++_counter}`;
}
// ── ANSI 颜色处理 ──
const ANSI_COLORS: Record<string, string> = {
'30': '#666', '31': '#ff6b68', '32': '#5fb55f', '33': '#f5a742',
'34': '#6db5f5', '35': '#d381d3', '36': '#38c9c9', '37': '#ccc',
'90': '#888', '91': '#ff8a8a', '92': '#7ed97e', '93': '#ffd580',
'94': '#8ec8ff', '95': '#e8a0e8', '96': '#5ee8e8', '97': '#fff',
};
function ansiToHtml(raw: string): string {
// P1-2: 正则批量替换 ANSI,代替逐字遍历
let text = escapeHtml(raw);
// 处理 \r 进度覆盖:删除 \r 前到上一换行之间的内容
text = text.replace(/\r(?!\n)/g, (match, offset) => {
const prevNewline = text.lastIndexOf('\n', offset);
return prevNewline >= 0 ? '\n' : '';
});
// 批量替换 ANSI 序列为 span(连续同色合并)
let result = '';
let i = 0;
const seqRe = /\x1b\[([\d;]*)m/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = seqRe.exec(text)) !== null) {
// 输出序列之前的文本
result += text.slice(lastIndex, match.index);
const codes = match[1].split(';');
// 关闭之前的 span(如果有)
// 简化:每个颜色序列独立包裹,不复用 span
// 查找对应的文本片段:从当前位置到下一个 ANSI 序列或文本末尾
const nextSeq = seqRe.exec(text);
const contentStart = match.index + match[0].length;
const contentEnd = nextSeq ? nextSeq.index : text.length;
seqRe.lastIndex = nextSeq ? nextSeq.index : contentEnd; // 回退
const content = text.slice(contentStart, contentEnd);
if (!content) { lastIndex = contentEnd; continue; }
let color = '';
let bold = false;
for (const code of codes) {
if (code === '1') bold = true;
else if (code === '0' || code === '') { /* reset */ }
else if (ANSI_COLORS[code]) color = ANSI_COLORS[code];
}
if (color || bold) {
const style = [color ? `color:${color}` : '', bold ? 'font-weight:bold' : ''].filter(Boolean).join(';');
result += `<span style="${style}">${content}</span>`;
} else {
result += content;
}
lastIndex = contentEnd;
}
result += text.slice(lastIndex);
return result;
}
// ── 初始化 ──
export async function initWorkspacePanel(): Promise<void> {
// ── 强制重置默认页签:Electron 窗口关闭再打开时渲染进程未销毁,
// 模块级 activeTab 可能保留上次用户手动切换的值
activeTab = 'tools';
renderPanel();
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) {
logDebug('非桌面环境,跳过工作空间面板初始化');
return;
}
// 获取工作空间目录
try {
const dirInfo = await bridge.workspace.getDir();
workspaceDir = dirInfo.dir;
currentFileDir = workspaceDir;
logInfo('工作空间目录', workspaceDir);
} catch (err) {
logError('获取工作空间目录失败', (err as Error).message);
}
// 监听进程输出
bridge.workspace.onOutput((data) => {
logDebug('工作空间收到输出', `ID: ${data.id} | ${data.type} | ${data.data.length}字节`);
appendTerminalOutput(data.id, data.type, data.data);
});
bridge.workspace.onExit((data) => {
logDebug('工作空间收到退出', `ID: ${data.id} | code: ${data.code}`);
handleProcessExit(data.id, data.code);
});
// 监听 AI 命令实时输出,流式推送到终端面板
bridge.workspace.onCmdOutput((data) => {
appendToolStreamOutput(data.command, data.type, data.data);
});
// 监听 AI 命令执行完毕
bridge.workspace.onCmdDone((data) => {
logDebug('工作空间 AI 命令完成', `command: ${data.command.slice(0, 50)} | exitCode: ${data.exitCode}`);
handleToolDone(data.command, data.exitCode);
});
// 绑定 UI 事件
bindEvents();
// ── 监听工作空间目录变更(来自设置面板)──
window.addEventListener('workspaceDirChanged', ((e: CustomEvent) => {
const newDir = e.detail?.dir;
if (newDir && newDir !== workspaceDir) {
workspaceDir = newDir;
currentFileDir = newDir;
logInfo('工作空间目录已刷新', newDir);
// 如果当前在文件页签,立即重新加载
if (activeTab === 'files') {
loadFiles(newDir);
}
// 刷新终端空闲状态中的目录显示
if (activeTab === 'terminal') {
renderTerminal();
}
// 刷新文件页签(即使不在当前 tab,下次切过去就是新路径)
renderPanel();
}
}) as EventListener);
// 创建唯一终端
terminal = {
id: genId(),
title: '终端',
lines: [],
running: false,
autoScroll: true
};
logInfo('工作空间面板已初始化');
// 首次渲染:显示空闲状态
renderPanel();
}
function bindEvents(): void {
// Tab 切换
document.querySelector('#wsTabTerminal')?.addEventListener('click', () => switchTab('terminal'));
document.querySelector('#wsTabTools')?.addEventListener('click', () => switchTab('tools'));
document.querySelector('#wsTabFiles')?.addEventListener('click', () => switchTab('files'));
// 停止按钮
document.querySelector('#wsStopBtn')?.addEventListener('click', killCurrentProcess);
// 清空按钮
document.querySelector('#wsClearBtn')?.addEventListener('click', clearTerminal);
// 工具清空按钮
document.querySelector('#wsToolsClearBtn')?.addEventListener('click', clearToolCards);
// 文件刷新
document.querySelector('#wsFileRefreshBtn')?.addEventListener('click', () => {
loadFiles(currentFileDir || workspaceDir);
});
// 文件返回上级
document.querySelector('#wsFileUpBtn')?.addEventListener('click', () => {
if (currentFileDir && currentFileDir !== workspaceDir) {
const parent = currentFileDir.replace(/[/\\][^/\\]+$/, '');
loadFiles(parent);
}
});
// 关闭预览
document.querySelector('#wsPreviewClose')?.addEventListener('click', () => {
previewFile = null;
renderPanel();
});
}
// ── 面板控制 ──(常驻显示,无需切换)
function switchTab(tab: 'terminal' | 'tools' | 'files'): void {
activeTab = tab;
renderPanel();
if (tab === 'files') {
loadFiles(currentFileDir || workspaceDir);
}
}
// ── 终端管理 ──
function getActiveSession(): TerminalSession | undefined {
return terminal ?? undefined;
}
/** AI Tool Calling 实时输出 → 流式追加到活跃终端 */
function appendToolStreamOutput(command: string, type: 'stdout' | 'stderr', data: string): void {
const session = getActiveSession();
if (!session) return;
// 首次输出时显示命令头
if (!currentAiCommand) {
currentAiCommand = command;
session.running = true;
session.lines.push({ type: 'system', text: `$ ${command} (AI)` });
updateHint();
updateStopBtnState();
}
// 流式追加输出(按行分割,保留最后一行用于后续追加)
const lines = data.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (i < lines.length - 1 || line) {
const cleanLine = line.replace(/\r/g, '');
if (cleanLine) session.lines.push({ type, text: cleanLine });
}
}
// P1-3: 限制最大行数,但保留系统消息(命令头/退出状态等)
if (session.lines.length > 3000) {
const systemLines = session.lines.filter(l => l.type === 'system');
const otherLines = session.lines.filter(l => l.type !== 'system');
session.lines = [...systemLines.slice(-10), ...otherLines.slice(-1990)];
}
renderTerminal();
}
/** AI Tool Calling 命令执行完毕 */
function handleToolDone(command: string, exitCode: number | null): void {
const session = getActiveSession();
if (!session) return;
// 显示退出状态
const exitMsg = exitCode === 0 ? '✓ AI 命令执行成功' : `✗ AI 命令退出 (code: ${exitCode})`;
session.lines.push({ type: 'system', text: exitMsg });
session.running = false;
currentAiCommand = null;
renderTerminal();
updateStopBtnState();
updateHint();
// 命令结束后,如果没有其他正在运行的工具,切到工具页签看结果
const hasRunningTools = toolCards.some(c => c.status === 'running' && c.name !== 'run_command');
if (!hasRunningTools && toolCards.length > 0) {
setTimeout(() => switchToTab('tools'), 600);
}
}
function appendTerminalOutput(sessionId: string, type: 'stdout' | 'stderr', data: string): void {
if (!terminal || terminal.id !== sessionId) {
logDebug('工作空间输出丢失', `未匹配终端: ${sessionId}`);
return;
}
const session = terminal;
// 按行分割,处理 \r 进度更新
const lines = data.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (i < lines.length - 1 || line) {
// 处理 \r 覆盖(进度条效果)
if (line.includes('\r') && !line.endsWith('\n')) {
const parts = line.split('\r');
const lastPart = parts[parts.length - 1];
if (session.lines.length > 0 && session.lines[session.lines.length - 1].type !== 'system') {
session.lines[session.lines.length - 1] = { type, text: lastPart };
} else {
session.lines.push({ type, text: lastPart });
}
} else {
const cleanLine = line.replace(/\r/g, '');
if (cleanLine) session.lines.push({ type, text: cleanLine });
}
}
}
// P1-3: 限制最大行数,保留系统消息
if (session.lines.length > 3000) {
const systemLines = session.lines.filter(l => l.type === 'system');
const otherLines = session.lines.filter(l => l.type !== 'system');
session.lines = [...systemLines.slice(-10), ...otherLines.slice(-1990)];
}
renderTerminal();
}
function handleProcessExit(sessionId: string, code: number | null): void {
if (!terminal || terminal.id !== sessionId) {
logDebug('工作空间退出信号丢失', `未匹配终端: ${sessionId}`);
return;
}
const session = terminal;
session.running = false;
const exitMsg = code === 0 ? '✓ 进程正常退出' : `✗ 进程退出 (code: ${code})`;
session.lines.push({ type: 'system', text: exitMsg });
logInfo('工作空间进程退出', `ID: ${sessionId} | code: ${code}`);
renderTerminal();
// 更新停止按钮状态
updateStopBtnState();
}
function killCurrentProcess(): void {
const session = getActiveSession();
if (!session) return;
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) return;
// 终止用户终端进程(如果有)
if (session.running) {
bridge.workspace.kill(session.id);
}
// 终止当前 AI 命令
bridge.workspace.cmdKill();
const cmd = currentAiCommand;
session.lines.push({ type: 'system', text: '■ 命令已手动终止' });
session.running = false;
currentAiCommand = null;
renderTerminal();
updateStopBtnState();
updateHint();
// 通知 AI 命令被用户终止
if (cmd && onToolTerminated) {
onToolTerminated(cmd);
}
}
function clearTerminal(): void {
const session = getActiveSession();
if (!session) return;
session.lines = [];
_termRenderedCount = 0; // P0-2: 重置增量计数器
if (_termContainer) _termContainer.innerHTML = '';
renderTerminal();
}
// ── 文件浏览器 ──
async function loadFiles(dirPath: string): Promise<void> {
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) return;
try {
const result = await bridge.workspace.listDir(dirPath);
if (result.success && result.entries) {
currentFileDir = dirPath;
fileTree = result.entries.map(e => ({
name: e.name,
type: e.type,
size: e.size,
modified: e.modified
}));
}
} catch (err) {
logError('加载目录失败', (err as Error).message);
}
renderPanel();
}
async function openFile(filePath: string, fileName: string): Promise<void> {
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) return;
try {
const result = await bridge.workspace.readFile(filePath);
if (result.success && result.content !== undefined) {
previewFile = { name: fileName, content: result.content, path: filePath };
renderPanel();
}
} catch (err) {
logError('读取文件失败', (err as Error).message);
}
}
function navigateToDir(dirName: string): void {
const newPath = currentFileDir + '/' + dirName;
loadFiles(newPath.replace(/\/+/g, '/'));
}
// ── 渲染 ──
export function renderPanel(): void {
const panel = document.querySelector('#workspacePanel') as HTMLElement;
if (!panel) return;
const termTab = document.querySelector('#wsTabTerminal') as HTMLElement;
const toolsTab = document.querySelector('#wsTabTools') as HTMLElement;
const filesTab = document.querySelector('#wsTabFiles') as HTMLElement;
if (termTab) termTab.classList.toggle('active', activeTab === 'terminal');
if (toolsTab) toolsTab.classList.toggle('active', activeTab === 'tools');
if (filesTab) filesTab.classList.toggle('active', activeTab === 'files');
const termContent = document.querySelector('#wsTerminalContent') as HTMLElement;
const toolsContent = document.querySelector('#wsToolsContent') as HTMLElement;
const filesContent = document.querySelector('#wsFilesContent') as HTMLElement;
if (termContent) termContent.style.display = activeTab === 'terminal' ? '' : 'none';
if (toolsContent) toolsContent.style.display = activeTab === 'tools' ? '' : 'none';
if (filesContent) filesContent.style.display = activeTab === 'files' ? '' : 'none';
if (activeTab === 'terminal') {
renderTerminal();
} else if (activeTab === 'tools') {
renderToolCalls();
} else {
renderFileList();
}
updateStopBtnState();
}
function renderTerminal(): void {
if (!_termContainer) _termContainer = document.querySelector('#wsTermOutput') as HTMLElement;
const container = _termContainer;
if (!container) return;
const session = getActiveSession();
if (!session) {
container.innerHTML = '<div class="ws-term-placeholder">无终端会话</div>';
_termRenderedCount = 0;
return;
}
// 空闲状态:显示 Logo + 信息 + 贴士
if (session.lines.length === 0 && !session.running) {
_termRenderedCount = 0;
renderIdleState(container);
startTipRotation(container);
return;
}
// 有内容时停止贴士轮转
stopTipRotation();
// ── P0-2: 终端增量渲染 — 只追加新行,不重建已有 DOM ──
// 检测重置(行数骤减说明被清空)
if (_termRenderedCount > session.lines.length) {
container.innerHTML = '';
_termRenderedCount = 0;
}
// 从 idle 状态切换到有内容时,清空 idle DOM
// idle 状态用 position:absolute 覆盖在内容上方,不清空会同时显示
if (_termRenderedCount === 0 && container.querySelector('.ws-idle-state')) {
container.innerHTML = '';
}
// 增量追加新行
const fragment = document.createDocumentFragment();
for (let i = _termRenderedCount; i < session.lines.length; i++) {
const line = session.lines[i];
const div = document.createElement('div');
div.className = `ws-term-line ws-term-${line.type}`;
div.innerHTML = ansiToHtml(line.text); // P1-2: ANSI 颜色,正则批量替换
fragment.appendChild(div);
}
if (fragment.childNodes.length > 0) {
container.appendChild(fragment);
_termRenderedCount = session.lines.length;
}
// 行数超限时清理旧 DOM 节点并同步计数
if (container.children.length > 3000) {
const excess = container.children.length - 2000;
for (let i = 0; i < excess && container.firstChild; i++) {
container.removeChild(container.firstChild);
}
_termRenderedCount = 2000;
}
// 自动滚动到底部
if (session.autoScroll) {
container.scrollTop = container.scrollHeight;
}
// 监听滚动事件(只绑定一次)
if (!(container as any)._scrollBound) {
(container as any)._scrollBound = true;
container.addEventListener('scroll', () => {
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
session.autoScroll = atBottom;
});
}
}
/** 渲染空闲状态 */
function renderIdleState(container: HTMLElement): void {
const tip = IDLE_TIPS[tipIndex];
const dirDisplay = workspaceDir
? workspaceDir.replace(/.*[/\\]/, '~/.')
: '未设置';
// 截取目录最后一段
const shortDir = workspaceDir
? '...' + workspaceDir.slice(-30)
: '未设置';
container.innerHTML = `
<div class="ws-idle-state">
<div class="ws-idle-logo">
<pre class="ws-idle-ascii"> ╭───────╮
│ ◕ ◕ │
│ ▽ │
╰─┬───┬─╯
╱│ │╲
│ │ ╲
🦙 Metona</pre>
</div>
<div class="ws-idle-info">
<div class="ws-idle-info-row">
<span class="ws-idle-label">工作空间</span>
<span class="ws-idle-value" title="${escapeHtml(workspaceDir)}">${escapeHtml(shortDir)}</span>
</div>
</div>
<div class="ws-idle-divider"></div>
<div class="ws-idle-tip" id="wsIdleTip">${escapeHtml(tip)}</div>
</div>
`;
// 重置滚动
container.scrollTop = 0;
}
/** 启动贴士轮转 */
function startTipRotation(container: HTMLElement): void {
if (tipTimer) return;
tipTimer = setInterval(() => {
// 只在空闲状态时轮转
const session = getActiveSession();
if (!session || session.lines.length > 0 || session.running) {
stopTipRotation();
return;
}
tipIndex = (tipIndex + 1) % IDLE_TIPS.length;
const tipEl = container.querySelector('#wsIdleTip');
if (tipEl) {
tipEl.classList.add('fading');
setTimeout(() => {
tipEl.textContent = IDLE_TIPS[tipIndex];
tipEl.classList.remove('fading');
}, 300);
}
}, 6000);
}
/** 停止贴士轮转 */
function stopTipRotation(): void {
if (tipTimer) {
clearInterval(tipTimer);
tipTimer = null;
}
}
function renderFileList(): void {
const container = document.querySelector('#wsFileList') as HTMLElement;
if (!container) return;
// 更新路径显示
const pathDisplay = document.querySelector('#wsFilePath') as HTMLElement;
if (pathDisplay) {
const relPath = currentFileDir.replace(workspaceDir, '~');
pathDisplay.textContent = relPath || '~';
pathDisplay.title = currentFileDir;
}
if (fileTree.length === 0) {
const relDir = currentFileDir.replace(workspaceDir, '~') || '~';
container.innerHTML = `
<div class="ws-idle-state">
<div class="ws-idle-icon ws-idle-icon-lg">📂</div>
<div class="ws-idle-desc">${escapeHtml(relDir)}</div>
<div class="ws-idle-divider"></div>
<div class="ws-idle-hint">空目录 · AI 创建的文件会出现在这里</div>
</div>
`;
return;
}
container.innerHTML = fileTree.map(entry => {
const icon = entry.type === 'directory' ? '📁' : getFileIcon(entry.name);
const size = entry.size !== null ? formatSize(entry.size) : '';
const fullPath = (currentFileDir + '/' + entry.name).replace(/\/+/g, '/');
return `<div class="ws-file-item" data-name="${escapeHtml(entry.name)}" data-type="${entry.type}" data-path="${escapeHtml(fullPath)}">
<span class="ws-file-icon">${icon}</span>
<span class="ws-file-name">${escapeHtml(entry.name)}</span>
<span class="ws-file-size">${size}</span>
</div>`;
}).join('');
// 绑定点击事件
container.querySelectorAll('.ws-file-item').forEach(item => {
item.addEventListener('click', () => {
const el = item as HTMLElement;
const type = el.dataset.type;
const name = el.dataset.name!;
const filePath = el.dataset.path!;
if (type === 'directory') {
navigateToDir(name);
} else {
openFile(filePath, name);
}
});
item.addEventListener('dblclick', () => {
const el = item as HTMLElement;
if (el.dataset.type === 'directory') {
navigateToDir(el.dataset.name!);
}
});
});
// 渲染预览
renderPreview();
}
function renderPreview(): void {
const previewContainer = document.querySelector('#wsPreview') as HTMLElement;
if (!previewContainer) return;
if (!previewFile) {
previewContainer.style.display = 'none';
return;
}
previewContainer.style.display = '';
const previewTitle = document.querySelector('#wsPreviewTitle') as HTMLElement;
const previewContent = document.querySelector('#wsPreviewContent') as HTMLElement;
if (previewTitle) previewTitle.textContent = previewFile.name;
if (previewContent) {
const escaped = escapeHtml(previewFile.content);
const lines = escaped.split('\n');
const numbered = lines.map((line, i) =>
`<span class="ws-preview-line"><span class="ws-line-num">${i + 1}</span>${line}</span>`
).join('\n');
previewContent.innerHTML = `<pre class="ws-preview-pre">${numbered}</pre>`;
}
}
function updateStopBtnState(): void {
const stopBtn = document.querySelector('#wsStopBtn') as HTMLButtonElement;
if (!stopBtn) return;
const running = currentAiCommand !== null;
stopBtn.disabled = !running;
stopBtn.classList.toggle('active', running);
}
function updateHint(): void {
const hint = document.querySelector('#wsTermHint') as HTMLElement;
if (!hint) return;
if (currentAiCommand) {
hint.textContent = `⏳ AI 正在执行: ${currentAiCommand.slice(0, 40)}${currentAiCommand.length > 40 ? '...' : ''}`;
hint.classList.add('running');
} else {
hint.textContent = '等待 AI 执行命令...';
hint.classList.remove('running');
}
}
// ── 工具函数 ──
function getFileIcon(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || '';
const iconMap: Record<string, string> = {
ts: '🔷', js: '🟡', json: '📋', md: '📝', txt: '📄',
py: '🐍', rs: '🦀', go: '🔵', java: '☕', c: '📄', cpp: '📄',
html: '🌐', css: '🎨', svg: '🖼️', png: '🖼️', jpg: '🖼️', gif: '🖼️',
sh: '⚡', bat: '⚡', yaml: '⚙️', yml: '⚙️', toml: '⚙️',
xml: '📋', csv: '📊', zip: '📦', tar: '📦', gz: '📦',
};
return iconMap[ext] || '📄';
}
// ── 工具调用管理 ──
/** 是否有命令正在终端中执行 */
function isTerminalBusy(): boolean {
return currentAiCommand !== null || (terminal?.running ?? false);
}
/** 添加工具调用卡片(由外部调用) */
export function addToolCard(tc: ToolCallRecord): void {
toolCards.push(tc);
// ── 立即追加 DOM 节点,不等待全量渲染 ──
if (activeTab === 'tools') {
appendToolCardDOM(tc);
}
const isPending = tc.status === 'pending';
if (tc.name === 'run_command') {
// run_command:始终切到终端
switchToTab('terminal');
} else if (!isTerminalBusy() && !isPending) {
// 终端空闲且非 pending 时才切到工具页签
// pending 卡片不切换 tab(准备中不应打断当前视图)
switchToTab('tools');
}
}
/** 工具开始执行:将 pending 卡片升级为 running,或新增 running 卡片 */
export function startToolCard(tc: ToolCallRecord): void {
// 查找是否有同名 pending 卡片(由 onToolCallPrepare 提前创建)
const pendingIdx = toolCards.findIndex(
c => c.name === tc.name && c.status === 'pending'
);
if (pendingIdx !== -1) {
// 升级 pending → running,增量更新 DOM
toolCards[pendingIdx] = tc;
if (activeTab === 'tools') {
updateToolCardDOM(tc);
}
} else {
// 没有提前创建的 pending 卡片,走正常追加流程
addToolCard(tc);
}
updateToolsHint();
}
/** 更新工具调用卡片状态(由外部调用)—— 增量更新 DOM,不触发全量重绘 */
export function updateToolCard(tc: ToolCallRecord): void {
// 找到最后一个同名且 running/pending 的卡片(pending 被 onToolCallStart 更新为 running
for (let i = toolCards.length - 1; i >= 0; i--) {
if (toolCards[i].name === tc.name && (toolCards[i].status === 'running' || toolCards[i].status === 'pending')) {
toolCards[i] = tc;
break;
}
}
// 增量更新 DOM:只替换这一个卡片的 DOM 节点,保留其他卡片不动
if (activeTab === 'tools') {
updateToolCardDOM(tc);
}
updateToolsHint();
}
/** 将单个工具卡片追加到 #wsToolsOutput DOM 容器 */
function appendToolCardDOM(tc: ToolCallRecord): void {
const container = document.querySelector('#wsToolsOutput') as HTMLElement;
if (!container) return;
// 首次添加卡片时清除空闲状态占位
if (container.querySelector('.ws-idle-state')) {
container.innerHTML = '';
}
const wrapper = document.createElement('div');
wrapper.innerHTML = renderToolCard(tc);
const card = wrapper.firstElementChild as HTMLElement;
if (card) {
card.dataset.toolName = tc.name;
container.appendChild(card);
}
// 始终滚到底部
scrollToolsToBottom(container);
}
/** 增量更新工具卡片 DOM 节点(找到 pending/running 状态的卡片并替换) */
function updateToolCardDOM(tc: ToolCallRecord): void {
const container = document.querySelector('#wsToolsOutput') as HTMLElement;
if (!container) return;
// 查找同名且状态为 pending 或 running 的卡片 DOM 节点
const runningCard = (
container.querySelector(`.tool-call-card[data-tool-name="${escapeAttr(tc.name)}"].tool-call-running`) ||
container.querySelector(`.tool-call-card[data-tool-name="${escapeAttr(tc.name)}"].tool-call-pending`)
) as HTMLElement | null;
if (runningCard) {
const wrapper = document.createElement('div');
wrapper.innerHTML = renderToolCard(tc);
const newCard = wrapper.firstElementChild as HTMLElement;
if (newCard) {
newCard.dataset.toolName = tc.name;
runningCard.replaceWith(newCard);
// 卡片内容变化后可能变高(如出现执行结果),跟随滚动
scrollToolsToBottom(container);
}
} else {
// running 卡片不在 DOM 中(可能是 tab 切换后再切回或其他边缘情况)
// 回退:追加新卡片
appendToolCardDOM(tc);
}
}
/** 工具页签滚动到底部(双重 rAF 保证布局完成) */
function scrollToolsToBottom(container: HTMLElement): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
});
});
}
/** CSS.escape 回退(兼容旧 Electron */
function escapeAttr(value: string): string {
try { return CSS.escape(value); } catch { return value.replace(/["\\]/g, '\\$&'); }
}
function clearToolCards(): void {
toolCards = [];
if (activeTab === 'tools') {
renderToolCalls();
}
updateToolsHint();
}
function updateToolsHint(): void {
const hint = document.querySelector('#wsToolsHint') as HTMLElement;
if (!hint) return;
if (toolCards.length === 0) {
hint.textContent = '等待工具调用...';
hint.classList.remove('running');
return;
}
const byStatus: Record<string, string[]> = {};
for (const tc of toolCards) {
const name = getToolDisplayName(tc.name);
if (!byStatus[tc.status]) byStatus[tc.status] = [];
byStatus[tc.status].push(name);
}
const parts: string[] = [];
const order = ['pending', 'running', 'success', 'error', 'cancelled'];
const icons: Record<string, string> = {
pending: '📝', running: '🔄', success: '✅', error: '❌', cancelled: '🚫'
};
for (const s of order) {
if (byStatus[s]?.length) {
parts.push(`${icons[s] || ''} ${byStatus[s].join(', ')}`);
}
}
hint.textContent = parts.join(' · ');
hint.classList.toggle('running', !!(byStatus.pending || byStatus.running));
}
function renderToolCalls(): void {
const container = document.querySelector('#wsToolsOutput') as HTMLElement;
if (!container) return;
if (toolCards.length === 0) {
container.innerHTML = `
<div class="ws-idle-state">
<div class="ws-idle-icon-grid">
<div class="ws-idle-icon-item" title="文件系统"><span>📄</span><small>文件</small></div>
<div class="ws-idle-icon-item" title="系统命令"><span>💻</span><small>命令</small></div>
<div class="ws-idle-icon-item" title="联网搜索"><span>🔍</span><small>搜索</small></div>
<div class="ws-idle-icon-item" title="浏览器"><span>🌐</span><small>浏览器</small></div>
<div class="ws-idle-icon-item" title="Git"><span>🔖</span><small>Git</small></div>
<div class="ws-idle-icon-item" title="记忆"><span>🧠</span><small>记忆</small></div>
<div class="ws-idle-icon-item" title="技能"><span>🎯</span><small>技能</small></div>
<div class="ws-idle-icon-item" title="子代理"><span>🤖</span><small>代理</small></div>
</div>
<div class="ws-idle-divider"></div>
<div class="ws-idle-desc">AI 对话中自动调用工具,结果在此展示</div>
<div class="ws-idle-hint">共 40 个内置工具 · MCP 动态扩展</div>
</div>
`;
return;
}
let html = '';
for (const tc of toolCards) {
html += renderToolCard(tc);
}
container.innerHTML = html;
scrollToolsToBottom(container);
}
function getToolDisplayName(name: string): string {
const names: Record<string, string> = {
read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录',
search_files: '搜索文件', create_directory: '创建目录', delete_file: '删除文件', run_command: '执行命令',
move_file: '移动文件', copy_file: '复制文件', web_fetch: '网页抓取',
edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
memory: '记忆管理',
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪',
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
};
return names[name] || name;
}
function getToolIcon(name: string): string {
const icons: Record<string, string> = {
read_file: '📄', write_file: '✏️', list_directory: '📁',
search_files: '🔍', create_directory: '📂', delete_file: '🗑️', run_command: '💻',
move_file: '📦', copy_file: '📋', web_fetch: '🌐',
edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️',
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
git: '🔖', compress: '🗜️', web_search: '🔍',
memory: '🧠',
session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋',
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
};
return icons[name] || '🔧';
}
function renderToolCard(tc: ToolCallRecord): string {
const icon = getToolIcon(tc.name);
const name = getToolDisplayName(tc.name);
const statusClass = tc.status === 'running' ? 'tool-call-running'
: tc.status === 'pending' ? 'tool-call-pending'
: tc.status === 'error' ? 'tool-call-error'
: '';
const statusLabels: Record<string, string> = {
pending: '📝 准备中…', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消'
};
const status = statusLabels[tc.status] || tc.status;
const args = tc.arguments || {};
let paramsHtml = '';
if (args.path) paramsHtml += `<div class="tool-param">路径: <code>${escapeHtml(String(args.path))}</code></div>`;
if (args.command) paramsHtml += `<div class="tool-param">命令: <code>${escapeHtml(String(args.command))}</code></div>`;
if (args.query) paramsHtml += `<div class="tool-param">查询: <code>${escapeHtml(String(args.query))}</code></div>`;
if (args.url) paramsHtml += `<div class="tool-param">URL: <code>${escapeHtml(String(args.url))}</code></div>`;
if (args.source) paramsHtml += `<div class="tool-param">源: <code>${escapeHtml(String(args.source))}</code></div>`;
if (args.destination) paramsHtml += `<div class="tool-param">目标: <code>${escapeHtml(String(args.destination))}</code></div>`;
if (args.selector) paramsHtml += `<div class="tool-param">选择器: <code>${escapeHtml(String(args.selector))}</code></div>`;
if (args.action) paramsHtml += `<div class="tool-param">操作: <code>${escapeHtml(String(args.action))}</code></div>`;
let resultHtml = '';
if (tc.result) {
const r = tc.result as Record<string, unknown>;
if (r.success) {
if (tc.name === 'run_command') {
const stdout = String(r.stdout || '').slice(0, 500);
const stderr = String(r.stderr || '').slice(0, 300);
if (stdout) resultHtml += `<pre class="tool-result-content">${escapeHtml(stdout)}${String(r.stdout || '').length > 500 ? '\n...' : ''}</pre>`;
if (stderr) resultHtml += `<pre class="tool-result-content tool-result-stderr">${escapeHtml(stderr)}</pre>`;
resultHtml += `<div class="tool-result-meta">exit: ${r.exitCode || 0}</div>`;
} else if (tc.name === 'web_search' && r.results) {
const results = r.results as Array<{ title: string; url: string }>;
resultHtml = results.slice(0, 5).map((item, i) =>
`<div class="tool-result-entry">[${i + 1}] ${escapeHtml(item.title)}<br><code>${escapeHtml(item.url)}</code></div>`
).join('');
resultHtml += `<div class="tool-result-meta">共 ${r.total || results.length} 条结果</div>`;
} else if (tc.name === 'read_file' && r.content) {
const content = String(r.content);
const lines = content.split('\n');
const preview = lines.slice(0, 15).join('\n');
resultHtml = `<pre class="tool-result-content">${escapeHtml(preview)}${lines.length > 15 ? `\n... (共 ${lines.length} 行)` : ''}</pre>`;
} else if (tc.name === 'list_directory' && r.entries) {
const entries = r.entries as Array<{ name: string; type: string }>;
resultHtml = entries.slice(0, 20).map(e =>
`<div class="tool-result-entry">${e.type === 'directory' ? '📁' : '📄'} ${escapeHtml(e.name)}</div>`
).join('');
if (entries.length > 20) resultHtml += `<div class="tool-result-entry">... 共 ${entries.length} 项</div>`;
} else if (tc.name === 'write_file') {
resultHtml = `<div class="tool-result-entry">✅ 已写入 ${escapeHtml(String(r.path || ''))}</div>`;
} else if (tc.name === 'delete_file') {
if (r.batch) {
resultHtml = `<div class="tool-result-entry">✅ 批量删除 ${r.successCount}/${r.totalPaths} 个路径</div>`;
if (r.results) {
for (const res of r.results) {
resultHtml += `<div class="tool-result-entry">${res.success ? '✅' : '❌'} ${escapeHtml(String(res.path || ''))}${res.success ? '' : ' — ' + escapeHtml(String(res.error || ''))}</div>`;
}
}
} else {
resultHtml = `<div class="tool-result-entry">✅ 已删除 ${escapeHtml(String(r.path || ''))}</div>`;
}
} else if (tc.name === 'create_directory') {
resultHtml = `<div class="tool-result-entry">✅ 已创建 ${escapeHtml(String(r.path || ''))}</div>`;
} else {
// P2-1: 通用成功显示,统一截断到 500 字符
const maxResultChars = 500;
const raw = JSON.stringify(r);
const summary = raw.length > maxResultChars ? raw.slice(0, maxResultChars) : raw;
resultHtml = `<pre class="tool-result-content">${escapeHtml(summary)}${raw.length > maxResultChars ? `\n... (${raw.length - maxResultChars} 字符已截断)` : ''}</pre>`;
}
} else {
resultHtml = `<div class="tool-result-error">${escapeHtml(String(r.error || '未知错误'))}</div>`;
}
}
return `<div class="tool-call-card ${statusClass}" data-tool-name="${escapeHtml(tc.name)}">
<div class="tool-call-header">
<span class="tool-call-icon">${icon}</span>
<span class="tool-call-name">${escapeHtml(name)}</span>
<span class="tool-call-status">${status}</span>
</div>
${paramsHtml ? `<div class="tool-call-params">${paramsHtml}</div>` : ''}
${resultHtml ? `<div class="tool-call-result">${resultHtml}</div>` : ''}
</div>`;
}
// ── 外部调用接口 ──
/** 获取工作空间目录 */
export function getWorkspaceDirPath(): string {
return workspaceDir;
}
/** 外部自动切换 tab */
export function switchToTab(tab: 'terminal' | 'tools' | 'files'): void {
activeTab = tab;
renderPanel();
}
/** 检查工作空间是否有 pending 或 running 的工具卡片 */
export function hasActiveCards(): boolean {
return toolCards.some(c => c.status === 'pending' || c.status === 'running');
}
/** 在工具页签显示工作提示(AI 正在处理中,但还没有具体的工具调用) */
export function showWorkingHint(text: string): void {
// 只在工具页签空闲时显示提示,避免覆盖已有的工具卡片
if (hasActiveCards()) return;
if (activeTab !== 'tools') return;
const hint = document.querySelector('#wsToolsHint') as HTMLElement;
if (hint) {
hint.textContent = text;
hint.classList.add('running');
}
}
/** 清除工作提示,恢复显示 updateToolsHint */
export function clearWorkingHint(): void {
updateToolsHint();
}