feat: 添加工作空间面板 - 右侧终端/文件浏览器
- 新增主进程 workspace.ts:spawn 进程管理,流式输出,安全检查 - IPC 双向通信:workspace:exec(on/send 模式,无超时限制) - preload 暴露 workspace API - 渲染进程 workspace-panel.ts:多终端 Tab、ANSI 颜色渲染、文件浏览器 - 设置面板添加工作空间目录配置 - 启动时自动创建 workspace 目录 - 窗口关闭时自动清理所有子进程 - 帮助文档更新
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
handleRunCommand
|
||||
} from './tool-handlers.js';
|
||||
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
|
||||
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js';
|
||||
|
||||
export function setupIPC(): void {
|
||||
ipcMain.handle('dialog:openFile', async (_, options?: { filters?: Array<{ name: string; extensions: string[] }> }) => {
|
||||
@@ -108,4 +109,61 @@ export function setupIPC(): void {
|
||||
ipcMain.handle('tool:setAllowedDirs', (_, dirs: string[]) => {
|
||||
setAllowedDirs(dirs);
|
||||
});
|
||||
|
||||
// ── Workspace IPC ──
|
||||
|
||||
// 获取工作空间目录
|
||||
ipcMain.handle('workspace:getDir', () => {
|
||||
return { dir: getWorkspaceDir(), defaultDir: getWorkspaceDir() };
|
||||
});
|
||||
|
||||
// 设置工作空间目录
|
||||
ipcMain.handle('workspace:setDir', (_, dir: string) => {
|
||||
try {
|
||||
setWorkspaceDir(dir);
|
||||
return { success: true, dir: getWorkspaceDir() };
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// 列出工作空间目录
|
||||
ipcMain.handle('workspace:listDir', (_, dirPath?: string) => {
|
||||
return listWorkspaceDir(dirPath);
|
||||
});
|
||||
|
||||
// 读取工作空间文件(复用 tool-handlers)
|
||||
ipcMain.handle('workspace:readFile', async (_, filePath: string) => {
|
||||
return handleReadFile({ path: filePath });
|
||||
});
|
||||
|
||||
// 启动命令(流式输出,通过 workspace:output 事件推送)
|
||||
ipcMain.on('workspace:exec', (event, { id, command, cwd }: { id: string; command: string; cwd?: string }) => {
|
||||
console.log('[IPC] workspace:exec', id, command.slice(0, 200));
|
||||
|
||||
const result = startProcess(
|
||||
id,
|
||||
command,
|
||||
cwd,
|
||||
(type, data) => {
|
||||
// 流式推送输出到渲染进程
|
||||
event.sender.send('workspace:output', { id, type, data });
|
||||
},
|
||||
(code) => {
|
||||
// 通知进程结束
|
||||
event.sender.send('workspace:exit', { id, code });
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
event.sender.send('workspace:output', { id, type: 'stderr', data: result.error + '\n' });
|
||||
event.sender.send('workspace:exit', { id, code: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
// 停止命令
|
||||
ipcMain.on('workspace:kill', (_, id: string) => {
|
||||
console.log('[IPC] workspace:kill', id);
|
||||
killProcess(id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { setupIPC } from './ipc.js';
|
||||
import { createTray } from './tray.js';
|
||||
import { createMenu } from './menu.js';
|
||||
import { showNotification } from './utils.js';
|
||||
import { ensureWorkspaceDir, killAllProcesses } from './workspace.js';
|
||||
|
||||
const APP_NAME = 'Metona Ollama';
|
||||
const ICON_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.png');
|
||||
@@ -143,6 +144,7 @@ if (!gotTheLock) {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
ensureWorkspaceDir();
|
||||
setupIPC();
|
||||
createMainWindow();
|
||||
createTray();
|
||||
@@ -165,6 +167,8 @@ app.on('window-all-closed', () => {
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
// 清理所有工作空间进程
|
||||
killAllProcesses();
|
||||
// 通知渲染进程释放显存
|
||||
mainWindow?.webContents.send('app-quit');
|
||||
});
|
||||
|
||||
@@ -38,5 +38,23 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
|
||||
},
|
||||
removeAllListeners: (channel: string) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
},
|
||||
workspace: {
|
||||
getDir: () => ipcRenderer.invoke('workspace:getDir'),
|
||||
setDir: (dir: string) => ipcRenderer.invoke('workspace:setDir', dir),
|
||||
listDir: (dirPath?: string) => ipcRenderer.invoke('workspace:listDir', dirPath),
|
||||
readFile: (filePath: string) => ipcRenderer.invoke('workspace:readFile', filePath),
|
||||
exec: (params: { id: string; command: string; cwd?: string }) => {
|
||||
ipcRenderer.send('workspace:exec', params);
|
||||
},
|
||||
kill: (id: string) => {
|
||||
ipcRenderer.send('workspace:kill', id);
|
||||
},
|
||||
onOutput: (callback: (data: { id: string; type: 'stdout' | 'stderr'; data: string }) => void) => {
|
||||
ipcRenderer.on('workspace:output', (_: unknown, data: { id: string; type: 'stdout' | 'stderr'; data: string }) => callback(data));
|
||||
},
|
||||
onExit: (callback: (data: { id: string; code: number | null }) => void) => {
|
||||
ipcRenderer.on('workspace:exit', (_: unknown, data: { id: string; code: number | null }) => callback(data));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Workspace - 终端进程管理器
|
||||
* 管理长时间运行的子进程,通过 IPC 流式推送输出
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { app } from 'electron';
|
||||
import { checkPathAllowed, checkCommandAllowed } from './tool-security.js';
|
||||
|
||||
export const DEFAULT_WORKSPACE_DIR = path.join(app.getPath('userData'), 'workspace');
|
||||
|
||||
/** 活跃进程 Map */
|
||||
const activeProcesses = new Map<string, ChildProcess>();
|
||||
|
||||
/** 确保工作空间目录存在 */
|
||||
export function ensureWorkspaceDir(): void {
|
||||
if (!fs.existsSync(DEFAULT_WORKSPACE_DIR)) {
|
||||
fs.mkdirSync(DEFAULT_WORKSPACE_DIR, { recursive: true });
|
||||
console.log('[Workspace] 创建默认工作空间目录:', DEFAULT_WORKSPACE_DIR);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前工作空间目录(支持用户自定义) */
|
||||
let _workspaceDir = DEFAULT_WORKSPACE_DIR;
|
||||
|
||||
export function getWorkspaceDir(): string {
|
||||
return _workspaceDir;
|
||||
}
|
||||
|
||||
export function setWorkspaceDir(dir: string): void {
|
||||
const resolved = path.resolve(dir);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
fs.mkdirSync(resolved, { recursive: true });
|
||||
}
|
||||
_workspaceDir = resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动一个命令进程
|
||||
* @param id 唯一标识,用于后续 kill
|
||||
* @param command 要执行的 shell 命令
|
||||
* @param cwd 工作目录,默认 workspace
|
||||
* @param onOutput 输出回调 (type, data)
|
||||
* @param onExit 退出回调 (code)
|
||||
*/
|
||||
export function startProcess(
|
||||
id: string,
|
||||
command: string,
|
||||
cwd: string | undefined,
|
||||
onOutput: (type: 'stdout' | 'stderr', data: string) => void,
|
||||
onExit: (code: number | null) => void
|
||||
): { success: boolean; error?: string } {
|
||||
// 安全检查
|
||||
const cmdCheck = checkCommandAllowed(command);
|
||||
if (!cmdCheck.ok) {
|
||||
return { success: false, error: cmdCheck.reason };
|
||||
}
|
||||
|
||||
const workDir = cwd ? path.resolve(cwd) : _workspaceDir;
|
||||
const dirCheck = checkPathAllowed(workDir, 'read');
|
||||
if (!dirCheck.ok) {
|
||||
return { success: false, error: dirCheck.reason };
|
||||
}
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(workDir)) {
|
||||
return { success: false, error: `工作目录不存在: ${workDir}` };
|
||||
}
|
||||
|
||||
// 杀掉同 id 的旧进程
|
||||
if (activeProcesses.has(id)) {
|
||||
try { activeProcesses.get(id)!.kill('SIGTERM'); } catch { /* ignore */ }
|
||||
activeProcesses.delete(id);
|
||||
}
|
||||
|
||||
try {
|
||||
const proc = spawn('bash', ['-c', command], {
|
||||
cwd: workDir,
|
||||
env: { ...process.env, TERM: 'xterm-256color' },
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
activeProcesses.set(id, proc);
|
||||
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
onOutput('stdout', data.toString());
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data: Buffer) => {
|
||||
onOutput('stderr', data.toString());
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
activeProcesses.delete(id);
|
||||
onExit(code);
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
activeProcesses.delete(id);
|
||||
onOutput('stderr', `进程启动失败: ${err.message}\n`);
|
||||
onExit(1);
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止指定进程 */
|
||||
export function killProcess(id: string): boolean {
|
||||
const proc = activeProcesses.get(id);
|
||||
if (!proc) return false;
|
||||
try {
|
||||
proc.kill('SIGTERM');
|
||||
activeProcesses.delete(id);
|
||||
return true;
|
||||
} catch {
|
||||
activeProcesses.delete(id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止所有进程(窗口关闭时调用) */
|
||||
export function killAllProcesses(): void {
|
||||
for (const [id, proc] of activeProcesses) {
|
||||
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
||||
}
|
||||
activeProcesses.clear();
|
||||
}
|
||||
|
||||
/** 列出工作空间目录内容 */
|
||||
export function listWorkspaceDir(dirPath?: string): { success: boolean; entries?: Array<{ name: string; type: string; size: number | null; modified: string }>; error?: string } {
|
||||
try {
|
||||
const targetDir = dirPath ? path.resolve(dirPath) : _workspaceDir;
|
||||
|
||||
// 安全检查:必须在 workspace 范围内
|
||||
if (!targetDir.startsWith(_workspaceDir) && !targetDir.startsWith(DEFAULT_WORKSPACE_DIR)) {
|
||||
return { success: false, error: '只能浏览工作空间目录内的文件' };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
return { success: false, error: '目录不存在' };
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(targetDir, { withFileTypes: true });
|
||||
const entries = items
|
||||
.filter(item => !item.name.startsWith('.'))
|
||||
.map(item => {
|
||||
const fullPath = path.join(targetDir, item.name);
|
||||
const stat = fs.statSync(fullPath);
|
||||
return {
|
||||
name: item.name,
|
||||
type: item.isDirectory() ? 'directory' : 'file',
|
||||
size: item.isFile() ? stat.size : null,
|
||||
modified: stat.mtime.toISOString()
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// 目录优先,然后按名称排序
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return { success: true, entries };
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查进程是否还活着 */
|
||||
export function isProcessAlive(id: string): boolean {
|
||||
return activeProcesses.has(id);
|
||||
}
|
||||
|
||||
/** 获取活跃进程数量 */
|
||||
export function getActiveProcessCount(): number {
|
||||
return activeProcesses.size;
|
||||
}
|
||||
@@ -167,6 +167,53 @@ export function initSettingsModal(): void {
|
||||
logSetting('Agent 记忆', toggleMemory.checked ? '开启' : '关闭');
|
||||
});
|
||||
}
|
||||
|
||||
// ── 工作空间目录设置 ──
|
||||
const inputWorkspaceDir = document.querySelector('#inputWorkspaceDir') as HTMLInputElement;
|
||||
const btnBrowseWorkspace = document.querySelector('#btnBrowseWorkspace') as HTMLButtonElement;
|
||||
if (inputWorkspaceDir) {
|
||||
// 加载当前工作空间目录
|
||||
const bridge = (window as any).metonaDesktop;
|
||||
if (bridge?.isDesktop) {
|
||||
bridge.workspace.getDir().then((info: { dir: string }) => {
|
||||
inputWorkspaceDir.value = info.dir;
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
if (btnBrowseWorkspace) {
|
||||
btnBrowseWorkspace.addEventListener('click', async () => {
|
||||
const bridge = (window as any).metonaDesktop;
|
||||
if (!bridge?.isDesktop) return;
|
||||
const paths = await bridge.dialog.openFile({
|
||||
filters: [{ name: '文件夹', extensions: ['*'] }]
|
||||
});
|
||||
// Electron openFile 对话框不能直接选文件夹,这里用 saveFile 代替
|
||||
// 或者让用户手动输入路径
|
||||
showToast('请在输入框中直接修改路径', 'info');
|
||||
});
|
||||
// 双击输入框解锁编辑
|
||||
inputWorkspaceDir.addEventListener('dblclick', () => {
|
||||
inputWorkspaceDir.removeAttribute('readonly');
|
||||
inputWorkspaceDir.focus();
|
||||
});
|
||||
inputWorkspaceDir.addEventListener('blur', async () => {
|
||||
inputWorkspaceDir.setAttribute('readonly', '');
|
||||
const newDir = inputWorkspaceDir.value.trim();
|
||||
if (!newDir) return;
|
||||
const bridge = (window as any).metonaDesktop;
|
||||
if (!bridge?.isDesktop) return;
|
||||
const result = await bridge.workspace.setDir(newDir);
|
||||
if (result.success) {
|
||||
showToast('工作空间目录已更新', 'success');
|
||||
logSetting('工作空间目录', result.dir);
|
||||
} else {
|
||||
showToast(`设置失败: ${result.error}`, 'error');
|
||||
// 恢复原值
|
||||
const info = await bridge.workspace.getDir();
|
||||
inputWorkspaceDir.value = info.dir;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function openSettingsModal(): void {
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
/**
|
||||
* Workspace Panel — 右侧工作空间面板
|
||||
* 包含命令行和文件两个 Tab
|
||||
*/
|
||||
|
||||
import { logInfo, logError, logDebug, logWarn } from '../services/log-service.js';
|
||||
import type { WorkspaceFileEntry } from '../types.js';
|
||||
|
||||
// ── 状态 ──
|
||||
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 = false;
|
||||
let activeTab: 'terminal' | 'files' = 'terminal';
|
||||
let terminalSessions: TerminalSession[] = [];
|
||||
let activeTerminalId = '';
|
||||
let workspaceDir = '';
|
||||
let currentFileDir = '';
|
||||
let fileTree: FileNode[] = [];
|
||||
let previewFile: { name: string; content: string; path: string } | null = null;
|
||||
let _counter = 0;
|
||||
|
||||
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 escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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> {
|
||||
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) => {
|
||||
appendTerminalOutput(data.id, data.type, data.data);
|
||||
});
|
||||
|
||||
bridge.workspace.onExit((data) => {
|
||||
handleProcessExit(data.id, data.code);
|
||||
});
|
||||
|
||||
// 绑定 UI 事件
|
||||
bindEvents();
|
||||
|
||||
// 创建默认终端
|
||||
createTerminalSession('终端 1');
|
||||
|
||||
logInfo('工作空间面板已初始化');
|
||||
}
|
||||
|
||||
function bindEvents(): void {
|
||||
// 切换面板显示
|
||||
document.querySelector('#btnWorkspace')?.addEventListener('click', togglePanel);
|
||||
document.querySelector('#btnWorkspaceClose')?.addEventListener('click', () => {
|
||||
if (panelVisible) togglePanel();
|
||||
});
|
||||
|
||||
// Tab 切换
|
||||
document.querySelector('#wsTabTerminal')?.addEventListener('click', () => switchTab('terminal'));
|
||||
document.querySelector('#wsTabFiles')?.addEventListener('click', () => switchTab('files'));
|
||||
|
||||
// 终端输入
|
||||
const termInput = document.querySelector('#wsTermInput') as HTMLInputElement;
|
||||
termInput?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const cmd = termInput.value.trim();
|
||||
if (cmd) {
|
||||
executeCommand(cmd);
|
||||
termInput.value = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 执行按钮
|
||||
document.querySelector('#wsExecBtn')?.addEventListener('click', () => {
|
||||
const cmd = termInput?.value.trim();
|
||||
if (cmd) {
|
||||
executeCommand(cmd);
|
||||
termInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 停止按钮
|
||||
document.querySelector('#wsStopBtn')?.addEventListener('click', killCurrentProcess);
|
||||
|
||||
// 清空按钮
|
||||
document.querySelector('#wsClearBtn')?.addEventListener('click', clearTerminal);
|
||||
|
||||
// 新建终端 Tab
|
||||
document.querySelector('#wsNewTabBtn')?.addEventListener('click', () => {
|
||||
const num = terminalSessions.length + 1;
|
||||
createTerminalSession(`终端 ${num}`);
|
||||
});
|
||||
|
||||
// 文件刷新
|
||||
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();
|
||||
});
|
||||
|
||||
// 拖拽调整宽度
|
||||
setupResizer();
|
||||
}
|
||||
|
||||
function setupResizer(): void {
|
||||
const resizer = document.querySelector('#wsResizer') as HTMLElement;
|
||||
const panel = document.querySelector('#workspacePanel') as HTMLElement;
|
||||
if (!resizer || !panel) return;
|
||||
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
|
||||
resizer.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
startX = e.clientX;
|
||||
startWidth = panel.offsetWidth;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const delta = startX - e.clientX; // 向左拖拽增大宽度
|
||||
const newWidth = Math.min(700, Math.max(300, startWidth + delta));
|
||||
panel.style.width = newWidth + 'px';
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 面板控制 ──
|
||||
export function togglePanel(): void {
|
||||
panelVisible = !panelVisible;
|
||||
const panel = document.querySelector('#workspacePanel') as HTMLElement;
|
||||
const btn = document.querySelector('#btnWorkspace') as HTMLElement;
|
||||
const mainWrap = document.querySelector('#mainWrap') as HTMLElement;
|
||||
|
||||
if (panel) panel.style.display = panelVisible ? '' : 'none';
|
||||
if (btn) btn.classList.toggle('active', panelVisible);
|
||||
if (mainWrap) mainWrap.classList.toggle('with-workspace', panelVisible);
|
||||
|
||||
if (panelVisible) {
|
||||
if (activeTab === 'files') loadFiles(currentFileDir || workspaceDir);
|
||||
const termInput = document.querySelector('#wsTermInput') as HTMLElement;
|
||||
termInput?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tab: 'terminal' | 'files'): void {
|
||||
activeTab = tab;
|
||||
renderPanel();
|
||||
|
||||
if (tab === 'files') {
|
||||
loadFiles(currentFileDir || workspaceDir);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 终端管理 ──
|
||||
function createTerminalSession(title: string): string {
|
||||
const session: TerminalSession = {
|
||||
id: genId(),
|
||||
title,
|
||||
lines: [],
|
||||
running: false,
|
||||
autoScroll: true
|
||||
};
|
||||
terminalSessions.push(session);
|
||||
activeTerminalId = session.id;
|
||||
renderPanel();
|
||||
return session.id;
|
||||
}
|
||||
|
||||
function getActiveSession(): TerminalSession | undefined {
|
||||
return terminalSessions.find(s => s.id === activeTerminalId);
|
||||
}
|
||||
|
||||
function executeCommand(command: string): void {
|
||||
const session = getActiveSession();
|
||||
if (!session) return;
|
||||
|
||||
const bridge = window.metonaDesktop;
|
||||
if (!bridge?.isDesktop) return;
|
||||
|
||||
// 显示命令
|
||||
session.lines.push({ type: 'system', text: `$ ${command}` });
|
||||
session.running = true;
|
||||
session.autoScroll = true;
|
||||
|
||||
renderTerminal();
|
||||
|
||||
const cwd = currentFileDir || workspaceDir;
|
||||
bridge.workspace.exec({ id: session.id, command, cwd });
|
||||
logInfo('工作空间执行命令', command.slice(0, 100));
|
||||
}
|
||||
|
||||
function appendTerminalOutput(sessionId: string, type: 'stdout' | 'stderr', data: string): void {
|
||||
const session = terminalSessions.find(s => s.id === sessionId);
|
||||
if (!session) return;
|
||||
|
||||
// 按行分割,处理 \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 {
|
||||
const session = terminalSessions.find(s => s.id === sessionId);
|
||||
if (!session) return;
|
||||
|
||||
session.running = false;
|
||||
const exitMsg = code === 0 ? '✓ 进程正常退出' : `✗ 进程退出 (code: ${code})`;
|
||||
session.lines.push({ type: 'system', text: exitMsg });
|
||||
|
||||
renderTerminal();
|
||||
|
||||
// 更新停止按钮状态
|
||||
updateStopBtnState();
|
||||
}
|
||||
|
||||
function killCurrentProcess(): void {
|
||||
const session = getActiveSession();
|
||||
if (!session || !session.running) return;
|
||||
|
||||
const bridge = window.metonaDesktop;
|
||||
if (!bridge?.isDesktop) return;
|
||||
|
||||
bridge.workspace.kill(session.id);
|
||||
session.lines.push({ type: 'system', text: '■ 已发送终止信号' });
|
||||
renderTerminal();
|
||||
}
|
||||
|
||||
function clearTerminal(): void {
|
||||
const session = getActiveSession();
|
||||
if (!session) return;
|
||||
session.lines = [];
|
||||
renderTerminal();
|
||||
}
|
||||
|
||||
function closeTerminalTab(sessionId: string): void {
|
||||
if (terminalSessions.length <= 1) return; // 至少保留一个
|
||||
|
||||
const session = terminalSessions.find(s => s.id === sessionId);
|
||||
if (session?.running) {
|
||||
window.metonaDesktop?.workspace.kill(sessionId);
|
||||
}
|
||||
|
||||
terminalSessions = terminalSessions.filter(s => s.id !== sessionId);
|
||||
if (activeTerminalId === sessionId) {
|
||||
activeTerminalId = terminalSessions[0].id;
|
||||
}
|
||||
renderPanel();
|
||||
}
|
||||
|
||||
// ── 文件浏览器 ──
|
||||
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 filesTab = document.querySelector('#wsTabFiles') as HTMLElement;
|
||||
|
||||
if (termTab) termTab.classList.toggle('active', activeTab === 'terminal');
|
||||
if (filesTab) filesTab.classList.toggle('active', activeTab === 'files');
|
||||
|
||||
const termContent = document.querySelector('#wsTerminalContent') as HTMLElement;
|
||||
const filesContent = document.querySelector('#wsFilesContent') as HTMLElement;
|
||||
|
||||
if (termContent) termContent.style.display = activeTab === 'terminal' ? '' : 'none';
|
||||
if (filesContent) filesContent.style.display = activeTab === 'files' ? '' : 'none';
|
||||
|
||||
if (activeTab === 'terminal') {
|
||||
renderTerminal();
|
||||
renderTerminalTabs();
|
||||
} 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;
|
||||
}
|
||||
|
||||
// 使用 DocumentFragment 优化渲染
|
||||
const fragment = document.createDocumentFragment();
|
||||
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 renderTerminalTabs(): void {
|
||||
const tabBar = document.querySelector('#wsTermTabBar') as HTMLElement;
|
||||
if (!tabBar) return;
|
||||
|
||||
tabBar.innerHTML = terminalSessions.map(s => {
|
||||
const active = s.id === activeTerminalId;
|
||||
const running = s.running ? ' ●' : '';
|
||||
const closeBtn = terminalSessions.length > 1
|
||||
? `<button class="ws-tab-close" data-close="${s.id}" title="关闭">✕</button>`
|
||||
: '';
|
||||
return `<div class="ws-tab ${active ? 'active' : ''}" data-session="${s.id}">
|
||||
<span class="ws-tab-title">${escapeHtml(s.title)}${running}</span>
|
||||
${closeBtn}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// 绑定 tab 点击事件
|
||||
tabBar.querySelectorAll('.ws-tab').forEach(tab => {
|
||||
tab.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const closeBtn = target.closest('.ws-tab-close') as HTMLElement;
|
||||
if (closeBtn) {
|
||||
e.stopPropagation();
|
||||
closeTerminalTab(closeBtn.dataset.close!);
|
||||
return;
|
||||
}
|
||||
const sessionId = (tab as HTMLElement).dataset.session;
|
||||
if (sessionId) {
|
||||
activeTerminalId = sessionId;
|
||||
renderPanel();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
container.innerHTML = '<div class="ws-file-empty">空目录</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 session = getActiveSession();
|
||||
const running = session?.running || false;
|
||||
stopBtn.disabled = !running;
|
||||
stopBtn.classList.toggle('active', 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 formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// ── 外部调用接口 ──
|
||||
|
||||
/** 从聊天区域调用:在工作空间执行命令 */
|
||||
export function execInWorkspace(command: string): void {
|
||||
// 确保面板可见
|
||||
if (!panelVisible) togglePanel();
|
||||
|
||||
// 切换到终端 tab
|
||||
activeTab = 'terminal';
|
||||
|
||||
// 使用当前活跃终端或创建新的
|
||||
const session = getActiveSession();
|
||||
if (session?.running) {
|
||||
// 当前终端有进程在跑,新建一个
|
||||
const num = terminalSessions.length + 1;
|
||||
createTerminalSession(`终端 ${num}`);
|
||||
}
|
||||
|
||||
renderPanel();
|
||||
executeCommand(command);
|
||||
}
|
||||
|
||||
/** 获取工作空间目录 */
|
||||
export function getWorkspaceDirPath(): string {
|
||||
return workspaceDir;
|
||||
}
|
||||
|
||||
/** 设置工作空间目录 */
|
||||
export async function setWorkspaceDirPath(dir: string): Promise<boolean> {
|
||||
const bridge = window.metonaDesktop;
|
||||
if (!bridge?.isDesktop) return false;
|
||||
|
||||
const result = await bridge.workspace.setDir(dir);
|
||||
if (result.success && result.dir) {
|
||||
workspaceDir = result.dir;
|
||||
currentFileDir = workspaceDir;
|
||||
logInfo('工作空间目录已更新', workspaceDir);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -65,6 +65,12 @@
|
||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="btnWorkspace" title="工作空间">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="btnSettings" title="设置">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
@@ -167,6 +173,83 @@
|
||||
</div>
|
||||
</div><!-- /.main-wrap -->
|
||||
|
||||
<!-- ═══════════════ 工作空间面板(右侧)═══════════════ -->
|
||||
<aside class="workspace-panel" id="workspacePanel" style="display:none;">
|
||||
<div class="ws-resizer" id="wsResizer"></div>
|
||||
<div class="ws-header">
|
||||
<span class="ws-title">🖥️ 工作空间</span>
|
||||
<div class="ws-tabs">
|
||||
<button class="ws-tab active" id="wsTabTerminal">💻 命令行</button>
|
||||
<button class="ws-tab" id="wsTabFiles">📁 文件</button>
|
||||
</div>
|
||||
<button class="icon-btn ws-toggle-btn" id="btnWorkspaceClose" title="收起面板">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 终端 Tab -->
|
||||
<div class="ws-content" id="wsTerminalContent">
|
||||
<div class="ws-term-tab-bar" id="wsTermTabBar"></div>
|
||||
<div class="ws-term-output" id="wsTermOutput"></div>
|
||||
<div class="ws-term-toolbar">
|
||||
<input class="ws-term-input" id="wsTermInput" type="text" placeholder="输入命令,Enter 执行..." spellcheck="false">
|
||||
<button class="ws-toolbar-btn" id="wsExecBtn" title="执行">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="ws-toolbar-btn ws-stop-btn" id="wsStopBtn" title="停止" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="6" width="12" height="12" rx="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="ws-toolbar-btn" id="wsClearBtn" title="清空">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="ws-toolbar-btn" id="wsNewTabBtn" title="新建终端">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件 Tab -->
|
||||
<div class="ws-content" id="wsFilesContent" style="display:none;">
|
||||
<div class="ws-file-toolbar">
|
||||
<button class="ws-toolbar-btn" id="wsFileUpBtn" title="上级目录">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="ws-file-path" id="wsFilePath">~</span>
|
||||
<button class="ws-toolbar-btn" id="wsFileRefreshBtn" title="刷新">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ws-file-list" id="wsFileList"></div>
|
||||
<div class="ws-preview" id="wsPreview" style="display:none;">
|
||||
<div class="ws-preview-header">
|
||||
<span class="ws-preview-title" id="wsPreviewTitle"></span>
|
||||
<button class="icon-btn" id="wsPreviewClose">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ws-preview-content" id="wsPreviewContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ═══════════════ 设置模态框 ═══════════════ -->
|
||||
<div class="modal-overlay" id="settingsModal" style="display:none;">
|
||||
<div class="modal">
|
||||
@@ -244,6 +327,14 @@
|
||||
<p class="text-muted" style="font-size:11px;margin-top:8px;">⚠️ 高风险操作(写入/删除/命令执行)会弹出确认对话框。系统关键路径(/etc, /sys, ~/.ssh 等)已自动屏蔽。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">🖥️ 工作空间目录</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input class="setting-input" id="inputWorkspaceDir" type="text" placeholder="默认: 应用数据目录/workspace" style="margin-bottom:0;flex:1;" readonly>
|
||||
<button class="btn btn-sm btn-outline" id="btnBrowseWorkspace">📂 浏览</button>
|
||||
</div>
|
||||
<p class="text-muted" style="margin-top:4px;font-size:11px;">工作空间用于存放 AI 执行命令时的工作文件。命令行默认在此目录执行。</p>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">
|
||||
🧠 Agent 记忆系统
|
||||
@@ -318,6 +409,7 @@
|
||||
<div class="help-section"><h4>📚 知识库 (RAG)</h4><ul><li>点击顶部 📚 按钮打开知识库管理</li><li>创建集合 → 选择嵌入模型(推荐 <code>nomic-embed-text</code> 等)</li><li>上传文档,自动分块并生成向量索引</li><li>开启 RAG 检索后,聊天时自动检索相关文档注入回答</li></ul></div>
|
||||
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>AI 会从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则/事件),跨会话持续积累</li><li>对话满 <strong>6 条消息</strong>后自动触发记忆提取</li><li>新对话时自动检索相关记忆并注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除记忆条目</li><li>记忆持久化到 IndexedDB,清除浏览器数据会丢失</li></ul></div>
|
||||
<div class="help-section"><h4>🕐 历史记录</h4><ul><li>所有会话自动保存到 IndexedDB</li><li>点击 🕐 按钮查看、搜索、恢复历史会话</li><li>支持导出 <code>.metona</code> 加密备份文件</li></ul></div>
|
||||
<div class="help-section"><h4>🖥️ 工作空间</h4><ul><li>点击顶部 🖥️ 按钮打开右侧工作空间面板</li><li><strong>命令行 Tab</strong> — 终端界面,实时显示命令输出,支持长时间运行(如 <code>ollama pull</code>)</li><li><strong>文件 Tab</strong> — 浏览工作空间目录,点击文件预览内容</li><li>支持多终端 Tab,每个 Tab 独立进程,可随时停止</li><li>命令在工作空间目录执行,可在设置中修改路径</li><li>AI 建议执行命令时,可在工作空间中运行并实时查看进度</li></ul></div>
|
||||
<div class="help-section"><h4>⚡ 快捷键</h4><table class="help-shortcuts"><tr><td><kbd>Enter</kbd></td><td>发送消息</td></tr><tr><td><kbd>Shift + Enter</kbd></td><td>换行</td></tr><tr><td><kbd>Ctrl + K</kbd></td><td>知识库管理</td></tr><tr><td><kbd>Ctrl + M</kbd></td><td>记忆面板</td></tr><tr><td><kbd>Esc</kbd></td><td>关闭弹窗</td></tr></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { initMemoryManager } from './services/memory-manager.js';
|
||||
import { setToolEnabled } from './services/tool-registry.js';
|
||||
import { initToolConfirmModal } from './components/tool-confirm-modal.js';
|
||||
import { initMemoryPanel } from './components/memory-panel.js';
|
||||
import { initWorkspacePanel } from './components/workspace-panel.js';
|
||||
import { initLogPanel } from './services/log-service.js';
|
||||
import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js';
|
||||
import type { ChatSession } from './types.js';
|
||||
@@ -202,6 +203,7 @@ async function init(): Promise<void> {
|
||||
initHelpModal();
|
||||
initToolConfirmModal();
|
||||
initMemoryPanel();
|
||||
initWorkspacePanel();
|
||||
setupDesktopIntegration();
|
||||
|
||||
bindGlobalEvents();
|
||||
|
||||
@@ -2659,3 +2659,419 @@ html, body {
|
||||
color: var(--accent-cyan, #00f5d4);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ═══════════════ 工作空间面板 ═══════════════ */
|
||||
|
||||
.workspace-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 420px;
|
||||
background: var(--bg-solid, #202020);
|
||||
border-left: 1px solid var(--border-default, rgba(255,255,255,0.08));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 10;
|
||||
font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.ws-resizer {
|
||||
position: absolute;
|
||||
left: -3px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
z-index: 11;
|
||||
}
|
||||
|
||||
.ws-resizer:hover {
|
||||
background: var(--accent, #60CDFF);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.ws-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-default, rgba(255,255,255,0.08));
|
||||
background: var(--bg-layer-alt, #2a2a2a);
|
||||
min-height: 44px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ws-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #fff);
|
||||
white-space: nowrap;
|
||||
font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.ws-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ws-tab {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-secondary, #999);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.ws-tab:hover {
|
||||
background: var(--bg-card-hover, #323232);
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.ws-tab.active {
|
||||
background: var(--bg-layer, #383838);
|
||||
color: var(--accent, #60CDFF);
|
||||
border-color: var(--border-strong, rgba(255,255,255,0.12));
|
||||
}
|
||||
|
||||
.ws-toggle-btn {
|
||||
margin-left: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* ── 终端内容区 ── */
|
||||
|
||||
.ws-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ws-term-tab-bar {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
padding: 4px 8px 0;
|
||||
background: var(--bg-layer-alt, #2a2a2a);
|
||||
border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.06));
|
||||
overflow-x: auto;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.ws-term-tab-bar .ws-tab {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
border-bottom: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws-term-tab-bar .ws-tab.active {
|
||||
background: var(--bg-solid, #202020);
|
||||
border-color: var(--border-default, rgba(255,255,255,0.08));
|
||||
border-bottom: 1px solid var(--bg-solid, #202020);
|
||||
}
|
||||
|
||||
.ws-tab-title {
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ws-tab-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary, #666);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.ws-tab-close:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.ws-term-output {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary, #fff);
|
||||
background: var(--bg-solid, #202020);
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ws-term-placeholder {
|
||||
color: var(--text-tertiary, #666);
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.ws-term-line {
|
||||
min-height: 1.5em;
|
||||
}
|
||||
|
||||
.ws-term-stdout {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.ws-term-stderr {
|
||||
color: #ff6b68;
|
||||
}
|
||||
|
||||
.ws-term-system {
|
||||
color: var(--accent, #60CDFF);
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.ws-term-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-top: 1px solid var(--border-default, rgba(255,255,255,0.08));
|
||||
background: var(--bg-layer-alt, #2a2a2a);
|
||||
}
|
||||
|
||||
.ws-term-input {
|
||||
flex: 1;
|
||||
background: var(--bg-layer, #383838);
|
||||
border: 1px solid var(--border-default, rgba(255,255,255,0.08));
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary, #fff);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.ws-term-input:focus {
|
||||
border-color: var(--accent, #60CDFF);
|
||||
}
|
||||
|
||||
.ws-term-input::placeholder {
|
||||
color: var(--text-tertiary, #666);
|
||||
}
|
||||
|
||||
.ws-toolbar-btn {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-secondary, #999);
|
||||
cursor: pointer;
|
||||
padding: 5px 7px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.ws-toolbar-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.ws-toolbar-btn:hover:not(:disabled) {
|
||||
background: var(--bg-card-hover, #323232);
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.ws-toolbar-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ws-stop-btn.active {
|
||||
color: #ff6b68;
|
||||
}
|
||||
|
||||
.ws-stop-btn.active:hover {
|
||||
background: rgba(255, 107, 104, 0.15);
|
||||
}
|
||||
|
||||
/* ── 文件浏览器 ── */
|
||||
|
||||
.ws-file-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border-default, rgba(255,255,255,0.08));
|
||||
background: var(--bg-layer-alt, #2a2a2a);
|
||||
}
|
||||
|
||||
.ws-file-path {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #999);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ws-file-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ws-file-empty {
|
||||
color: var(--text-tertiary, #666);
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
font-size: 13px;
|
||||
font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.ws-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ws-file-item:hover {
|
||||
background: var(--bg-card-hover, #323232);
|
||||
}
|
||||
|
||||
.ws-file-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ws-file-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #fff);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ws-file-size {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #666);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 文件预览 ── */
|
||||
|
||||
.ws-preview {
|
||||
border-top: 1px solid var(--border-default, rgba(255,255,255,0.08));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 50%;
|
||||
}
|
||||
|
||||
.ws-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-layer-alt, #2a2a2a);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ws-preview-title {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #fff);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ws-preview-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
background: var(--bg-solid, #202020);
|
||||
}
|
||||
|
||||
.ws-preview-pre {
|
||||
margin: 0;
|
||||
padding: 8px 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace;
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.ws-preview-line {
|
||||
display: block;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.ws-preview-line:hover {
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.ws-line-num {
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
padding-right: 12px;
|
||||
color: var(--text-tertiary, #666);
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--border-subtle, rgba(255,255,255,0.06));
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* ── 滚动条 ── */
|
||||
|
||||
.ws-term-output::-webkit-scrollbar,
|
||||
.ws-file-list::-webkit-scrollbar,
|
||||
.ws-preview-content::-webkit-scrollbar,
|
||||
.ws-term-tab-bar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.ws-term-output::-webkit-scrollbar-track,
|
||||
.ws-file-list::-webkit-scrollbar-track,
|
||||
.ws-preview-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ws-term-output::-webkit-scrollbar-thumb,
|
||||
.ws-file-list::-webkit-scrollbar-thumb,
|
||||
.ws-preview-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.ws-term-output::-webkit-scrollbar-thumb:hover,
|
||||
.ws-file-list::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* ── Header 按钮高亮 ── */
|
||||
|
||||
#btnWorkspace.active {
|
||||
color: var(--accent, #60CDFF);
|
||||
background: var(--accent-subtle, rgba(96,205,255,0.06));
|
||||
}
|
||||
|
||||
/* ── 主内容区自适应 ── */
|
||||
|
||||
.main-wrap.with-workspace {
|
||||
margin-right: 420px;
|
||||
}
|
||||
|
||||
Vendored
+23
@@ -189,6 +189,19 @@ export interface DocInfo {
|
||||
|
||||
// ── 桌面 Bridge 类型 ──
|
||||
|
||||
export interface WorkspaceFileEntry {
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
size: number | null;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceDirResult {
|
||||
success: boolean;
|
||||
entries?: WorkspaceFileEntry[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MetonaDesktopAPI {
|
||||
isDesktop: boolean;
|
||||
info: () => Promise<AppInfo>;
|
||||
@@ -216,6 +229,16 @@ export interface MetonaDesktopAPI {
|
||||
onTrayAction: (callback: (action: string) => void) => void;
|
||||
onAppQuit: (callback: () => void) => void;
|
||||
removeAllListeners: (channel: string) => void;
|
||||
workspace: {
|
||||
getDir: () => Promise<{ dir: string; defaultDir: string }>;
|
||||
setDir: (dir: string) => Promise<{ success: boolean; dir?: string; error?: string }>;
|
||||
listDir: (dirPath?: string) => Promise<WorkspaceDirResult>;
|
||||
readFile: (filePath: string) => Promise<{ success: boolean; content?: string; path?: string; error?: string; truncated?: boolean; lines?: number }>;
|
||||
exec: (params: { id: string; command: string; cwd?: string }) => void;
|
||||
kill: (id: string) => void;
|
||||
onOutput: (callback: (data: { id: string; type: 'stdout' | 'stderr'; data: string }) => void) => void;
|
||||
onExit: (callback: (data: { id: string; code: number | null }) => void) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppInfo {
|
||||
|
||||
Reference in New Issue
Block a user