456 lines
13 KiB
JavaScript
456 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const readline = require('readline');
|
||
const crypto = require('crypto');
|
||
|
||
const WORKSPACE = process.cwd();
|
||
const WORKSPACE_NAME = path.basename(WORKSPACE) || WORKSPACE;
|
||
const USERNAME = (os.userInfo().username || 'user');
|
||
const PROMPT = `${USERNAME}@${WORKSPACE_NAME} `;
|
||
|
||
const SHOW_REASONING = process.env.EA_REASONING !== '0';
|
||
const COLOR_ENABLED = !process.env.NO_COLOR;
|
||
const GREY = COLOR_ENABLED ? '\x1b[90m' : '';
|
||
const BLUE = COLOR_ENABLED ? '\x1b[94m' : '';
|
||
const RESET = COLOR_ENABLED ? '\x1b[0m' : '';
|
||
|
||
// ANSI 转义序列 - 用于精确光标控制
|
||
const ANSI = {
|
||
HIDE_CURSOR: '\x1b[?25l',
|
||
SHOW_CURSOR: '\x1b[?25h',
|
||
CLEAR_LINE: '\x1b[K', // 清除从光标到行尾
|
||
CLEAR_ENTIRE_LINE: '\x1b[2K', // 清除整行
|
||
MOVE_UP: (n) => `\x1b[${n}A`, // 光标上移n行
|
||
MOVE_DOWN: (n) => `\x1b[${n}B`, // 光标下移n行
|
||
MOVE_LEFT: (n) => `\x1b[${n}D`, // 光标左移n列
|
||
SAVE_CURSOR: '\x1b[s', // 保存光标位置(部分终端)
|
||
RESTORE_CURSOR: '\u001b[u', // 恢复光标位置
|
||
ALT_SCREEN: '\x1b[?1049h', // 切换到备用屏幕
|
||
MAIN_SCREEN: '\x1b[?1049l', // 返回主屏幕
|
||
};
|
||
|
||
const SPINNER_FRAMES = ['⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
||
const SPINNER_INTERVAL_MS = 80;
|
||
const SPINNER_INDENT = ' ';
|
||
|
||
const SYSTEM_PROMPT = loadSystemPrompt();
|
||
void SYSTEM_PROMPT;
|
||
|
||
const COMMAND_MENU_ITEMS = [
|
||
{ cmd: '/new', desc: '创建新对话' },
|
||
{ cmd: '/resume', desc: '加载旧对话' },
|
||
{ cmd: '/allow', desc: '切换运行模式(只读/无限制)' },
|
||
{ cmd: '/model', desc: '切换模型和思考模式' },
|
||
{ cmd: '/status', desc: '查看当前对话状态' },
|
||
{ cmd: '/compact', desc: '压缩当前对话' },
|
||
{ cmd: '/config', desc: '查看当前配置' },
|
||
{ cmd: '/help', desc: '显示指令列表' },
|
||
{ cmd: '/exit', desc: '退出程序' },
|
||
];
|
||
const COMMAND_MENU_SIZE = 6;
|
||
|
||
const state = {
|
||
conversationId: crypto.randomUUID(),
|
||
allow: 'full_access',
|
||
model: 'kimi',
|
||
thinking: 'thinking',
|
||
tokenUsage: 0,
|
||
baseUrl: 'https://api.example.com/v1',
|
||
apiKey: 'sk-xxxxxxw2y',
|
||
};
|
||
|
||
console.log('');
|
||
console.log('eagent demo');
|
||
console.log(`model: kimi | allow: full_access | cwd: ${WORKSPACE_NAME}`);
|
||
console.log('');
|
||
|
||
const rl = readline.createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout,
|
||
terminal: true,
|
||
});
|
||
|
||
readline.emitKeypressEvents(process.stdin, rl);
|
||
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
||
|
||
rl.setPrompt(PROMPT);
|
||
rl.prompt();
|
||
|
||
const menuState = {
|
||
active: false,
|
||
selected: 0,
|
||
offset: 0,
|
||
startRow: null, // 记录菜单开始的行号(相对位置)
|
||
};
|
||
|
||
process.stdin.on('keypress', async (str, key) => {
|
||
// 如果菜单处于激活状态,优先处理菜单导航
|
||
if (menuState.active) {
|
||
await handleMenuKeypress(str, key);
|
||
return;
|
||
}
|
||
|
||
// 检测 / 键来打开菜单
|
||
if (str === '/' && (rl.line === '' || rl.line === '/')) {
|
||
openCommandMenu();
|
||
return;
|
||
}
|
||
});
|
||
|
||
async function handleMenuKeypress(str, key) {
|
||
if (!key) return;
|
||
|
||
switch (key.name) {
|
||
case 'up':
|
||
menuState.selected = Math.max(0, menuState.selected - 1);
|
||
renderCommandMenu();
|
||
break;
|
||
case 'down':
|
||
menuState.selected = Math.min(
|
||
COMMAND_MENU_ITEMS.length - 1,
|
||
menuState.selected + 1
|
||
);
|
||
renderCommandMenu();
|
||
break;
|
||
case 'return':
|
||
case 'enter':
|
||
const chosen = COMMAND_MENU_ITEMS[menuState.selected]?.cmd;
|
||
await closeCommandMenu();
|
||
if (chosen) {
|
||
await handleCommand(chosen);
|
||
process.stdout.write('\n');
|
||
}
|
||
rl.prompt();
|
||
break;
|
||
case 'escape':
|
||
await closeCommandMenu();
|
||
process.stdout.write('\n');
|
||
rl.prompt();
|
||
break;
|
||
case 'c':
|
||
if (key.ctrl) {
|
||
await closeCommandMenu();
|
||
rl.close();
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
rl.on('line', async (line) => {
|
||
const input = line.trim();
|
||
if (!input) {
|
||
rl.prompt();
|
||
return;
|
||
}
|
||
|
||
if (input.startsWith('/')) {
|
||
await handleCommand(input);
|
||
rl.prompt();
|
||
return;
|
||
}
|
||
|
||
console.log(`用户:${line}`);
|
||
console.log('');
|
||
await simulateAssistantReply(input);
|
||
rl.prompt();
|
||
});
|
||
|
||
rl.on('close', () => {
|
||
process.stdout.write('\n');
|
||
process.exit(0);
|
||
});
|
||
|
||
function loadSystemPrompt() {
|
||
const p = path.join(__dirname, '..', 'prompts', 'system.txt');
|
||
try {
|
||
return fs.readFileSync(p, 'utf8');
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function truncateText(text, maxLen) {
|
||
if (text.length <= maxLen) return text;
|
||
return text.slice(0, maxLen) + '...';
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
async function simulateAssistantReply(userInput) {
|
||
const spinner = startSpinner('');
|
||
await sleep(600);
|
||
spinner.setLabel(' 思考中...');
|
||
await sleep(1200);
|
||
stopSpinner(spinner, SHOW_REASONING ? '○ 思考完成' : '○');
|
||
|
||
if (SHOW_REASONING) {
|
||
const reasoning = `好的,用户让我根据输入「${userInput}」做出回应,并给出明确的下一步。`;
|
||
const snippet = truncateText(reasoning, 50);
|
||
await streamText('', `${GREY}${snippet}${RESET}`, 12);
|
||
}
|
||
|
||
const reply = `这是模拟的 Eagent 回复内容。后续会替换为真实模型流式输出。`;
|
||
await streamText('Eagent:', reply, 18);
|
||
state.tokenUsage += userInput.length + reply.length;
|
||
}
|
||
|
||
function startSpinner(initialLabel) {
|
||
let i = 0;
|
||
let label = initialLabel;
|
||
const timer = setInterval(() => {
|
||
const frame = SPINNER_FRAMES[i % SPINNER_FRAMES.length];
|
||
process.stdout.write(`\r${SPINNER_INDENT}${frame}${label}`);
|
||
i += 1;
|
||
}, SPINNER_INTERVAL_MS);
|
||
return {
|
||
timer,
|
||
setLabel(nextLabel) {
|
||
label = nextLabel || '';
|
||
}
|
||
};
|
||
}
|
||
|
||
function stopSpinner(spinner, finalText) {
|
||
clearInterval(spinner.timer);
|
||
const clear = '\r' + ' '.repeat(80) + '\r';
|
||
process.stdout.write(clear + SPINNER_INDENT + finalText + '\n');
|
||
}
|
||
|
||
async function streamText(prefix, text, delayMs) {
|
||
if (prefix) process.stdout.write(prefix);
|
||
for (const ch of text) {
|
||
process.stdout.write(ch);
|
||
await sleep(delayMs);
|
||
}
|
||
process.stdout.write('\n');
|
||
}
|
||
|
||
async function handleCommand(input) {
|
||
const [cmd, ...rest] = input.split(/\s+/);
|
||
const arg = rest.join(' ').trim();
|
||
|
||
if (cmd === '/exit') {
|
||
rl.close();
|
||
return;
|
||
}
|
||
|
||
if (cmd === '/help') {
|
||
printHelp();
|
||
return;
|
||
}
|
||
|
||
switch (cmd) {
|
||
case '/new':
|
||
state.conversationId = crypto.randomUUID();
|
||
state.tokenUsage = 0;
|
||
console.log(`已创建新对话: ${state.conversationId}`);
|
||
return;
|
||
case '/resume':
|
||
if (arg) {
|
||
state.conversationId = arg;
|
||
console.log(`已加载对话: ${state.conversationId}`);
|
||
} else {
|
||
console.log('Updated Conversation');
|
||
console.log('2分钟前 帮我看看...');
|
||
console.log('14小时前 查看所有...');
|
||
console.log('13天前 ...');
|
||
console.log('提示:使用 /resume <id> 直接加载。');
|
||
}
|
||
return;
|
||
case '/allow':
|
||
state.allow = state.allow === 'full_access' ? 'read_only' : 'full_access';
|
||
console.log(`运行模式已切换为: ${state.allow}`);
|
||
return;
|
||
case '/model': {
|
||
if (!arg) {
|
||
state.thinking = state.thinking === 'thinking' ? 'fast' : 'thinking';
|
||
console.log(`思考模式已切换为: ${state.thinking}`);
|
||
return;
|
||
}
|
||
const parts = arg.split(/\s+/).filter(Boolean);
|
||
state.model = parts[0] || state.model;
|
||
if (parts[1]) state.thinking = parts[1];
|
||
console.log(`模型已切换为: ${state.model} | 思考模式: ${state.thinking}`);
|
||
return;
|
||
}
|
||
case '/status':
|
||
console.log(`model: ${state.model} | 思考: ${state.thinking}`);
|
||
console.log(`workspace: ${WORKSPACE}`);
|
||
console.log(`allow: ${state.allow}`);
|
||
console.log(`conversation: ${state.conversationId}`);
|
||
console.log(`token usage: ${state.tokenUsage}`);
|
||
return;
|
||
case '/compact': {
|
||
const before = state.tokenUsage;
|
||
const after = Math.max(0, Math.floor(before * 0.6));
|
||
state.tokenUsage = after;
|
||
console.log(`压缩完成:${before} -> ${after}`);
|
||
return;
|
||
}
|
||
case '/config':
|
||
console.log(`base_url: ${state.baseUrl}`);
|
||
console.log(`modelname: ${state.model}`);
|
||
console.log(`apikey: ${state.apiKey}`);
|
||
return;
|
||
default:
|
||
console.log(`未知指令: ${cmd},使用 /help 查看指令列表。`);
|
||
}
|
||
}
|
||
|
||
function printHelp() {
|
||
console.log('/new 创建新对话');
|
||
console.log('/resume 加载旧对话');
|
||
console.log('/allow 切换运行模式(只读/无限制)');
|
||
console.log('/model 切换模型和思考模式');
|
||
console.log('/status 查看当前对话状态');
|
||
console.log('/compact 压缩当前对话');
|
||
console.log('/config 查看当前配置');
|
||
console.log('/exit 退出程序');
|
||
console.log('/help 显示指令列表');
|
||
}
|
||
|
||
/**
|
||
* 打开命令菜单
|
||
*
|
||
* 关键修复点:
|
||
* 1. 在输入 / 后,光标位于提示行末尾
|
||
* 2. 我们不写换行符,而是直接在下一行开始渲染菜单
|
||
* 3. 使用 ANSI 序列精确控制光标位置
|
||
*/
|
||
function openCommandMenu() {
|
||
if (menuState.active) return;
|
||
|
||
menuState.active = true;
|
||
menuState.selected = 0;
|
||
menuState.offset = 0;
|
||
|
||
// 清空当前输入行(保留提示符)
|
||
rl.line = '';
|
||
rl.cursor = 0;
|
||
|
||
// 隐藏光标,暂停 readline
|
||
rl.pause();
|
||
process.stdout.write(ANSI.HIDE_CURSOR);
|
||
|
||
// 移动到行尾(确保我们在输入行的末尾)
|
||
const currentLine = PROMPT + '/';
|
||
process.stdout.write(ANSI.CLEAR_LINE);
|
||
process.stdout.write('\r' + currentLine);
|
||
|
||
// 关键:移到下一行开始渲染菜单
|
||
process.stdout.write('\n');
|
||
|
||
renderCommandMenu();
|
||
}
|
||
|
||
/**
|
||
* 关闭命令菜单
|
||
*
|
||
* 关键修复点:
|
||
* 1. 向上移动到菜单开始的位置
|
||
* 2. 清除所有菜单行
|
||
* 3. 将光标移回输入行(在 / 后面)
|
||
* 4. 恢复 readline 状态
|
||
*/
|
||
async function closeCommandMenu() {
|
||
if (!menuState.active) return;
|
||
|
||
// 清除菜单(从当前位置向上清除)
|
||
clearCommandMenu();
|
||
|
||
// 向上移动到输入行(菜单有 COMMAND_MENU_SIZE 行)
|
||
process.stdout.write(ANSI.MOVE_UP(COMMAND_MENU_SIZE));
|
||
|
||
// 清除输入行并重新显示提示符
|
||
process.stdout.write('\r' + ANSI.CLEAR_LINE);
|
||
process.stdout.write(PROMPT);
|
||
|
||
// 显示光标
|
||
process.stdout.write(ANSI.SHOW_CURSOR);
|
||
|
||
menuState.active = false;
|
||
rl.resume();
|
||
}
|
||
|
||
/**
|
||
* 渲染命令菜单
|
||
*
|
||
* 关键修复点:
|
||
* 1. 菜单固定显示 6 行
|
||
* 2. 每次重新渲染时,先回到菜单起始位置
|
||
* 3. 使用 ANSI 序列清除并重写每一行
|
||
* 4. 保持光标位置稳定
|
||
*/
|
||
function renderCommandMenu() {
|
||
const total = COMMAND_MENU_ITEMS.length;
|
||
|
||
// 计算滚动偏移
|
||
if (menuState.selected < menuState.offset) {
|
||
menuState.offset = menuState.selected;
|
||
}
|
||
if (menuState.selected >= menuState.offset + COMMAND_MENU_SIZE) {
|
||
menuState.offset = menuState.selected - COMMAND_MENU_SIZE + 1;
|
||
}
|
||
|
||
// 保存当前光标位置(用于后续恢复)
|
||
// 实际上我们不需要保存,因为我们控制整个渲染过程
|
||
|
||
// 移到菜单开始位置(向上移动菜单行数)
|
||
// 注意:此时光标在最后一行的末尾
|
||
process.stdout.write(ANSI.MOVE_UP(COMMAND_MENU_SIZE));
|
||
|
||
// 渲染每一行
|
||
const end = Math.min(total, menuState.offset + COMMAND_MENU_SIZE);
|
||
let lineIndex = 0;
|
||
|
||
for (let i = menuState.offset; i < menuState.offset + COMMAND_MENU_SIZE; i++) {
|
||
// 移到行首并清除该行
|
||
process.stdout.write('\r' + ANSI.CLEAR_LINE);
|
||
|
||
if (i < end) {
|
||
const item = COMMAND_MENU_ITEMS[i];
|
||
const isSelected = i === menuState.selected;
|
||
const cmdText = item.cmd.padEnd(12, ' ');
|
||
const left = isSelected ? `${BLUE}▸ ${cmdText}${RESET}` : ` ${cmdText}`;
|
||
const right = `${GREY}${item.desc}${RESET}`;
|
||
process.stdout.write(`${left} ${right}`);
|
||
} else {
|
||
// 空白行(当项目少于 COMMAND_MENU_SIZE 时)
|
||
process.stdout.write('');
|
||
}
|
||
|
||
// 如果不是最后一行,移到下一行
|
||
lineIndex++;
|
||
if (lineIndex < COMMAND_MENU_SIZE) {
|
||
process.stdout.write('\n');
|
||
}
|
||
}
|
||
|
||
// 将光标移回菜单第一行(这样下次渲染时从正确的位置开始)
|
||
process.stdout.write(ANSI.MOVE_UP(COMMAND_MENU_SIZE - 1));
|
||
}
|
||
|
||
/**
|
||
* 清除命令菜单
|
||
*
|
||
* 从当前位置向上清除所有菜单行
|
||
*/
|
||
function clearCommandMenu() {
|
||
// 移到菜单底部(向下移动菜单行数-1,因为我们当前在第一行)
|
||
process.stdout.write(ANSI.MOVE_DOWN(COMMAND_MENU_SIZE - 1));
|
||
|
||
// 从底部开始向上清除每一行
|
||
for (let i = 0; i < COMMAND_MENU_SIZE; i++) {
|
||
process.stdout.write('\r' + ANSI.CLEAR_LINE);
|
||
if (i < COMMAND_MENU_SIZE - 1) {
|
||
process.stdout.write(ANSI.MOVE_UP(1));
|
||
}
|
||
}
|
||
}
|