Update status bar and track progress
This commit is contained in:
parent
136a503846
commit
6d2d4857f3
@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "1042413d-b4ee-4586-85e5-eb80879d9fbe",
|
||||
"title": "新对话",
|
||||
"created_at": "2026-02-28T04:33:13.020Z",
|
||||
"updated_at": "2026-02-28T04:33:13.020Z",
|
||||
"metadata": {
|
||||
"model_key": "kimi-k2.5",
|
||||
"model_id": "kimi-k2.5",
|
||||
"thinking_mode": true,
|
||||
"allow_mode": "full_access",
|
||||
"token_usage": {
|
||||
"prompt": 0,
|
||||
"completion": 0,
|
||||
"total": 0
|
||||
},
|
||||
"cwd": "/Users/jojo/Desktop/easyagent"
|
||||
},
|
||||
"messages": []
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "1be5b6a9-769a-4276-96b6-185627fc308a",
|
||||
"title": "新对话",
|
||||
"created_at": "2026-02-28T04:35:06.703Z",
|
||||
"updated_at": "2026-02-28T04:35:10.259Z",
|
||||
"metadata": {
|
||||
"model_key": "kimi-k2.5",
|
||||
"model_id": "kimi-k2.5",
|
||||
"thinking_mode": true,
|
||||
"allow_mode": "read_only",
|
||||
"token_usage": {
|
||||
"prompt": 0,
|
||||
"completion": 0,
|
||||
"total": 0
|
||||
},
|
||||
"cwd": "/Users/jojo/Desktop/easyagent"
|
||||
},
|
||||
"messages": []
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "3a4b4cce-feac-49e7-8aee-66fe2881d076",
|
||||
"title": "新对话",
|
||||
"created_at": "2026-02-28T04:16:47.305Z",
|
||||
"updated_at": "2026-02-28T04:16:50.825Z",
|
||||
"metadata": {
|
||||
"model_key": "kimi-k2.5",
|
||||
"model_id": "kimi-k2.5",
|
||||
"thinking_mode": true,
|
||||
"allow_mode": "read_only",
|
||||
"token_usage": {
|
||||
"prompt": 0,
|
||||
"completion": 0,
|
||||
"total": 0
|
||||
},
|
||||
"cwd": "/Users/jojo/Desktop/easyagent"
|
||||
},
|
||||
"messages": []
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "cb1e445c-e7f5-4b70-bbf9-6114c441af47",
|
||||
"title": "新对话",
|
||||
"created_at": "2026-02-28T04:25:19.837Z",
|
||||
"updated_at": "2026-02-28T04:25:22.770Z",
|
||||
"metadata": {
|
||||
"model_key": "kimi-k2.5",
|
||||
"model_id": "kimi-k2.5",
|
||||
"thinking_mode": true,
|
||||
"allow_mode": "read_only",
|
||||
"token_usage": {
|
||||
"prompt": 0,
|
||||
"completion": 0,
|
||||
"total": 0
|
||||
},
|
||||
"cwd": "/Users/jojo/Desktop/easyagent"
|
||||
},
|
||||
"messages": []
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "e24b0466-9fed-4da5-b8b8-627154fe1d76",
|
||||
"title": "新对话",
|
||||
"created_at": "2026-02-28T04:32:28.441Z",
|
||||
"updated_at": "2026-02-28T04:32:31.209Z",
|
||||
"metadata": {
|
||||
"model_key": "kimi-k2.5",
|
||||
"model_id": "kimi-k2.5",
|
||||
"thinking_mode": true,
|
||||
"allow_mode": "read_only",
|
||||
"token_usage": {
|
||||
"prompt": 0,
|
||||
"completion": 0,
|
||||
"total": 0
|
||||
},
|
||||
"cwd": "/Users/jojo/Desktop/easyagent"
|
||||
},
|
||||
"messages": []
|
||||
}
|
||||
52
.easyagent/index.json
Normal file
52
.easyagent/index.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"3a4b4cce-feac-49e7-8aee-66fe2881d076": {
|
||||
"title": "新对话",
|
||||
"created_at": "2026-02-28T04:16:47.305Z",
|
||||
"updated_at": "2026-02-28T04:16:50.825Z",
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"thinking_mode": true,
|
||||
"run_mode": "thinking",
|
||||
"model_key": "kimi-k2.5"
|
||||
},
|
||||
"cb1e445c-e7f5-4b70-bbf9-6114c441af47": {
|
||||
"title": "新对话",
|
||||
"created_at": "2026-02-28T04:25:19.837Z",
|
||||
"updated_at": "2026-02-28T04:25:22.770Z",
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"thinking_mode": true,
|
||||
"run_mode": "thinking",
|
||||
"model_key": "kimi-k2.5"
|
||||
},
|
||||
"e24b0466-9fed-4da5-b8b8-627154fe1d76": {
|
||||
"title": "新对话",
|
||||
"created_at": "2026-02-28T04:32:28.441Z",
|
||||
"updated_at": "2026-02-28T04:32:31.209Z",
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"thinking_mode": true,
|
||||
"run_mode": "thinking",
|
||||
"model_key": "kimi-k2.5"
|
||||
},
|
||||
"1042413d-b4ee-4586-85e5-eb80879d9fbe": {
|
||||
"title": "新对话",
|
||||
"created_at": "2026-02-28T04:33:13.020Z",
|
||||
"updated_at": "2026-02-28T04:33:13.020Z",
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"thinking_mode": true,
|
||||
"run_mode": "thinking",
|
||||
"model_key": "kimi-k2.5"
|
||||
},
|
||||
"1be5b6a9-769a-4276-96b6-185627fc308a": {
|
||||
"title": "新对话",
|
||||
"created_at": "2026-02-28T04:35:06.703Z",
|
||||
"updated_at": "2026-02-28T04:35:10.259Z",
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"thinking_mode": true,
|
||||
"run_mode": "thinking",
|
||||
"model_key": "kimi-k2.5"
|
||||
}
|
||||
}
|
||||
87
2.txt
Normal file
87
2.txt
Normal file
@ -0,0 +1,87 @@
|
||||
收到,我先不再改代码。下面是根据本次对话记录整理出来的修改清单(仅基于聊天内容,不查看当前文件;如果某条在中途被回滚/覆盖,已在备注里说明)。
|
||||
|
||||
整体新增/变更汇总(按功能模块)
|
||||
|
||||
1. 底部状态栏 + 保留空行 ✓
|
||||
|
||||
- 新增“永久底部状态栏”概念:最底行显示提示信息,上一行留空;输入行位于其上方。
|
||||
- 左侧文案在输入时为“输入/查看所有指令”,运行时变为“按下Esc停止”。
|
||||
- 右侧显示“当前上下文 0/256k”,且数字>1000时显示 1.1k 格式。
|
||||
- 需要窗口缩放时自适应,不增加新底层显示行。
|
||||
|
||||
2. ESC 取消行为与记录落盘时机
|
||||
|
||||
- 按 Esc 需要立即中断运行:
|
||||
- 若模型思考中:显示“∙ 停止思考”,并在思考内容下方打印红色“已取消本次响应”,上下各留一行空行。
|
||||
- 若工具执行中:立即停止工具执行,工具结果为红色“任务被用户取消”,且不再继续执行工具结果处理。
|
||||
- 数据落盘更细:用户输入后、模型完整输出后(不含工具)、工具调用后、工具结果后都应写入内存+落盘。
|
||||
- 取消时如果输出未完成,未完成内容不入记录。
|
||||
|
||||
3. 工具输出显示
|
||||
|
||||
- “任务被用户取消”不带“└”或“失败:”前缀;左侧对齐。
|
||||
- 工具执行完后输出与下一行 prompt 之间空一行。
|
||||
- “取消响应”也需红色且左侧对齐,上一行留空。
|
||||
|
||||
4. /new 与 /resume token 复位
|
||||
|
||||
- /new 需要把右下角“当前上下文”重置(逻辑同 /resume)。
|
||||
- 修复了 /new 未被识别的问题(与命令菜单注入有关)。
|
||||
|
||||
5. 只读权限提示文案
|
||||
|
||||
- 只读模式提示文案最后一句改为“请告知当前无权限需要用户输入 /allow 切换权限”。
|
||||
- 只读模式下工具结果文案也需更新。
|
||||
|
||||
6. /allow、/model 等“Update”提示清理
|
||||
|
||||
- /allow /model /thinking 等菜单里不显示“Update …”。
|
||||
|
||||
7. /model 交互逻辑
|
||||
|
||||
- 需求最终定为:
|
||||
- 选择模型后,隔一行显示“模型已切换为: xxx”;
|
||||
- 隔一行再显示思考模式选择;
|
||||
- 选择思考模式后,在选项下面隔一行显示“思考模式: xxx”;
|
||||
- 不再要求清除菜单残留(因为清理和状态栏冲突)。
|
||||
|
||||
8. 命令菜单 / 无效指令
|
||||
|
||||
- 当 / 输入无匹配结果:
|
||||
- 菜单显示“无效的指令”(替换 No results found)。
|
||||
- 按 Enter 后不发送给模型,只显示“无效的命令“/app””。
|
||||
|
||||
9. 多模态(图片/视频)
|
||||
|
||||
- 输入路径或拖拽可自动插入蓝色 token [图片 #1] / [视频 #1]。
|
||||
- 删除时 token 作为整体删除。
|
||||
- 最多 9 张图片、1 个视频。
|
||||
- 发送时:文本里包含 [图片 #n] 显示;实际请求使用 image_url/video_url base64 发送。
|
||||
- 识别单引号路径 '/path/to/file.png'。
|
||||
- 修复“只发送文本而未发送真实图片”的问题,最终要求按示例把图片作为 content list 的一部分发送。
|
||||
|
||||
10. 文件编辑工具创建文件
|
||||
|
||||
- 编辑文件时自动创建不存在的文件与目录,不再报错 ENOENT。
|
||||
|
||||
11. 提示信息空行规范
|
||||
|
||||
- 所有提示信息(如“已创建新对话/权限切换/模型切换/思考模式切换”)上下各空一行。
|
||||
- Banner 与 prompt 之间需保持空行。
|
||||
|
||||
12. 思考完成标记
|
||||
|
||||
- 思考完成行使用 ∙ 而不是 ○。
|
||||
- ∙ 行与思考内容整体左移一个空格以对齐工具调用的 • 前缀。
|
||||
|
||||
13. /system.txt 增加模型变量
|
||||
|
||||
- system prompt 里加入当前使用模型(用请求模型名称)。
|
||||
|
||||
14. 取消时错误显示
|
||||
|
||||
- 在思考时按 Esc,不能重复显示思考内容;只显示“停止思考 + 已取消本次响应”。
|
||||
|
||||
15. 运行指令取消空行
|
||||
|
||||
- 工具取消输出行与后续 prompt 之间需空一行。
|
||||
4094
edited_records.txt
Normal file
4094
edited_records.txt
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,9 @@
|
||||
你是 easyagent,一个极简终端智能体。
|
||||
你是 EasyAgent,一个极简终端智能体。
|
||||
目标:帮助用户完成开发任务,优先高信息密度输出。
|
||||
输出限制:禁止使用 Markdown(md)格式,内容无法渲染,必须使用纯文字格式输出。
|
||||
|
||||
- 当前时间:{current_time}
|
||||
- 当前模型:{model_id}
|
||||
- 工作区路径:{path}
|
||||
- 系统信息:{system}
|
||||
- 终端类型:{terminal}
|
||||
|
||||
BIN
src/.DS_Store
vendored
BIN
src/.DS_Store
vendored
Binary file not shown.
@ -144,7 +144,7 @@ async function handleCommand(input, ctx) {
|
||||
value: 'full_access',
|
||||
},
|
||||
];
|
||||
const selected = await runSelect({ rl, message: 'Update Permissions', choices, pageSize: 6 });
|
||||
const selected = await runSelect({ rl, message: '', choices, pageSize: 6 });
|
||||
if (selected) {
|
||||
state.allowMode = selected;
|
||||
console.log(`运行模式已切换为: ${state.allowMode}`);
|
||||
@ -157,19 +157,24 @@ async function handleCommand(input, ctx) {
|
||||
const modelChoices = [
|
||||
{ name: `1. Kimi${state.modelKey === 'kimi-k2.5' ? ' (current)' : ''}`, value: 'kimi-k2.5' },
|
||||
];
|
||||
const model = await runSelect({ rl, message: 'Update Model', choices: modelChoices, pageSize: 6 });
|
||||
const model = await runSelect({ rl, message: '', choices: modelChoices, pageSize: 6 });
|
||||
if (!model) return { exit: false };
|
||||
state.modelKey = model;
|
||||
state.modelId = config.model_id || 'kimi-k2.5';
|
||||
console.log('');
|
||||
console.log(`模型已切换为: ${state.modelKey}`);
|
||||
console.log('');
|
||||
|
||||
const thinkingChoices = [
|
||||
{ name: `1. Fast${!state.thinkingMode ? ' (current)' : ''}`, value: 'fast' },
|
||||
{ name: `2. Thinking${state.thinkingMode ? ' (current)' : ''}`, value: 'thinking' },
|
||||
];
|
||||
const mode = await runSelect({ rl, message: 'Update Thinking Mode', choices: thinkingChoices, pageSize: 6 });
|
||||
const mode = await runSelect({ rl, message: '', choices: thinkingChoices, pageSize: 6 });
|
||||
if (mode) {
|
||||
state.thinkingMode = mode === 'thinking';
|
||||
console.log(`模型已切换为: ${state.modelKey} | 思考模式: ${mode}`);
|
||||
console.log('');
|
||||
console.log(`思考模式: ${mode}`);
|
||||
console.log('');
|
||||
persist();
|
||||
}
|
||||
return { exit: false };
|
||||
|
||||
@ -20,6 +20,7 @@ const { createConversation, updateConversation } = require('../storage/conversat
|
||||
const { applyUsage, normalizeTokenUsage } = require('../utils/token_usage');
|
||||
const { gray, cyan, green } = 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);
|
||||
@ -46,8 +47,11 @@ renderBanner({
|
||||
workspace: WORKSPACE,
|
||||
conversationId: state.conversation?.id,
|
||||
});
|
||||
console.log('');
|
||||
console.log('');
|
||||
|
||||
let rl = null;
|
||||
let statusBar = null;
|
||||
let commandMenuActive = false;
|
||||
let menuSearchTerm = '';
|
||||
let menuLastSearchTerm = '';
|
||||
@ -60,6 +64,12 @@ 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 === '') {
|
||||
@ -119,7 +129,7 @@ process.stdin.on('keypress', (str, key) => {
|
||||
readline.clearLine(process.stdout, 0);
|
||||
readline.cursorTo(process.stdout, 0);
|
||||
}
|
||||
rl.prompt(true);
|
||||
promptWithStatus(true);
|
||||
if (menuInjectedCommand) {
|
||||
const injected = menuInjectedCommand;
|
||||
menuInjectedCommand = null;
|
||||
@ -143,7 +153,7 @@ function initReadline() {
|
||||
|
||||
process.stdin.resume();
|
||||
rl.setPrompt(PROMPT);
|
||||
rl.prompt();
|
||||
promptWithStatus();
|
||||
|
||||
rl.on('line', async (line) => {
|
||||
if (rl) {
|
||||
@ -173,14 +183,14 @@ function initReadline() {
|
||||
menuLastSearchTerm = '';
|
||||
}
|
||||
if (!input) {
|
||||
rl.prompt();
|
||||
promptWithStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.startsWith('/')) {
|
||||
const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE });
|
||||
if (result && result.exit) return;
|
||||
rl.prompt();
|
||||
promptWithStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -190,10 +200,11 @@ function initReadline() {
|
||||
state.messages.push({ role: 'user', content: line });
|
||||
persistConversation();
|
||||
await runAssistantLoop();
|
||||
rl.prompt();
|
||||
promptWithStatus();
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
if (statusBar) statusBar.destroy();
|
||||
process.stdout.write('\n');
|
||||
process.exit(0);
|
||||
});
|
||||
@ -224,6 +235,19 @@ function persistConversation() {
|
||||
});
|
||||
}
|
||||
|
||||
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 }];
|
||||
@ -256,6 +280,7 @@ async function runAssistantLoop() {
|
||||
let firstLoop = true;
|
||||
const hideCursor = () => process.stdout.write('\x1b[?25l');
|
||||
const showCursor = () => process.stdout.write('\x1b[?25h');
|
||||
if (statusBar) statusBar.setMode('running');
|
||||
while (continueLoop) {
|
||||
hideCursor();
|
||||
const spinner = new Spinner(' ', firstLoop ? 1 : 0);
|
||||
@ -418,4 +443,5 @@ async function runAssistantLoop() {
|
||||
continueLoop = false;
|
||||
}
|
||||
showCursor();
|
||||
if (statusBar) statusBar.setMode('input');
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ function buildSystemPrompt(basePrompt, opts) {
|
||||
const systemName = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux';
|
||||
let prompt = basePrompt;
|
||||
const allowModeValue = opts.allowMode === 'read_only'
|
||||
? `${opts.allowMode}\n 已禁用 edit_file、run_command。若用户要求使用,请告知当前无权限需要请求切换权限。`
|
||||
? `${opts.allowMode}\n 已禁用 edit_file、run_command。若用户要求使用,请告知当前无权限需要用户输入 /allow 切换权限。`
|
||||
: opts.allowMode;
|
||||
const replacements = {
|
||||
current_time: localIso,
|
||||
@ -48,6 +48,7 @@ function buildSystemPrompt(basePrompt, opts) {
|
||||
allow_mode: allowModeValue,
|
||||
permissions: allowModeValue,
|
||||
git: getGitInfo(opts.workspace),
|
||||
model_id: opts.modelId || '',
|
||||
};
|
||||
for (const [key, value] of Object.entries(replacements)) {
|
||||
const token = `{${key}}`;
|
||||
|
||||
@ -7,7 +7,7 @@ function createState(config, workspace) {
|
||||
modelKey: config.default_model_key || 'kimi-k2.5',
|
||||
modelId: config.model_id || 'kimi-k2.5',
|
||||
thinkingMode: true,
|
||||
tokenUsage: 0,
|
||||
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
||||
conversation: null,
|
||||
messages: [],
|
||||
};
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
const { getModelProfile } = require('./model_profiles');
|
||||
|
||||
async function* streamChat({ config, messages, tools, thinkingMode }) {
|
||||
async function* streamChat({ config, messages, tools, thinkingMode, abortSignal }) {
|
||||
const profile = getModelProfile(config);
|
||||
const url = `${profile.base_url}/chat/completions`;
|
||||
const headers = {
|
||||
@ -31,8 +31,14 @@ async function* streamChat({ config, messages, tools, thinkingMode }) {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: abortSignal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (abortSignal && abortSignal.aborted) {
|
||||
const error = new Error('请求已取消');
|
||||
error.code = 'aborted';
|
||||
throw error;
|
||||
}
|
||||
const cause = err && err.cause ? ` (${err.cause.code || err.cause.message || err.cause})` : '';
|
||||
throw new Error(`请求失败: ${err.message || err}${cause}`);
|
||||
}
|
||||
@ -47,6 +53,11 @@ async function* streamChat({ config, messages, tools, thinkingMode }) {
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
if (abortSignal && abortSignal.aborted) {
|
||||
const error = new Error('请求已取消');
|
||||
error.code = 'aborted';
|
||||
throw error;
|
||||
}
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
@ -4,6 +4,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { toISO } = require('../utils/time');
|
||||
const { normalizeTokenUsage } = require('../utils/token_usage');
|
||||
|
||||
function getWorkspaceStore(workspace) {
|
||||
const base = path.join(workspace, '.easyagent');
|
||||
@ -33,9 +34,18 @@ function saveIndex(indexFile, index) {
|
||||
|
||||
function extractTitle(messages) {
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'user' && typeof msg.content === 'string' && msg.content.trim()) {
|
||||
const content = msg.content.trim();
|
||||
return content.length > 50 ? `${content.slice(0, 50)}...` : content;
|
||||
if (msg.role === 'user') {
|
||||
let content = '';
|
||||
if (typeof msg.content === 'string') {
|
||||
content = msg.content.trim();
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
content = msg.content
|
||||
.filter((part) => part && part.type === 'text' && typeof part.text === 'string')
|
||||
.map((part) => part.text)
|
||||
.join(' ')
|
||||
.trim();
|
||||
}
|
||||
if (content) return content.length > 50 ? `${content.slice(0, 50)}...` : content;
|
||||
}
|
||||
}
|
||||
return '新对话';
|
||||
@ -82,7 +92,7 @@ function createConversation(workspace, metadata = {}) {
|
||||
model_id: metadata.model_id || 'kimi-k2.5',
|
||||
thinking_mode: !!metadata.thinking_mode,
|
||||
allow_mode: metadata.allow_mode || 'full_access',
|
||||
token_usage: metadata.token_usage || 0,
|
||||
token_usage: normalizeTokenUsage(metadata.token_usage),
|
||||
cwd: metadata.cwd || '',
|
||||
},
|
||||
messages: [],
|
||||
@ -123,6 +133,9 @@ function updateConversation(workspace, conversation, messages, metadataUpdates =
|
||||
updated.metadata = {
|
||||
...conversation.metadata,
|
||||
...metadataUpdates,
|
||||
token_usage: metadataUpdates && Object.prototype.hasOwnProperty.call(metadataUpdates, 'token_usage')
|
||||
? normalizeTokenUsage(metadataUpdates.token_usage)
|
||||
: normalizeTokenUsage(conversation.metadata?.token_usage),
|
||||
};
|
||||
saveConversation(workspace, updated);
|
||||
return updated;
|
||||
|
||||
@ -138,7 +138,7 @@ function buildToolContent(result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function executeTool({ workspace, config, allowMode, toolCall }) {
|
||||
async function executeTool({ workspace, config, allowMode, toolCall, abortSignal }) {
|
||||
const name = toolCall.function.name;
|
||||
let args = {};
|
||||
try {
|
||||
@ -153,7 +153,7 @@ async function executeTool({ workspace, config, allowMode, toolCall }) {
|
||||
}
|
||||
|
||||
if (allowMode === 'read_only' && (name === 'edit_file' || name === 'run_command')) {
|
||||
const note = '当前为只读模式,已禁用 edit_file、run_command。若需使用,请先请求切换权限。';
|
||||
const note = '当前为只读模式,已禁用 edit_file、run_command。若需使用,请告知当前无权限需要用户输入 /allow 切换权限。';
|
||||
return {
|
||||
success: false,
|
||||
tool: name,
|
||||
@ -162,11 +162,21 @@ async function executeTool({ workspace, config, allowMode, toolCall }) {
|
||||
};
|
||||
}
|
||||
|
||||
if (abortSignal && abortSignal.aborted) {
|
||||
return {
|
||||
success: false,
|
||||
tool: name,
|
||||
error: '任务被用户取消',
|
||||
formatted: '任务被用户取消',
|
||||
raw: { success: false, error: '任务被用户取消', cancelled: true },
|
||||
};
|
||||
}
|
||||
|
||||
let raw;
|
||||
if (name === 'read_file') raw = readFileTool(workspace, args);
|
||||
else if (name === 'edit_file') raw = editFileTool(workspace, args);
|
||||
else if (name === 'run_command') raw = await runCommandTool(workspace, args);
|
||||
else if (name === 'web_search') raw = await webSearchTool(config, args);
|
||||
else if (name === 'run_command') raw = await runCommandTool(workspace, args, abortSignal);
|
||||
else if (name === 'web_search') raw = await webSearchTool(config, args, abortSignal);
|
||||
else if (name === 'extract_webpage') {
|
||||
if (!args.mode) args.mode = 'read';
|
||||
const targetPath = args.target_path ? (path.isAbsolute(args.target_path) ? args.target_path : path.join(workspace, args.target_path)) : null;
|
||||
@ -174,10 +184,10 @@ async function executeTool({ workspace, config, allowMode, toolCall }) {
|
||||
if (!targetPath || !targetPath.toLowerCase().endsWith('.md')) {
|
||||
raw = { success: false, error: 'target_path 必须是 .md 文件' };
|
||||
} else {
|
||||
raw = await extractWebpageTool(config, args, targetPath);
|
||||
raw = await extractWebpageTool(config, args, targetPath, abortSignal);
|
||||
}
|
||||
} else {
|
||||
raw = await extractWebpageTool(config, args, targetPath);
|
||||
raw = await extractWebpageTool(config, args, targetPath, abortSignal);
|
||||
}
|
||||
} else if (name === 'search_workspace') raw = await searchWorkspaceTool(workspace, args);
|
||||
else if (name === 'read_mediafile') raw = readMediafileTool(workspace, args);
|
||||
|
||||
@ -45,19 +45,26 @@ function editFileTool(workspace, args) {
|
||||
const oldString = args.old_string ?? '';
|
||||
const newString = args.new_string ?? '';
|
||||
try {
|
||||
let creating = false;
|
||||
if (!fs.existsSync(target)) {
|
||||
return { success: false, error: '文件不存在' };
|
||||
creating = true;
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, '', 'utf8');
|
||||
}
|
||||
const stat = fs.statSync(target);
|
||||
if (!stat.isFile()) {
|
||||
return { success: false, error: '目标不是文件' };
|
||||
}
|
||||
const original = fs.readFileSync(target, 'utf8');
|
||||
if (!original.includes(oldString)) {
|
||||
if (!creating && oldString === '') {
|
||||
return { success: false, error: 'old_string 不能为空,请从 read_file 内容中精确复制' };
|
||||
}
|
||||
if (!creating && !original.includes(oldString)) {
|
||||
return { success: false, error: 'old_string 未匹配到内容' };
|
||||
}
|
||||
const updated = original.split(oldString).join(newString);
|
||||
const replacements = original.split(oldString).length - 1;
|
||||
const updated = creating ? newString : original.split(oldString).join(newString);
|
||||
let replacements = creating ? 0 : original.split(oldString).length - 1;
|
||||
if (creating && newString) replacements = 1;
|
||||
fs.writeFileSync(target, updated, 'utf8');
|
||||
const diff = diffSummary(original, updated);
|
||||
return {
|
||||
|
||||
@ -9,14 +9,21 @@ function resolvePath(workspace, p) {
|
||||
return path.join(workspace, p);
|
||||
}
|
||||
|
||||
function runCommandTool(workspace, args) {
|
||||
function runCommandTool(workspace, args, abortSignal) {
|
||||
return new Promise((resolve) => {
|
||||
const cmd = args.command;
|
||||
const timeoutSec = Number(args.timeout || 0);
|
||||
const cwd = resolvePath(workspace, args.working_dir || '.');
|
||||
if (!cmd) return resolve({ success: false, error: 'command 不能为空' });
|
||||
if (abortSignal && abortSignal.aborted) {
|
||||
return resolve({ success: false, error: '任务被用户取消', cancelled: true });
|
||||
}
|
||||
const timeoutMs = Math.max(0, timeoutSec) * 1000;
|
||||
exec(cmd, { cwd, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, shell: true }, (err, stdout, stderr) => {
|
||||
let finished = false;
|
||||
const child = exec(cmd, { cwd, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, shell: true }, (err, stdout, stderr) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort);
|
||||
const output = [stdout, stderr].filter(Boolean).join('');
|
||||
if (err) {
|
||||
const isTimeout = err.killed && err.signal === 'SIGTERM';
|
||||
@ -31,6 +38,15 @@ function runCommandTool(workspace, args) {
|
||||
}
|
||||
resolve({ success: true, status: 'ok', output });
|
||||
});
|
||||
const onAbort = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch (_) {}
|
||||
return resolve({ success: false, error: '任务被用户取消', cancelled: true });
|
||||
};
|
||||
if (abortSignal) abortSignal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
async function webSearchTool(config, args) {
|
||||
async function webSearchTool(config, args, abortSignal) {
|
||||
if (abortSignal && abortSignal.aborted) {
|
||||
return { success: false, error: '任务被用户取消', cancelled: true };
|
||||
}
|
||||
const body = {
|
||||
api_key: config.tavily_api_key,
|
||||
query: args.query,
|
||||
@ -18,6 +21,7 @@ async function webSearchTool(config, args) {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: abortSignal,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
@ -25,11 +29,17 @@ async function webSearchTool(config, args) {
|
||||
}
|
||||
return { success: true, ...json, query: args.query, searched_at: new Date().toISOString() };
|
||||
} catch (err) {
|
||||
if (err && err.name === 'AbortError') {
|
||||
return { success: false, error: '任务被用户取消', cancelled: true };
|
||||
}
|
||||
return { success: false, error: err.message || String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function extractWebpageTool(config, args, savePath) {
|
||||
async function extractWebpageTool(config, args, savePath, abortSignal) {
|
||||
if (abortSignal && abortSignal.aborted) {
|
||||
return { success: false, error: '任务被用户取消', cancelled: true };
|
||||
}
|
||||
const body = {
|
||||
api_key: config.tavily_api_key,
|
||||
urls: [args.url],
|
||||
@ -40,6 +50,7 @@ async function extractWebpageTool(config, args, savePath) {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: abortSignal,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
@ -53,6 +64,9 @@ async function extractWebpageTool(config, args, savePath) {
|
||||
}
|
||||
return { success: true, content, url: args.url };
|
||||
} catch (err) {
|
||||
if (err && err.name === 'AbortError') {
|
||||
return { success: false, error: '任务被用户取消', cancelled: true };
|
||||
}
|
||||
return { success: false, error: err.message || String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,14 @@ const COMMAND_CHOICES = [
|
||||
{ value: '/exit', desc: '退出程序' },
|
||||
];
|
||||
|
||||
function hasCommandMatch(term) {
|
||||
if (!term) return false;
|
||||
const t = term.toLowerCase();
|
||||
return COMMAND_CHOICES.some(
|
||||
(c) => c.value.toLowerCase().includes(t) || c.desc.toLowerCase().includes(t)
|
||||
);
|
||||
}
|
||||
|
||||
async function openCommandMenu(options) {
|
||||
const { rl, prompt, pageSize, colorEnabled, resetAnsi, onInput, abortSignal } = options;
|
||||
let latestInput = '';
|
||||
@ -37,6 +45,7 @@ async function openCommandMenu(options) {
|
||||
message: (text) => text,
|
||||
searchTerm: (text) => `/${(text || '').replace(/^\/+/, '')}`,
|
||||
highlight: (text) => (colorEnabled ? `\x1b[94m${text}${resetAnsi}` : text),
|
||||
error: () => '无效的指令',
|
||||
keysHelpTip: () => '',
|
||||
description: (text) => text,
|
||||
answer: () => '',
|
||||
@ -77,4 +86,4 @@ async function openCommandMenu(options) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { openCommandMenu };
|
||||
module.exports = { openCommandMenu, hasCommandMatch };
|
||||
|
||||
103
src/ui/status_bar.js
Normal file
103
src/ui/status_bar.js
Normal file
@ -0,0 +1,103 @@
|
||||
'use strict';
|
||||
|
||||
const readline = require('readline');
|
||||
const { visibleWidth, truncatePlain } = require('../utils/text_width');
|
||||
|
||||
function truncateNoEllipsis(text, maxCols) {
|
||||
const out = truncatePlain(text, maxCols);
|
||||
if (out.endsWith('...') && visibleWidth(text) > maxCols) {
|
||||
return out.slice(0, -3);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function createStatusBar({ getTokens, maxTokens }) {
|
||||
let mode = 'input';
|
||||
let enabled = true;
|
||||
let scrollApplied = false;
|
||||
let lastRows = null;
|
||||
|
||||
const render = () => {
|
||||
if (!enabled || !process.stdout.isTTY) return;
|
||||
const cols = process.stdout.columns || 80;
|
||||
const rows = process.stdout.rows || 24;
|
||||
if (rows < 3) return;
|
||||
const left = mode === 'running' ? '按下Esc停止' : '输入/查看所有指令';
|
||||
const total = typeof getTokens === 'function' ? getTokens() : 0;
|
||||
const formatCount = (value) => {
|
||||
const num = Number(value) || 0;
|
||||
if (num < 1000) return String(num);
|
||||
const k = Math.round((num / 1000) * 10) / 10;
|
||||
return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`;
|
||||
};
|
||||
const right = `当前上下文 ${formatCount(total)}/${maxTokens}`;
|
||||
const rightTail = ` ${formatCount(total)}/${maxTokens}`;
|
||||
const leftWidth = visibleWidth(left);
|
||||
const rightWidth = visibleWidth(right);
|
||||
const usableCols = Math.max(1, cols - 1);
|
||||
let safeLine = '';
|
||||
|
||||
const availableLeft = usableCols - rightWidth - 1;
|
||||
if (availableLeft >= 1) {
|
||||
const displayLeft = truncateNoEllipsis(left, availableLeft);
|
||||
const gap = usableCols - visibleWidth(displayLeft) - rightWidth;
|
||||
safeLine = `${displayLeft}${' '.repeat(Math.max(0, gap))}${right}`;
|
||||
} else {
|
||||
safeLine = truncateNoEllipsis(right, usableCols);
|
||||
}
|
||||
|
||||
process.stdout.write('\x1b7'); // save cursor
|
||||
if (!scrollApplied || lastRows !== rows) {
|
||||
process.stdout.write('\x1b[r'); // reset scroll region
|
||||
process.stdout.write(`\x1b[1;${rows - 2}r`); // reserve last 2 lines
|
||||
scrollApplied = true;
|
||||
lastRows = rows;
|
||||
}
|
||||
// keep one blank spacer line above status line
|
||||
readline.cursorTo(process.stdout, 0, rows - 2);
|
||||
readline.clearLine(process.stdout, 0);
|
||||
// render status line on last row
|
||||
readline.cursorTo(process.stdout, 0, rows - 1);
|
||||
readline.clearLine(process.stdout, 0);
|
||||
process.stdout.write('\x1b[?7l'); // disable auto-wrap
|
||||
process.stdout.write(safeLine);
|
||||
process.stdout.write('\x1b[?7h'); // re-enable auto-wrap
|
||||
process.stdout.write('\x1b8'); // restore cursor
|
||||
};
|
||||
|
||||
const setMode = (nextMode) => {
|
||||
if (mode === nextMode) return;
|
||||
mode = nextMode;
|
||||
render();
|
||||
};
|
||||
|
||||
const setEnabled = (nextEnabled) => {
|
||||
enabled = nextEnabled;
|
||||
if (enabled) render();
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
if (!process.stdout.isTTY) return;
|
||||
if (scrollApplied) {
|
||||
process.stdout.write('\x1b[r'); // reset scroll region
|
||||
scrollApplied = false;
|
||||
}
|
||||
const rows = process.stdout.rows || 24;
|
||||
if (rows >= 2) {
|
||||
readline.cursorTo(process.stdout, 0, rows - 2);
|
||||
readline.clearLine(process.stdout, 0);
|
||||
}
|
||||
if (rows >= 1) {
|
||||
readline.cursorTo(process.stdout, 0, rows - 1);
|
||||
readline.clearLine(process.stdout, 0);
|
||||
}
|
||||
};
|
||||
|
||||
if (process.stdout.isTTY) {
|
||||
process.stdout.on('resize', () => render());
|
||||
}
|
||||
|
||||
return { render, setMode, setEnabled, destroy };
|
||||
}
|
||||
|
||||
module.exports = { createStatusBar };
|
||||
@ -130,6 +130,7 @@ function startToolDisplay(line) {
|
||||
function formatResultLines(name, args, raw) {
|
||||
if (!raw || raw.success === false) {
|
||||
const msg = raw && raw.error ? raw.error : '执行失败';
|
||||
if (msg === '任务被用户取消') return [red(msg)];
|
||||
return [red(`失败: ${msg}`)];
|
||||
}
|
||||
if (name === 'run_command') {
|
||||
@ -216,6 +217,7 @@ function printResultLines(lines) {
|
||||
process.stdout.write(` ${line}\n`);
|
||||
}
|
||||
});
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
|
||||
module.exports = { buildStartLine, buildFinalLine, startToolDisplay, formatResultLines, printResultLines };
|
||||
|
||||
28
src/utils/token_usage.js
Normal file
28
src/utils/token_usage.js
Normal file
@ -0,0 +1,28 @@
|
||||
'use strict';
|
||||
|
||||
function normalizeTokenUsage(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
return {
|
||||
prompt: Number(value.prompt) || 0,
|
||||
completion: Number(value.completion) || 0,
|
||||
total: Number(value.total) || 0,
|
||||
};
|
||||
}
|
||||
const num = Number(value);
|
||||
return {
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
total: Number.isFinite(num) ? num : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function applyUsage(base, usage) {
|
||||
const normalized = normalizeTokenUsage(base);
|
||||
if (!usage) return normalized;
|
||||
if (Number.isFinite(usage.prompt_tokens)) normalized.prompt += usage.prompt_tokens;
|
||||
if (Number.isFinite(usage.completion_tokens)) normalized.completion += usage.completion_tokens;
|
||||
if (Number.isFinite(usage.total_tokens)) normalized.total = usage.total_tokens;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
module.exports = { normalizeTokenUsage, applyUsage };
|
||||
Loading…
Reference in New Issue
Block a user