#!/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 } = require('../utils/colors'); const { createIndentedWriter } = require('../ui/indented_writer'); 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, }); let rl = null; let commandMenuActive = false; let menuSearchTerm = ''; let menuLastSearchTerm = ''; let menuJustClosedAt = 0; let menuInjectedCommand = null; let menuAbortController = null; readline.emitKeypressEvents(process.stdin); if (process.stdin.isTTY) process.stdin.setRawMode(true); initReadline(); process.stdin.on('keypress', (str, key) => { if (commandMenuActive) { if (key && key.name === 'backspace' && menuSearchTerm === '') { if (menuAbortController && !menuAbortController.signal.aborted) { menuAbortController.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; 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); } rl.prompt(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); rl.prompt(); rl.on('line', async (line) => { if (rl) { rl.line = ''; rl.cursor = 0; } if (commandMenuActive) { return; } const input = line.trim(); if (menuJustClosedAt) { 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); } rl.prompt(); return; } menuJustClosedAt = 0; menuLastSearchTerm = ''; } if (!input) { rl.prompt(); return; } if (input.startsWith('/')) { const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE }); if (result && result.exit) return; rl.prompt(); return; } console.log(''); const userWriter = createIndentedWriter(' '); userWriter.writeLine(`${cyan('用户:')}${line}`); state.messages.push({ role: 'user', content: line }); persistConversation(); await runAssistantLoop(); rl.prompt(); }); rl.on('close', () => { 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 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; } async function runAssistantLoop() { let continueLoop = true; let firstLoop = true; const hideCursor = () => process.stdout.write('\x1b[?25l'); const showCursor = () => process.stdout.write('\x1b[?25h'); 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; 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(); try { for await (const chunk of streamChat({ config, messages, tools, thinkingMode: state.thinkingMode })) { 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) { clearTimeout(thinkingDelay); if (state.thinkingMode) { spinner.stop('○'); } else { spinner.stopSilent(); } showCursor(); console.log(`错误: ${err.message || err}`); return; } clearTimeout(thinkingDelay); 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 toolResult = await executeTool({ workspace: WORKSPACE, config, allowMode: state.allowMode, toolCall: call }); 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(); } 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; } showCursor(); }