1100 lines
37 KiB
TypeScript
1100 lines
37 KiB
TypeScript
/**
|
||
* 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;
|
||
|
||
// ── 空闲状态轮转贴士 ──
|
||
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;
|
||
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 {
|
||
// 先转义 HTML
|
||
let text = escapeHtml(raw);
|
||
|
||
// 处理 ANSI 转义序列
|
||
// \x1b[XXm 颜色, \x1b[0m 重置, \x1b[1m 加粗, \x1b[K 清除行尾
|
||
let result = '';
|
||
let openSpan = false;
|
||
let i = 0;
|
||
|
||
while (i < text.length) {
|
||
// 匹配 \x1b[...m 或 \x1b[...K
|
||
if (text[i] === '\x1b' && text[i + 1] === '[') {
|
||
const end = text.indexOf('m', i + 2);
|
||
const endK = text.indexOf('K', i + 2);
|
||
const seqEnd = end !== -1 && (endK === -1 || end < endK) ? end : endK;
|
||
|
||
if (seqEnd !== -1 && seqEnd - i < 20) {
|
||
const codes = text.substring(i + 2, seqEnd).split(';');
|
||
|
||
// 忽略 \x1b[K (清除行)
|
||
if (text[seqEnd] === 'K') {
|
||
i = seqEnd + 1;
|
||
continue;
|
||
}
|
||
|
||
if (openSpan) {
|
||
result += '</span>';
|
||
openSpan = false;
|
||
}
|
||
|
||
// 检查是否有颜色代码
|
||
let color = '';
|
||
let bold = false;
|
||
for (const code of codes) {
|
||
if (code === '0' || code === '') {
|
||
// reset
|
||
} else if (code === '1') {
|
||
bold = true;
|
||
} else if (ANSI_COLORS[code]) {
|
||
color = ANSI_COLORS[code];
|
||
}
|
||
}
|
||
|
||
if (color || bold) {
|
||
const styles: string[] = [];
|
||
if (color) styles.push(`color:${color}`);
|
||
if (bold) styles.push('font-weight:bold');
|
||
result += `<span style="${styles.join(';')}">`;
|
||
openSpan = true;
|
||
}
|
||
|
||
i = seqEnd + 1;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// 处理回车符(ollama pull 等用 \r 更新进度)
|
||
if (text[i] === '\r' && text[i + 1] !== '\n') {
|
||
// \r 后面不是 \n → 进度更新,忽略之前的内容
|
||
// 找到上一个 \n 或开头
|
||
const lastNewline = result.lastIndexOf('\n');
|
||
if (lastNewline !== -1) {
|
||
result = result.substring(0, lastNewline + 1);
|
||
} else {
|
||
// 清除当前行(通过移除到最后一个 <span> 之后的内容太复杂,简化处理)
|
||
const spanStart = result.lastIndexOf('><');
|
||
if (spanStart !== -1) {
|
||
// 不处理,保留
|
||
}
|
||
}
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
result += text[i];
|
||
i++;
|
||
}
|
||
|
||
if (openSpan) result += '</span>';
|
||
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();
|
||
|
||
// 创建唯一终端
|
||
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 });
|
||
}
|
||
}
|
||
|
||
// 限制最大行数
|
||
if (session.lines.length > 3000) {
|
||
session.lines = session.lines.slice(-2000);
|
||
}
|
||
|
||
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 });
|
||
}
|
||
}
|
||
}
|
||
|
||
// 限制最大行数
|
||
if (session.lines.length > 3000) {
|
||
session.lines = session.lines.slice(-2000);
|
||
}
|
||
|
||
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 = [];
|
||
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 {
|
||
const container = document.querySelector('#wsTermOutput') as HTMLElement;
|
||
if (!container) return;
|
||
|
||
const session = getActiveSession();
|
||
if (!session) {
|
||
container.innerHTML = '<div class="ws-term-placeholder">无终端会话</div>';
|
||
return;
|
||
}
|
||
|
||
// 空闲状态:显示 Logo + 信息 + 贴士
|
||
if (session.lines.length === 0 && !session.running) {
|
||
renderIdleState(container);
|
||
startTipRotation(container);
|
||
return;
|
||
}
|
||
|
||
// 有内容时停止贴士轮转
|
||
stopTipRotation();
|
||
|
||
// 使用 DocumentFragment 优化渲染
|
||
const wrapper = document.createElement('div');
|
||
|
||
for (const line of session.lines) {
|
||
const div = document.createElement('div');
|
||
div.className = `ws-term-line ws-term-${line.type}`;
|
||
div.innerHTML = ansiToHtml(line.text);
|
||
wrapper.appendChild(div);
|
||
}
|
||
|
||
container.innerHTML = wrapper.innerHTML;
|
||
|
||
// 自动滚动到底部
|
||
if (session.autoScroll) {
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
// 监听滚动事件
|
||
container.onscroll = () => {
|
||
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);
|
||
// 等浏览器完成布局后再滚到底部
|
||
requestAnimationFrame(() => {
|
||
container.lastElementChild?.scrollIntoView({ block: 'end' });
|
||
});
|
||
}
|
||
}
|
||
|
||
/** 增量更新工具卡片 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);
|
||
}
|
||
} else {
|
||
// running 卡片不在 DOM 中(可能是 tab 切换后再切回或其他边缘情况)
|
||
// 回退:追加新卡片
|
||
appendToolCardDOM(tc);
|
||
}
|
||
}
|
||
|
||
/** 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">共 44 个内置工具 · MCP 动态扩展</div>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
for (const tc of toolCards) {
|
||
html += renderToolCard(tc);
|
||
}
|
||
container.innerHTML = html;
|
||
// 全量重建后等两帧让布局稳定,再滚到底部
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
container.lastElementChild?.scrollIntoView({ block: 'end' });
|
||
});
|
||
});
|
||
}
|
||
|
||
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_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆',
|
||
skill_list: '技能列表', skill_view: '查看技能',
|
||
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
|
||
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_search: '🧠', memory_add: '💾', memory_replace: '✏️', memory_remove: '🗑️',
|
||
skill_list: '🎯', skill_view: '👁️',
|
||
session_list: '📋', session_read: '📖', spawn_task: '🤖',
|
||
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') {
|
||
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 {
|
||
// 通用成功显示
|
||
const summary = JSON.stringify(r).slice(0, 300);
|
||
resultHtml = `<pre class="tool-result-content">${escapeHtml(summary)}${JSON.stringify(r).length > 300 ? '\n...' : ''}</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();
|
||
}
|
||
|