852 lines
28 KiB
JavaScript
852 lines
28 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 { 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');
|
||
}
|
||
}
|