agent-Specialization/easyagent/src/cli/index.js

852 lines
28 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 { getModelByKey } = require('../config');
const { streamChat } = require('../model/client');
const { executeTool } = require('../tools/dispatcher');
const { openCommandMenu, hasCommandMatch } = 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, normalizeUsagePayload } = require('../utils/token_usage');
const { gray, cyan, green, red, blue } = require('../utils/colors');
const { createIndentedWriter } = require('../ui/indented_writer');
const { createStatusBar } = require('../ui/status_bar');
const { visibleWidth } = require('../utils/text_width');
const { readMediafileTool } = require('../tools/read_mediafile');
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 SLASH_COMMANDS = new Set(['/new', '/resume', '/allow', '/model', '/status', '/compact', '/config', '/help', '/exit']);
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.heic']);
const VIDEO_EXTS = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']);
const config = ensureConfig();
if (!config.valid_models || config.valid_models.length === 0) {
console.log('');
console.log(`未找到可用模型,请先在 ${config.path} 填写完整模型信息。`);
console.log('');
process.exit(1);
}
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 currentMedia = { tokens: [], text: '' };
let commandMenuActive = false;
let menuSearchTerm = '';
let menuLastSearchTerm = '';
let menuJustClosedAt = 0;
let menuInjectedCommand = null;
let menuAbortController = null;
let menuJustClosedInjected = false;
let suppressSlashMenuUntil = 0;
let pendingSlashTimer = null;
function printNotice(message) {
console.log('');
console.log(message);
console.log('');
}
function printNoticeInline(message) {
console.log(message);
}
function getPathExt(p) {
const idx = p.lastIndexOf('.');
if (idx === -1) return '';
return p.slice(idx).toLowerCase();
}
function decodeEscapedPath(p) {
let text = String(p || '');
if (text.startsWith('file://')) {
text = text.replace(/^file:\/\//, '');
}
try {
text = decodeURIComponent(text);
} catch (_) {}
return text.replace(/\\ /g, ' ').replace(/\\\\/g, '\\');
}
function findMediaMatches(line) {
const matches = [];
const quoted = /'([^']+)'/g;
let m;
while ((m = quoted.exec(line)) !== null) {
matches.push({ raw: m[0], path: m[1], index: m.index });
}
const extGroup = Array.from(new Set([...IMAGE_EXTS, ...VIDEO_EXTS]))
.map((e) => e.replace('.', '\\.'))
.join('|');
const unquoted = new RegExp(`/((?:\\\\ |[^\\s])+?)\\.(${extGroup})`, 'g');
while ((m = unquoted.exec(line)) !== null) {
const raw = `/${m[1]}.${m[2]}`;
matches.push({ raw, path: raw, index: m.index });
}
matches.sort((a, b) => a.index - b.index);
return matches;
}
function applyMediaTokens(line) {
if (!line) return { line: '', tokens: [] };
const matches = findMediaMatches(line);
if (!matches.length) return { line, tokens: [] };
let images = 0;
let videos = 0;
let cursor = 0;
let out = '';
const tokens = [];
for (const match of matches) {
if (match.index < cursor) continue;
const before = line.slice(cursor, match.index);
out += before;
const decoded = decodeEscapedPath(match.path);
if (!fs.existsSync(decoded)) {
out += match.raw;
cursor = match.index + match.raw.length;
continue;
}
const ext = getPathExt(decoded);
const isImage = IMAGE_EXTS.has(ext);
const isVideo = VIDEO_EXTS.has(ext);
if (!isImage && !isVideo) {
out += match.raw;
cursor = match.index + match.raw.length;
continue;
}
if (isImage && images >= 9) {
out += match.raw;
cursor = match.index + match.raw.length;
continue;
}
if (isVideo && videos >= 1) {
out += match.raw;
cursor = match.index + match.raw.length;
continue;
}
const token = isImage ? `[图片 #${images + 1}]` : `[视频 #${videos + 1}]`;
tokens.push({ token, path: decoded, type: isImage ? 'image' : 'video' });
out += token;
if (isImage) images += 1;
if (isVideo) videos += 1;
cursor = match.index + match.raw.length;
}
out += line.slice(cursor);
return { line: out, tokens };
}
function applySingleMediaPath(line, rawPath, tokens) {
if (!line || !rawPath) return { line, tokens };
const idx = line.indexOf(rawPath);
if (idx === -1) return { line, tokens };
const decoded = decodeEscapedPath(rawPath);
if (!fs.existsSync(decoded)) return { line, tokens };
const ext = getPathExt(decoded);
const isImage = IMAGE_EXTS.has(ext);
const isVideo = VIDEO_EXTS.has(ext);
if (!isImage && !isVideo) return { line, tokens };
const images = tokens.filter((t) => t.type === 'image').length;
const videos = tokens.filter((t) => t.type === 'video').length;
if (isImage && images >= 9) return { line, tokens };
if (isVideo && videos >= 1) return { line, tokens };
const token = isImage ? `[图片 #${images + 1}]` : `[视频 #${videos + 1}]`;
const nextLine = line.slice(0, idx) + token + line.slice(idx + rawPath.length);
const nextTokens = tokens.concat([{ token, path: decoded, type: isImage ? 'image' : 'video' }]);
return { line: nextLine, tokens: nextTokens };
}
function colorizeTokens(line) {
return line.replace(/\[(图片|视频) #\d+\]/g, (t) => blue(t));
}
function refreshInputLine() {
if (!rl || !process.stdout.isTTY || commandMenuActive) return;
const line = rl.line || '';
const colorLine = colorizeTokens(line);
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
process.stdout.write(PROMPT + colorLine);
const cursorCol = visibleWidth(PROMPT) + visibleWidth(line.slice(0, rl.cursor || 0));
readline.cursorTo(process.stdout, cursorCol);
}
function removeTokenAtCursor(line, cursor, allowStart = false) {
const tokenRe = /\[(图片|视频) #\d+\]/g;
let m;
while ((m = tokenRe.exec(line)) !== null) {
const start = m.index;
const end = m.index + m[0].length;
if ((allowStart ? cursor >= start : cursor > start) && cursor <= end) {
const nextLine = line.slice(0, start) + line.slice(end);
return { line: nextLine, cursor: start, removed: true };
}
}
return { line, cursor, removed: false };
}
function isSlashCommand(line) {
if (!line || line[0] !== '/') return false;
const cmd = line.split(/\s+/)[0];
return SLASH_COMMANDS.has(cmd);
}
readline.emitKeypressEvents(process.stdin);
if (process.stdin.isTTY) process.stdin.setRawMode(true);
process.stdin.on('data', (chunk) => {
if (isRunning || commandMenuActive || !rl) return;
const text = chunk ? chunk.toString() : '';
if (!text) return;
const looksLikePath = text.includes('file://') || /\.(png|jpe?g|gif|webp|bmp|tiff|heic|mp4|mov|avi|mkv|webm|m4v)\b/i.test(text);
if (!looksLikePath) return;
suppressSlashMenuUntil = Date.now() + 200;
setImmediate(() => {
const line = rl.line || '';
const raw = text.replace(/\r?\n/g, '').trim();
const decoded = raw ? decodeEscapedPath(raw) : '';
let applied = applyMediaTokens(line);
if (raw) {
if (line.includes(raw)) {
applied = applySingleMediaPath(applied.line, raw, applied.tokens);
} else if (decoded && line.includes(decoded)) {
applied = applySingleMediaPath(applied.line, decoded, applied.tokens);
}
}
if (applied.tokens.length) {
rl.line = applied.line;
rl.cursor = rl.line.length;
currentMedia = { tokens: applied.tokens, text: applied.line };
refreshInputLine();
}
});
});
initReadline();
statusBar = createStatusBar({
getTokens: () => normalizeTokenUsage(state.tokenUsage).total || 0,
getMaxTokens: () => {
const model = getModelByKey(config, state.modelKey);
return model && Number.isFinite(model.max_context) ? model.max_context : null;
},
});
statusBar.render();
process.stdin.on('keypress', (str, key) => {
if (commandMenuActive) {
if (key && key.name === 'backspace' && menuSearchTerm === '') {
if (menuAbortController && !menuAbortController.signal.aborted) {
menuAbortController.abort();
}
}
if (key && key.name === 'return') {
const term = String(menuSearchTerm || '').replace(/^\/+/, '').trim();
if (term && !hasCommandMatch(term)) {
menuInjectedCommand = `/${term}`;
if (menuAbortController && !menuAbortController.signal.aborted) {
menuAbortController.abort();
}
}
}
return;
}
if (!isRunning && key && (key.name === 'backspace' || key.name === 'delete')) {
const line = rl.line || '';
const cursor = rl.cursor || 0;
const updated = removeTokenAtCursor(line, cursor, key.name === 'delete');
if (updated.removed) {
rl.line = updated.line;
rl.cursor = updated.cursor;
refreshInputLine();
if (statusBar) statusBar.render();
return;
}
if (statusBar) statusBar.render();
}
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 === '/' && Date.now() < suppressSlashMenuUntil) {
return;
}
if (pendingSlashTimer && (str !== '/' || (rl.line && rl.line !== '/'))) {
clearTimeout(pendingSlashTimer);
pendingSlashTimer = null;
}
if (!isRunning) {
const rawLine = rl.line || '';
let applied = { line: rawLine, tokens: [] };
if (!isSlashCommand(rawLine)) {
const hasToken = /\[(图片|视频) #\d+\]/.test(rawLine);
if (hasToken && currentMedia.tokens.length && currentMedia.text === rawLine) {
applied = { line: rawLine, tokens: currentMedia.tokens };
} else {
applied = applyMediaTokens(rawLine);
}
if (hasToken && !applied.tokens.length && currentMedia.tokens.length) {
applied = { line: rawLine, tokens: currentMedia.tokens };
}
}
if (applied.line !== (rl.line || '')) {
rl.line = applied.line;
rl.cursor = rl.line.length;
currentMedia = { tokens: applied.tokens, text: applied.line };
} else if (currentMedia.text !== applied.line) {
currentMedia = { tokens: applied.tokens, text: applied.line };
}
refreshInputLine();
}
if (statusBar) statusBar.render();
if (str === '/' && (rl.line === '' || rl.line === '/')) {
if (pendingSlashTimer) {
clearTimeout(pendingSlashTimer);
pendingSlashTimer = null;
}
pendingSlashTimer = setTimeout(() => {
pendingSlashTimer = null;
if (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));
}
});
}
}, 80);
return;
}
});
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;
}
let applied = { line, tokens: [] };
if (!isSlashCommand(line)) {
const hasToken = /\[(图片|视频) #\d+\]/.test(line);
if (hasToken && currentMedia.tokens.length && currentMedia.text === line) {
applied = { line, tokens: currentMedia.tokens };
} else {
applied = applyMediaTokens(line);
}
if (hasToken && !applied.tokens.length && currentMedia.tokens.length) {
applied = { line, tokens: currentMedia.tokens };
}
}
const normalizedLine = applied.line;
currentMedia = { tokens: applied.tokens, text: normalizedLine };
const input = normalizedLine.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('/') && isSlashCommand(input)) {
const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE, statusBar });
if (result && result.exit) return;
promptWithStatus();
return;
}
if (input.startsWith('/') && !isSlashCommand(input)) {
printNoticeInline(`无效的命令“${input}`);
promptWithStatus();
return;
}
console.log('');
const userWriter = createIndentedWriter(' ');
const displayLine = colorizeTokens(normalizedLine);
userWriter.writeLine(`${cyan('用户:')}${displayLine}`);
const content = buildUserContent(normalizedLine, currentMedia.tokens);
state.messages.push({ role: 'user', content });
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,
modelId: state.modelId || state.modelKey,
});
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 buildUserContent(line, tokens) {
if (!tokens.length) return line;
const parts = [{ type: 'text', text: line }];
for (const info of tokens) {
const media = readMediafileTool(WORKSPACE, { path: info.path });
if (media && media.success) {
const url = `data:${media.mime};base64,${media.b64}`;
parts.push({
type: media.type === 'image' ? 'image_url' : 'video_url',
[media.type === 'image' ? 'image_url' : 'video_url']: { url },
});
}
}
return parts;
}
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 {
const currentContextTokens = normalizeTokenUsage(state.tokenUsage).total || 0;
for await (const chunk of streamChat({
config,
modelKey: state.modelKey,
messages,
tools,
thinkingMode: state.thinkingMode,
currentContextTokens,
abortSignal: streamController.signal,
})) {
const choice = chunk.choices && chunk.choices[0];
const usage = (chunk && chunk.usage)
|| (choice && choice.usage)
|| (choice && choice.delta && choice.delta.usage);
const normalizedUsage = normalizeUsagePayload(usage);
if (normalizedUsage) {
if (Number.isFinite(normalizedUsage.prompt_tokens)) usagePrompt = normalizedUsage.prompt_tokens;
if (Number.isFinite(normalizedUsage.completion_tokens)) usageCompletion = normalizedUsage.completion_tokens;
if (Number.isFinite(normalizedUsage.total_tokens)) usageTotal = normalizedUsage.total_tokens;
}
if (!choice) continue;
const delta = choice.delta || {};
if (delta.reasoning_content || delta.reasoning_details || choice.reasoning_details) {
thinkingActive = true;
showThinkingLabel = true;
let rc = '';
if (delta.reasoning_content) {
rc = delta.reasoning_content;
} else if (delta.reasoning_details) {
if (Array.isArray(delta.reasoning_details)) {
rc = delta.reasoning_details.map((d) => d.text || '').join('');
} else if (typeof delta.reasoning_details === 'string') {
rc = delta.reasoning_details;
} else if (delta.reasoning_details && typeof delta.reasoning_details.text === 'string') {
rc = delta.reasoning_details.text;
}
} else if (choice.reasoning_details) {
if (Array.isArray(choice.reasoning_details)) {
rc = choice.reasoning_details.map((d) => d.text || '').join('');
} else if (typeof choice.reasoning_details === 'string') {
rc = choice.reasoning_details;
} else if (choice.reasoning_details && typeof choice.reasoning_details.text === 'string') {
rc = choice.reasoning_details.text;
}
}
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) {
if (gotAnswer) {
spinner.stopSilent();
} else {
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');
}
}