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