EasyAgent/demo/cli_demo_fixed.js
2026-02-28 03:00:08 +08:00

456 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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));
}
}
}