EasyAgent/src/cli/index.js

516 lines
16 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 os = require('os');
const path = require('path');
const readline = require('readline');
const { ensureConfig } = require('../config');
const { createState } = require('../core/state');
const { buildSystemPrompt } = require('../core/context');
const { streamChat } = require('../model/client');
const { executeTool } = require('../tools/dispatcher');
const { openCommandMenu } = require('../ui/command_menu');
const { handleCommand } = require('./commands');
const { Spinner, truncateThinking } = require('../ui/spinner');
const { renderBanner } = require('../ui/banner');
const { buildStartLine, buildFinalLine, startToolDisplay, formatResultLines, printResultLines } = require('../ui/tool_display');
const { createConversation, updateConversation } = require('../storage/conversation_store');
const { applyUsage, normalizeTokenUsage } = require('../utils/token_usage');
const { gray, cyan, green, red } = require('../utils/colors');
const { createIndentedWriter } = require('../ui/indented_writer');
const { createStatusBar } = require('../ui/status_bar');
const WORKSPACE = process.cwd();
const WORKSPACE_NAME = path.basename(WORKSPACE);
const USERNAME = os.userInfo().username || 'user';
const PROMPT = `${USERNAME}@${WORKSPACE_NAME} % `;
const MENU_PAGE_SIZE = 6;
const config = ensureConfig();
const state = createState(config, WORKSPACE);
state.conversation = createConversation(WORKSPACE, {
model_key: state.modelKey,
model_id: state.modelId,
thinking_mode: state.thinkingMode,
allow_mode: state.allowMode,
token_usage: state.tokenUsage,
cwd: WORKSPACE,
});
const systemPrompt = fs.readFileSync(path.join(__dirname, '../../prompts/system.txt'), 'utf8');
const tools = JSON.parse(fs.readFileSync(path.join(__dirname, '../../doc/tools.json'), 'utf8'));
renderBanner({
modelKey: state.modelKey,
workspace: WORKSPACE,
conversationId: state.conversation?.id,
});
console.log('');
console.log('');
let rl = null;
let statusBar = null;
let isRunning = false;
let escPendingCancel = false;
let activeStreamController = null;
let activeToolController = null;
let commandMenuActive = false;
let menuSearchTerm = '';
let menuLastSearchTerm = '';
let menuJustClosedAt = 0;
let menuInjectedCommand = null;
let menuAbortController = null;
let menuJustClosedInjected = false;
readline.emitKeypressEvents(process.stdin);
if (process.stdin.isTTY) process.stdin.setRawMode(true);
initReadline();
statusBar = createStatusBar({
getTokens: () => normalizeTokenUsage(state.tokenUsage).total || 0,
maxTokens: '256k',
});
statusBar.render();
process.stdin.on('keypress', (str, key) => {
if (commandMenuActive) {
if (key && key.name === 'backspace' && menuSearchTerm === '') {
if (menuAbortController && !menuAbortController.signal.aborted) {
menuAbortController.abort();
}
}
return;
}
if (key && key.name === 'escape' && isRunning) {
escPendingCancel = true;
if (activeStreamController && typeof activeStreamController.abort === 'function') {
activeStreamController.abort();
}
if (activeToolController && typeof activeToolController.abort === 'function') {
activeToolController.abort();
}
return;
}
if (str === '/' && (rl.line === '' || rl.line === '/')) {
commandMenuActive = true;
if (rl) {
rl.pause();
rl.line = '';
rl.cursor = 0;
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
}
menuSearchTerm = '';
menuAbortController = new AbortController();
void openCommandMenu({
rl,
prompt: PROMPT,
pageSize: MENU_PAGE_SIZE,
colorEnabled: process.stdout.isTTY,
resetAnsi: '\x1b[0m',
onInput: (input) => {
menuSearchTerm = input || '';
},
abortSignal: menuAbortController.signal,
})
.then((result) => {
if (result && result.chosen && !result.cancelled) {
menuInjectedCommand = result.chosen;
}
})
.finally(() => {
commandMenuActive = false;
menuAbortController = null;
menuJustClosedAt = menuInjectedCommand ? Date.now() : 0;
menuJustClosedInjected = !!menuInjectedCommand;
menuLastSearchTerm = menuSearchTerm;
drainStdin();
rl.line = '';
rl.cursor = 0;
menuSearchTerm = '';
if (process.stdout.isTTY) {
readline.clearScreenDown(process.stdout);
}
// Clear possible echoes from the base readline line (current + previous line).
if (process.stdout.isTTY) {
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
readline.moveCursor(process.stdout, 0, -1);
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
} else {
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
}
promptWithStatus(true);
if (menuInjectedCommand) {
const injected = menuInjectedCommand;
menuInjectedCommand = null;
setImmediate(() => injectLine(injected));
}
});
}
});
function initReadline() {
if (rl) {
try {
rl.removeAllListeners();
} catch (_) {}
}
rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: true,
});
process.stdin.resume();
rl.setPrompt(PROMPT);
promptWithStatus();
rl.on('line', async (line) => {
if (rl) {
rl.line = '';
rl.cursor = 0;
}
if (commandMenuActive) {
return;
}
const input = line.trim();
if (menuJustClosedAt) {
if (!menuJustClosedInjected) {
const tooOld = Date.now() - menuJustClosedAt > 800;
const normalizedMenu = String(menuLastSearchTerm).trim().replace(/^\/+/, '');
const normalizedInput = input.replace(/^\/+/, '');
if (!tooOld && (!normalizedMenu ? input === '' : normalizedInput === normalizedMenu)) {
menuJustClosedAt = 0;
menuLastSearchTerm = '';
if (process.stdout.isTTY) {
readline.moveCursor(process.stdout, 0, -1);
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
}
promptWithStatus();
return;
}
}
menuJustClosedAt = 0;
menuLastSearchTerm = '';
menuJustClosedInjected = false;
}
if (!input) {
promptWithStatus();
return;
}
if (input.startsWith('/')) {
const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE, statusBar });
if (result && result.exit) return;
promptWithStatus();
return;
}
console.log('');
const userWriter = createIndentedWriter(' ');
userWriter.writeLine(`${cyan('用户:')}${line}`);
state.messages.push({ role: 'user', content: line });
persistConversation();
await runAssistantLoop();
promptWithStatus();
});
rl.on('close', () => {
if (statusBar) statusBar.destroy();
process.stdout.write('\n');
process.exit(0);
});
}
function drainStdin() {
if (!process.stdin.isTTY) return;
process.stdin.pause();
while (process.stdin.read() !== null) {}
process.stdin.resume();
}
function injectLine(text) {
if (!rl) return;
rl.write(text);
rl.write('\n');
}
function persistConversation() {
if (!state.conversation) return;
state.conversation = updateConversation(WORKSPACE, state.conversation, state.messages, {
model_key: state.modelKey,
model_id: state.modelId,
thinking_mode: state.thinkingMode,
allow_mode: state.allowMode,
token_usage: state.tokenUsage,
cwd: WORKSPACE,
});
}
function promptWithStatus(force = false) {
if (!rl) return;
if (process.stdout.isTTY) {
const rows = process.stdout.rows || 24;
if (rows >= 3) {
readline.cursorTo(process.stdout, 0, rows - 3);
readline.clearLine(process.stdout, 0);
}
}
rl.prompt(force);
if (statusBar) statusBar.render();
}
function buildApiMessages() {
const system = buildSystemPrompt(systemPrompt, { workspace: WORKSPACE, allowMode: state.allowMode });
const messages = [{ role: 'system', content: system }];
for (const msg of state.messages) {
const m = { role: msg.role };
if (msg.role === 'tool') {
m.tool_call_id = msg.tool_call_id;
m.content = msg.content;
} else if (msg.role === 'assistant' && Array.isArray(msg.tool_calls)) {
m.content = msg.content || null;
m.tool_calls = msg.tool_calls;
if (Object.prototype.hasOwnProperty.call(msg, 'reasoning_content')) {
m.reasoning_content = msg.reasoning_content;
} else if (state.thinkingMode) {
m.reasoning_content = '';
}
} else {
m.content = msg.content;
if (msg.role === 'assistant' && Object.prototype.hasOwnProperty.call(msg, 'reasoning_content')) {
m.reasoning_content = msg.reasoning_content;
}
}
messages.push(m);
}
return messages;
}
function printCancelLine() {
console.log('');
process.stdout.write(` ${red('已取消本次响应')}\n\n`);
}
function stopSpinnerForCancel(spinner, thinkingActive, showThinkingLabel, thinkingMode) {
if (!spinner) return;
if (thinkingMode && (thinkingActive || showThinkingLabel)) {
spinner.stop('∙ 停止思考');
} else {
spinner.stopSilent();
}
}
async function runAssistantLoop() {
let continueLoop = true;
let firstLoop = true;
const hideCursor = () => process.stdout.write('\x1b[?25l');
const showCursor = () => process.stdout.write('\x1b[?25h');
isRunning = true;
escPendingCancel = false;
if (statusBar) statusBar.setMode('running');
try {
while (continueLoop) {
hideCursor();
const spinner = new Spinner(' ', firstLoop ? 1 : 0);
firstLoop = false;
let thinkingBuffer = '';
let fullThinkingBuffer = '';
let thinkingActive = false;
let showThinkingLabel = false;
let gotAnswer = false;
let assistantContent = '';
let toolCalls = {};
let usageTotal = null;
let usagePrompt = null;
let usageCompletion = null;
let firstContent = true;
let assistantWriter = null;
let cancelled = false;
const thinkingDelay = setTimeout(() => {
if (state.thinkingMode) showThinkingLabel = true;
}, 400);
spinner.start(() => {
if (!state.thinkingMode) return '';
if (!showThinkingLabel) return '';
return { label: ' 思考中...', thinking: thinkingBuffer, colorThinking: true };
});
const messages = buildApiMessages();
const streamController = new AbortController();
activeStreamController = streamController;
try {
for await (const chunk of streamChat({ config, messages, tools, thinkingMode: state.thinkingMode, abortSignal: streamController.signal })) {
const choice = chunk.choices && chunk.choices[0];
if (!choice) continue;
const usage = (choice && (choice.usage || choice.delta?.usage)) || chunk.usage;
if (usage) {
if (Number.isFinite(usage.prompt_tokens)) usagePrompt = usage.prompt_tokens;
if (Number.isFinite(usage.completion_tokens)) usageCompletion = usage.completion_tokens;
if (Number.isFinite(usage.total_tokens)) usageTotal = usage.total_tokens;
}
const delta = choice.delta || {};
if (delta.reasoning_content || delta.reasoning_details) {
thinkingActive = true;
showThinkingLabel = true;
const rc = delta.reasoning_content || (Array.isArray(delta.reasoning_details) ? delta.reasoning_details.map((d) => d.text || '').join('') : '');
fullThinkingBuffer += rc;
thinkingBuffer = truncateThinking(fullThinkingBuffer);
}
if (delta.tool_calls) {
delta.tool_calls.forEach((tc) => {
const idx = tc.index;
if (!toolCalls[idx]) toolCalls[idx] = { id: tc.id, type: tc.type, function: { name: '', arguments: '' } };
if (tc.function?.name) toolCalls[idx].function.name = tc.function.name;
if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments;
});
}
if (delta.content) {
if (!gotAnswer) {
clearTimeout(thinkingDelay);
if (state.thinkingMode) {
spinner.stop(thinkingActive ? '○ 思考完成' : '○');
} else {
spinner.stopSilent();
}
console.log('');
assistantWriter = createIndentedWriter(' ');
assistantWriter.write(`${green('Eagent')}`);
gotAnswer = true;
}
if (!gotAnswer) return;
if (!assistantWriter) assistantWriter = createIndentedWriter(' ');
assistantWriter.write(delta.content);
assistantContent += delta.content;
}
if (choice.finish_reason) {
if (choice.finish_reason === 'tool_calls') {
continueLoop = true;
}
}
}
} catch (err) {
if (err && (err.code === 'aborted' || err.name === 'AbortError' || escPendingCancel)) {
cancelled = true;
} else {
clearTimeout(thinkingDelay);
if (state.thinkingMode) {
spinner.stop('○');
} else {
spinner.stopSilent();
}
showCursor();
console.log(`错误: ${err.message || err}`);
return;
}
}
activeStreamController = null;
clearTimeout(thinkingDelay);
if (cancelled) {
stopSpinnerForCancel(spinner, thinkingActive, showThinkingLabel, state.thinkingMode);
showCursor();
printCancelLine();
return;
}
if (!gotAnswer) {
if (state.thinkingMode) {
spinner.stop(thinkingActive ? '○ 思考完成' : '○');
} else {
spinner.stopSilent();
}
} else {
process.stdout.write('\n\n');
}
showCursor();
if (usageTotal !== null || usagePrompt !== null || usageCompletion !== null) {
state.tokenUsage = applyUsage(normalizeTokenUsage(state.tokenUsage), {
prompt_tokens: usagePrompt,
completion_tokens: usageCompletion,
total_tokens: usageTotal,
});
}
const toolCallList = Object.keys(toolCalls)
.map((k) => Number(k))
.sort((a, b) => a - b)
.map((k) => toolCalls[k]);
if (toolCallList.length) {
const assistantMsg = {
role: 'assistant',
content: assistantContent || null,
tool_calls: toolCallList,
};
if (state.thinkingMode) assistantMsg.reasoning_content = fullThinkingBuffer || '';
state.messages.push(assistantMsg);
persistConversation();
for (const call of toolCallList) {
let args = {};
try { args = JSON.parse(call.function.arguments || '{}'); } catch (_) {}
const startLine = buildStartLine(call.function.name, args);
const finalLine = buildFinalLine(call.function.name, args);
const indicator = startToolDisplay(startLine);
const toolController = new AbortController();
activeToolController = toolController;
const toolResult = await executeTool({
workspace: WORKSPACE,
config,
allowMode: state.allowMode,
toolCall: call,
abortSignal: toolController.signal,
});
activeToolController = null;
indicator.stop(finalLine);
const resultLines = formatResultLines(call.function.name, args, toolResult.raw || { success: toolResult.success, error: toolResult.error });
printResultLines(resultLines);
const toolMsg = {
role: 'tool',
tool_call_id: call.id,
content: toolResult.tool_content || toolResult.formatted,
tool_raw: toolResult.raw,
};
state.messages.push(toolMsg);
persistConversation();
if (escPendingCancel || (toolResult.raw && toolResult.raw.cancelled)) {
return;
}
}
continueLoop = true;
hideCursor();
continue;
}
if (assistantContent) {
const msg = { role: 'assistant', content: assistantContent };
if (state.thinkingMode) msg.reasoning_content = fullThinkingBuffer || '';
state.messages.push(msg);
persistConversation();
}
continueLoop = false;
}
} finally {
showCursor();
isRunning = false;
escPendingCancel = false;
activeStreamController = null;
activeToolController = null;
if (statusBar) statusBar.setMode('input');
}
}