516 lines
16 KiB
JavaScript
516 lines
16 KiB
JavaScript
#!/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');
|
||
}
|
||
}
|