feat: 朗读功能、弹窗优化、Node.js 打包、Prompt 更新

- Assist/Agent 模式新增朗读开关 + NSSpeechSynthesizer 语音合成
- 朗读完成后弹窗延迟5秒带动画关闭(不再写死15/20秒)
- 打字模式下不自动关闭弹窗
- 二次输入时取消旧朗读/旧延迟关闭,避免竞争问题
- Agent 进度计时器第二次输入时正确归零
- DMG 打包自动清空 models.json API Key
- Node.js 运行时打包进 Agent/bin/,用户无需安装
- Agent system prompt 更新为 VoiceInput 身份 + 语音容错说明
- AGENTS.md 全面更新,补全 Agent 模式文档
This commit is contained in:
JOJO 2026-04-29 18:22:14 +08:00
parent 94b05c28b3
commit 76741877a8
40 changed files with 4337 additions and 56 deletions

4
.gitignore vendored
View File

@ -1,4 +1,6 @@
.build/
.swiftpm/
*.app
.DS_Store
.DS_Store
Agent/bin/node
agent_debug.log

170
AGENTS.md
View File

@ -2,7 +2,14 @@
## 项目概述
VoiceInput 是一款 **macOS 菜单栏语音输入应用**。按住 Fn / Globe 键录音,松开后通过剪贴板 + Cmd+V 自动将识别文本注入光标位置。支持 Apple 原生语音识别实时转录、LLMDeepSeek纠错优化、多语言切换以及双击 Control 切换 Input / Assist 模式。
VoiceInput 是一款 **macOS 菜单栏语音输入应用**。按住 Fn / Globe 键录音,松开后通过剪贴板 + Cmd+V 自动将识别文本注入光标位置。支持 Apple 原生语音识别实时转录、LLM 纠错优化、AI 对话、Agent 智能体调用、多语言切换。
**三种模式**(双击 Control 循环切换):
| 模式 | 图标 | 功能 |
|------|------|------|
| Input语音输入 | 🎤 | 语音转文字 + LLM 纠错 → 注入光标 |
| AssistAI 助手) | 💬 | 语音提问 → AI 回复(注入光标或刘海弹窗) |
| Agent智能体 | 🤖 | 语音/文字 → 多轮对话 + 工具调用 → 刘海弹窗 |
- **最低系统**macOS 14+
- **语言**Swift 5.9
@ -20,7 +27,7 @@ VoiceInput 是一款 **macOS 菜单栏语音输入应用**。按住 Fn / Globe
| `make clean` | 清理构建产物 |
| `make install` | Release 构建 + 安装到 `/Applications` |
| `make sign` | Ad-hoc 签名Release 后用) |
| `make dmg` | 签名 + 创建 DMG 分发包 |
| `make dmg` | 签名 + 创建 DMG 分发包**自动清空 API Key** |
构建产物路径:`.build/release/VoiceInput.app`Release`.build/debug/VoiceInput.app`Debug
@ -30,27 +37,51 @@ VoiceInput 是一款 **macOS 菜单栏语音输入应用**。按住 Fn / Globe
```
Sources/
├── main.swift # 入口:创建 NSApplication(.accessory) + AppDelegate
├── main.swift # 入口:创建 NSApplication(.accessory) + AppDelegate
├── App/
│ ├── AppDelegate.swift # 主控制器:权限引导、录音/停止、模式切换、LLM 调度
│ └── MenuBarController.swift # 菜单栏图标与菜单语言、LLM 配置、模式切换
│ ├── AppDelegate.swift # 主控制器权限、录音、模式调度、LLM、朗读
│ └── MenuBarController.swift # 菜单栏图标与菜单(语言/模式/LLM配置
├── Core/
│ ├── KeyMonitor.swift # CGEvent 全局热键Fn 键长按录音、Control 键双击切换模式
│ ├── SpeechRecognizer.swift # SFSpeechRecognizer 封装:流式识别、语言切换
│ ├── AudioLevelMonitor.swift # AVAudioEngine 音频电平RMS 音量驱动波形
│ ├── TextInjector.swift # 剪贴板 + Cmd+V 注入;自动切换 CJK 输入法
│ ├── InputSourceManager.swift # 输入法检测与切换(中文/英文等)
│ └── LLMRefiner.swift # DeepSeek API 调用:语音纠错(refine) / AI 对话(chat)
│ ├── KeyMonitor.swift # CGEvent 全局热键Fn 长按录音、Control 双击切换
│ ├── SpeechRecognizer.swift # SFSpeechRecognizer 封装:流式识别、语言切换
│ ├── AudioLevelMonitor.swift # AVAudioEngine 音频电平RMS 驱动波形
│ ├── TextInjector.swift # 剪贴板 + Cmd+V 注入;自动切换 CJK 输入法
│ ├── InputSourceManager.swift # 输入法检测与切换(中文/英文等)
│ ├── LLMRefiner.swift # LLM API语音纠错(refine) / AI 对话(chat)
│ ├── AgentBridge.swift # Agent CLI 进程桥接Node.js stdin/stdout JSON 通信
│ └── NotchDisplayManager.swift # AI 回复刘海弹窗管理Assist 模式)
├── UI/
│ ├── RecordingPanel.swift # 胶囊浮动窗:无边框、底部居中、弹簧动画
│ ├── WaveformView.swift # 5 条球形波动画RMS 驱动实时跳动
│ └── SettingsWindow.swift # LLM 设置面板API Key / Base URL / Model / Test
│ ├── RecordingPanel.swift # 胶囊浮动窗:无边框、底部居中、弹簧动画
│ ├── WaveformView.swift # 5 条球形波动画RMS 驱动实时跳动
│ ├── SettingsWindow.swift # LLM 设置面板Input/Assist/Agent 三合一)
│ ├── AgentNotchView.swift # Agent 刘海弹窗视图SwiftUI
│ ├── AgentProgressTracker.swift # Agent 任务进度追踪(工具调用/流式输出)
│ ├── AgentModelEditor.swift # Agent 模型添加/编辑弹窗
│ ├── AppKitTextField.swift # 支持快捷键的 NSTextField 包装
│ ├── KeyEquivalentSecureTextField.swift # 支持快捷键的安全文本框
│ └── KeyEquivalentPanel.swift # 支持快捷键的 NSPanel 基类
├── Utils/
│ └── Config.swift # UserDefaults 持久化语言、LLM 配置、触发模式
│ └── Config.swift # UserDefaults 持久化语言、LLM、触发模式、朗读等
└── Resources/
├── AppIcon.icns # 应用图标
├── AppIcon-macOS.png # 图标源文件
└── Info.plist # Bundle 配置
├── AppIcon.icns # 应用图标
├── AppIcon-macOS.png # 图标源文件
└── Info.plist # Bundle 配置
Agent/
├── models.json # Agent 模型配置API URL/Key/Model
├── models.example.json # 模型配置模板(分发包用)
├── prompts/
│ └── system.txt # Agent system prompt含 {current_time} 等占位符)
├── src/
│ ├── bridge.js # Node.js 桥接入口stdin/stdout JSON 通信)
│ ├── config.js # 配置加载
│ ├── cli/ # CLI 命令处理
│ ├── core/ # 上下文构建、状态管理
│ ├── model/ # LLM 客户端、模型配置
│ ├── storage/ # 对话历史存储
│ ├── tools/ # 工具实现(文件读写、命令执行、搜索等)
│ └── ui/ # CLI 终端 UIAgent 内部使用)
└── node_modules/ # npm 依赖
```
## 架构模式
@ -70,7 +101,7 @@ Sources/
`beginPermissionFlow()` 使用 `CGPreflightListenEventAccess()``CGEvent.tapCreate` 做权限预检。未授权时弹出对话框 + 轮询 Timer。
### 录音流程
### 录音与调度流程
```
Fn Down长按 > 200ms
@ -81,27 +112,75 @@ Fn Down长按 > 200ms
Fn Up
→ AppDelegate.stopRecording()
→ 如果启用 LLM 纠错 → LLMRefiner.refine()
→ TextInjector.inject() // 注入文本
→ RecordingPanel.hideAnimated()
→ 根据 triggerMode 分发:
├── input → handleInputMode() → LLMRefiner.refine() → TextInjector.inject()
├── assist → handleAssistMode() → LLMRefiner.chat() → inject 或 Notch弹窗
└── agent → handleAgentMode() → AgentBridge 子进程 → AgentNotch 弹窗
```
### Fn 键交互(长按录音)
### Fn 键交互
`KeyMonitor` 通过 `CGEvent.tapCreate(.cghidEventTap)` 监听 `flagsChanged` 事件。
- Fn 键 mask `0x00800000`**长按 > 200ms**触发录音(`onFnDown` → `onFnUp`
- Control 键 mask `0x00040000`**双击(两次按下间隔 < 400ms**切换模式`onControlDoubleClick`
- Fn 短按(< 200ms无操作留作扩展
- Fn 键 mask `0x00800000`**长按 > 200ms**:录音(`onFnDown` → `onFnUp`
- Control 键 mask `0x00040000`**双击(间隔 < 400ms**切换模式`onControlDoubleClick` Input Assist Agent 循环
- Fn 短按(< 200ms无操作
### LLM 集成
### 三种模式详解
`LLMRefiner` 通过 HTTP 调用 DeepSeek Chat Completions API。两种模式
#### Input 模式(语音输入)
- 流程:语音识别 → LLM 纠错(可选)→ 注入光标
- LLM 配置:`Config.shared.inputLLM`(菜单栏 `🎤 Input LLM` → Settings
- **Input 模式**`refine`):纠错优化,修正同音字/拼写/术语
- **Assist 模式**`chat`AI 对话,结果注入光标
#### Assist 模式AI 助手)
- 流程:语音识别 → LLM 对话 → 结果展示
- 输出方式(`Config.shared.assistOutputMode`
- `inject`:直接注入光标
- `notch`:刘海弹窗展示(`NotchDisplayManager`
- 朗读开关(`Config.shared.assistSpeakEnabled`):弹窗模式下朗读 AI 回复
- LLM 配置:`Config.shared.assistLLM`(菜单栏 `💬 Assist LLM` → Settings
配置通过 `Config.shared.inputLLM` / `Config.shared.assistLLM` 独立管理。
#### Agent 模式(智能体)
- 流程:语音/文字输入 → Node.js CLI 子进程 → 工具调用 → 流式输出
- 通过 `AgentBridge``Agent/src/bridge.js` 通信stdin/stdout JSON 行协议)
- 结果在 `AgentNotchView`(刘海弹窗)展示,支持复制/停止/新对话
- 朗读开关(`Config.shared.agentSpeakEnabled`):任务完成时朗读最终输出
- 配置通过 `Agent/models.json` 管理(菜单栏 `🤖 Agent LLM` → Settings
### Agent JSON 通信协议
`AgentBridge` 通过 `bridge.js` 子进程的 stdin/stdout 通信:
**发送stdin**
```json
{"action":"chat","message":"用户输入","workspace":"/path","allowMode":"full_access","modelKey":"deepseek-v4-flash","thinkingMode":false}
```
**接收stdout逐行 JSON**
| type | 说明 |
|------|------|
| `status` | 状态变更 `{"mode":"thinking"}` |
| `tool_start` | 工具开始 `{"tool":"run_command","display":"ls -la"}` |
| `tool_end` | 工具结束 `{"tool":"run_command","result":["line1","line2"]}` |
| `assistant_chunk` | 流式文本 `{"content":"部分回复..."}` |
| `done` | 任务完成 `{"content":"完整回复","elapsed_ms":1234}` |
| `error` | 错误 `{"message":"错误描述"}` |
### 语音朗读
`AppDelegate` 使用 `NSSpeechSynthesizer`macOS 原生)进行语音合成。朗读行为:
| 模式 | 触发时机 | 弹窗关闭逻辑 |
|------|---------|-------------|
| Assist | AI 回复弹窗展示时(需开启开关 + notch 模式) | 朗读完成 + 5s 带动画关闭 |
| Agent | 任务完成 `.done` 事件时(需开启开关) | 朗读完成 + 5s 带动画关闭 |
朗读开关关闭时弹窗按固定时间关闭Assist 15s / Agent 20s
用户手动停止/关闭弹窗或开始新对话时立即停止朗读。
### DMG 安全
`make dmg` 打包时会自动将 `models.json` 替换为空配置 `{"models":[],"default_model":"","tavily_api_key":""}`,防止 API Key 泄露。本地开发构建不受影响。
## 代码约定
@ -117,21 +196,28 @@ Fn Up
## 关键依赖
所有依赖通过系统框架链接,无第三方包:
### Swift 端
所有依赖通过系统框架链接:
```
Speech → 语音识别
AVFAudio → 音频采集与电平
Carbon → 全局热键(已逐步迁移至 CoreGraphics
CoreGraphics → CGEvent API热键监听、文本注入
AppKit → UI 组件
Foundation → 基础类型与网络
Speech → 语音识别
AVFAudio → 音频采集与电平
Carbon → 全局热键(已逐步迁移至 CoreGraphics
CoreGraphics → CGEvent API热键监听、文本注入
AppKit → UI 组件、NSSpeechSynthesizer语音合成
Foundation → 基础类型与网络
DynamicNotchKit → 刘海弹窗Swift Package纯 SwiftUI
```
Entitlements 文件 `Sources/Resources/VoiceInput.entitlements` 包含
Entitlements 文件 `Sources/Resources/VoiceInput.entitlements`
- `com.apple.security.device.audio-input` — 麦克风
- `com.apple.security.cs.disable-library-validation` — 允许加载系统插件
### Agent 端Node.js
Agent CLI 使用 Node.js入口为 `Agent/src/bridge.js`。Node.js 运行时已打包在 `Agent/bin/node`arm64构建时自动进入 `.app` bundle。用户无需额外安装 Node.js。
## 注意事项
1. **ARC 陷阱**`NSApplication.delegate` 是 weak 引用,`main.swift` 中的 `AppDelegate` 会被 ARC 释放。解决方案:`AppDelegate` 内部使用 `static retainedInstance` 自持 + `main.swift` 使用 `withExtendedLifetime` 双重保险。
@ -142,4 +228,8 @@ Entitlements 文件 `Sources/Resources/VoiceInput.entitlements` 包含:
4. **输入法切换**:注入文本前 `TextInjector` 会通过 `InputSourceManager` 检测并切换到合适的 CJK 输入法,确保粘贴中文时不出现乱码。
5. **DMG 构建**`make dmg` 依赖 `make sign``make release`,需要 `hdiutil`(系统自带)。
5. **DMG 构建**`make dmg` 依赖 `make sign``make release`,需要 `hdiutil`(系统自带)。打包时自动清空 `models.json` 中的 API Key。
6. **Agent prompt 维护**Agent 的 system prompt 在 `Agent/prompts/system.txt`,包含 `{current_time}`、`{model_id}` 等占位符,由 `context.js``buildSystemPrompt()` 在运行时替换。
7. **NSSpeechSynthesizer 弃用警告**macOS 14 标记 `NSSpeechSynthesizer` 为 deprecated建议用 `AVSpeechSynthesizer`),但功能正常。编译时仅有 warning无 error。

15
Agent/models.example.json Normal file
View File

@ -0,0 +1,15 @@
{
"tavily_api_key": "tvly-xxxxxxxxxxxxxxxxxxxx",
"default_model": "your-model-name",
"models": [
{
"url": "https://api.example.com/v1",
"name": "your-model-name",
"apikey": "sk-xxxxxxxxxxxxxxxx",
"modes": "快速,思考",
"multimodal": "图片,视频",
"max_output": 8192,
"max_context": 128000
}
]
}

437
Agent/package-lock.json generated Normal file
View File

@ -0,0 +1,437 @@
{
"name": "easyagent",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "easyagent",
"version": "0.1.0",
"dependencies": {
"@inquirer/search": "^4.1.4",
"@inquirer/select": "^5.1.0",
"diff": "^5.2.0",
"fast-glob": "^3.3.2",
"mime-types": "^2.1.35"
},
"bin": {
"eagent": "bin/eagent"
}
},
"node_modules/@inquirer/ansi": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz",
"integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==",
"license": "MIT",
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
}
},
"node_modules/@inquirer/core": {
"version": "11.1.9",
"resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.9.tgz",
"integrity": "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==",
"license": "MIT",
"dependencies": {
"@inquirer/ansi": "^2.0.5",
"@inquirer/figures": "^2.0.5",
"@inquirer/type": "^4.0.5",
"cli-width": "^4.1.0",
"fast-wrap-ansi": "^0.2.0",
"mute-stream": "^3.0.0",
"signal-exit": "^4.1.0"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/figures": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz",
"integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==",
"license": "MIT",
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
}
},
"node_modules/@inquirer/search": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.8.tgz",
"integrity": "sha512-fGiHKGD6DyPIYUWxoXnQTeXeyYqSOUrasDMABBmMHUalH/LxkuzY0xVRtimXAt1sUeeyYkVuKQx1bebMuN11Kw==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^11.1.9",
"@inquirer/figures": "^2.0.5",
"@inquirer/type": "^4.0.5"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/select": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.4.tgz",
"integrity": "sha512-2kWcGKPMLAXAWRp1AH1SLsQmX+j0QjeljyXMUji9WMZC8nRDO0b7qquIGr6143E7KMLt3VAIGNXzwa/6PXQs4Q==",
"license": "MIT",
"dependencies": {
"@inquirer/ansi": "^2.0.5",
"@inquirer/core": "^11.1.9",
"@inquirer/figures": "^2.0.5",
"@inquirer/type": "^4.0.5"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/type": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz",
"integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==",
"license": "MIT",
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cli-width": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
"integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
"license": "ISC",
"engines": {
"node": ">= 12"
}
},
"node_modules/diff": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
"integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-string-truncated-width": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
"integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
"license": "MIT"
},
"node_modules/fast-string-width": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz",
"integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==",
"license": "MIT",
"dependencies": {
"fast-string-truncated-width": "^3.0.2"
}
},
"node_modules/fast-wrap-ansi": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz",
"integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==",
"license": "MIT",
"dependencies": {
"fast-string-width": "^3.0.2"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mute-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
"integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==",
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
}
}
}

20
Agent/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "easyagent",
"version": "0.1.0",
"private": true,
"description": "easyagent terminal AI",
"bin": {
"eagent": "bin/eagent"
},
"type": "commonjs",
"scripts": {
"start": "node src/cli/index.js"
},
"dependencies": {
"@inquirer/search": "^4.1.4",
"@inquirer/select": "^5.1.0",
"diff": "^5.2.0",
"fast-glob": "^3.3.2",
"mime-types": "^2.1.35"
}
}

13
Agent/prompts/system.txt Normal file
View File

@ -0,0 +1,13 @@
你是 VoiceInput 的 AI 智能助手,通过语音或文字与用户交互。
目标:帮助用户完成开发与日常任务,回答简洁准确。
输出格式:使用纯文字,避免 Markdown 语法。
重要提示:用户的输入来自语音识别,可能存在同音字、转录错误或模糊表达。请结合上下文推测用户真实意图。如果确实无法理解,应向用户确认而非猜测回复。
- 当前时间:{current_time}
- 当前模型:{model_id}
- 工作区路径:{path}
- 系统信息:{system}
- 终端类型:{terminal}
- 权限:{allow_mode}
- Git{git}

363
Agent/src/cli/commands.js Normal file
View File

@ -0,0 +1,363 @@
'use strict';
const { createConversation, loadConversation, listConversations, updateConversation } = require('../storage/conversation_store');
const { formatRelativeTime } = require('../utils/time');
const { maskKey, getModelByKey } = require('../config');
const { runSelect } = require('../ui/select_prompt');
const { runResumeMenu } = require('../ui/resume_menu');
const { buildFinalLine, formatResultLines, printResultLines } = require('../ui/tool_display');
const { renderBox } = require('../ui/banner');
const { createIndentedWriter } = require('../ui/indented_writer');
const { cyan, green } = require('../utils/colors');
const { Spinner } = require('../ui/spinner');
const { normalizeTokenUsage } = require('../utils/token_usage');
function printHelp() {
console.log('/new 创建新对话');
console.log('/resume 加载旧对话');
console.log('/allow 切换运行模式(只读/无限制)');
console.log('/model 切换模型和思考模式');
console.log('/status 查看当前对话状态');
console.log('/compact 压缩当前对话');
console.log('/config 查看当前配置');
console.log('/exit 退出程序');
console.log('/help 显示指令列表');
}
function printNotice(message) {
console.log('');
console.log(message);
console.log('');
}
function applyModelState(state, config, modelKey, preferredThinking) {
const model = getModelByKey(config, modelKey);
if (!model || !model.valid) return false;
state.modelKey = model.key;
state.modelId = model.model_id || model.name || model.key;
if (model.modes === 'fast') state.thinkingMode = false;
else if (model.modes === 'thinking') state.thinkingMode = true;
else state.thinkingMode = !!preferredThinking;
return true;
}
async function handleCommand(input, ctx) {
const { rl, state, config, workspace, statusBar } = ctx;
const persist = () => {
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,
});
};
const [cmd, ...rest] = input.split(/\s+/);
const arg = rest.join(' ').trim();
if (cmd === '/exit') {
rl.close();
return { exit: true };
}
if (cmd === '/help') {
printHelp();
return { exit: false };
}
if (cmd === '/new') {
state.tokenUsage = normalizeTokenUsage({ prompt: 0, completion: 0, total: 0 });
const conv = createConversation(workspace, {
model_key: state.modelKey,
model_id: state.modelId,
thinking_mode: state.thinkingMode,
allow_mode: state.allowMode,
token_usage: state.tokenUsage,
cwd: workspace,
});
state.conversation = conv;
state.messages = [];
printNotice(`已创建新对话: ${conv.id}`);
persist();
if (statusBar) statusBar.render();
return { exit: false };
}
if (cmd === '/resume') {
if (arg) {
const conv = loadConversation(workspace, arg);
if (!conv) {
printNotice('未找到对话');
return { exit: false };
}
state.conversation = conv;
state.messages = conv.messages || [];
const ok = applyModelState(state, config, conv.metadata?.model_key || state.modelKey, !!conv.metadata?.thinking_mode);
if (!ok) {
printNotice('对话中的模型不可用,已保留当前模型');
}
state.allowMode = conv.metadata?.allow_mode || state.allowMode;
state.tokenUsage = normalizeTokenUsage(conv.metadata?.token_usage);
printNotice(`已加载对话: ${conv.id}`);
renderConversation(state.messages);
persist();
return { exit: false };
}
const items = listConversations(workspace);
if (!items.length) {
printNotice('暂无对话记录');
return { exit: false };
}
const filtered = items.filter((it) => it.id !== state.conversation?.id);
if (!filtered.length) {
printNotice('暂无可恢复的对话');
return { exit: false };
}
const displayItems = filtered.map((item) => {
const rel = formatRelativeTime(item.updated_at || item.created_at);
const title = item.title || '新对话';
return { id: item.id, time: rel, title };
});
const result = await runResumeMenu({ rl, items: displayItems });
if (!result) return { exit: false };
if (result.type === 'new') {
const convNew = createConversation(workspace, {
model_key: state.modelKey,
model_id: state.modelId,
thinking_mode: state.thinkingMode,
allow_mode: state.allowMode,
token_usage: state.tokenUsage,
cwd: workspace,
});
state.conversation = convNew;
state.messages = [];
printNotice(`已创建新对话: ${convNew.id}`);
persist();
return { exit: false };
}
const conv = loadConversation(workspace, result.id);
if (!conv) {
printNotice('未找到对话');
return { exit: false };
}
state.conversation = conv;
state.messages = conv.messages || [];
const ok = applyModelState(state, config, conv.metadata?.model_key || state.modelKey, !!conv.metadata?.thinking_mode);
if (!ok) {
printNotice('对话中的模型不可用,已保留当前模型');
}
state.allowMode = conv.metadata?.allow_mode || state.allowMode;
state.tokenUsage = normalizeTokenUsage(conv.metadata?.token_usage);
printNotice(`已加载对话: ${conv.id}`);
renderConversation(state.messages);
persist();
return { exit: false };
}
if (cmd === '/allow') {
const choices = [
{
name: `1. Read Only${state.allowMode === 'read_only' ? ' (current)' : ''} 只能读取文件与搜索,不能修改和运行指令`,
value: 'read_only',
},
{
name: `2. Full Access${state.allowMode === 'full_access' ? ' (current)' : ''} 可修改/执行,允许工作区外文件与网络`,
value: 'full_access',
},
];
const selected = await runSelect({ rl, message: '', choices, pageSize: 6 });
if (selected) {
state.allowMode = selected;
printNotice(`运行模式已切换为: ${state.allowMode}`);
persist();
}
return { exit: false };
}
if (cmd === '/model') {
const models = config.valid_models || [];
if (!models.length) {
printNotice('未找到可用模型,请先完善 models.json');
return { exit: false };
}
const modelChoices = models.map((model, idx) => ({
name: `${idx + 1}. ${model.name}${state.modelKey === model.key ? ' (current)' : ''}`,
value: model.key,
}));
const modelKey = await runSelect({ rl, message: '', choices: modelChoices, pageSize: 6 });
if (!modelKey) return { exit: false };
const selected = getModelByKey(config, modelKey);
if (!selected || !selected.valid) {
printNotice('模型配置无效');
return { exit: false };
}
state.modelKey = selected.key;
state.modelId = selected.model_id || selected.name || selected.key;
printNotice(`模型已切换为: ${state.modelKey}`);
if (selected.modes === 'fast') {
state.thinkingMode = false;
printNotice('思考模式: fast');
persist();
return { exit: false };
}
if (selected.modes === 'thinking') {
state.thinkingMode = true;
printNotice('思考模式: thinking');
persist();
return { exit: false };
}
const thinkingChoices = [
{ name: `1. Fast${!state.thinkingMode ? ' (current)' : ''}`, value: 'fast' },
{ name: `2. Thinking${state.thinkingMode ? ' (current)' : ''}`, value: 'thinking' },
];
const mode = await runSelect({ rl, message: '', choices: thinkingChoices, pageSize: 6 });
if (mode) {
state.thinkingMode = mode === 'thinking';
printNotice(`思考模式: ${mode}`);
persist();
}
return { exit: false };
}
if (cmd === '/status') {
const usage = normalizeTokenUsage(state.tokenUsage);
const model = getModelByKey(config, state.modelKey);
const title = 'Status';
const maxContext = model && model.max_context ? model.max_context : '';
const maxOutput = model && model.max_output ? model.max_output : '';
const lines = [
`model: ${state.modelKey}`,
`thinking: ${state.thinkingMode ? 'thinking' : 'fast'}`,
`workspace: ${workspace}`,
`allow: ${state.allowMode}`,
`conversation: ${state.conversation?.id || 'none'}`,
`tokens(in): ${usage.prompt}`,
`tokens(out): ${usage.completion}`,
`tokens(total): ${usage.total}`,
`max_context: ${maxContext}`,
`max_output: ${maxOutput}`,
];
renderBox({ title, lines });
return { exit: false };
}
if (cmd === '/config') {
const model = getModelByKey(config, state.modelKey);
console.log('');
console.log(`config: ${config.path || ''}`);
if (model) {
console.log(`base_url: ${model.base_url || ''}`);
console.log(`modelname: ${model.model_id || model.name || ''}`);
console.log(`apikey: ${maskKey(model.api_key)}`);
console.log(`modes: ${model.modes || ''}`);
console.log(`multimodal: ${model.multimodal || ''}`);
console.log(`max_output: ${model.max_output || ''}`);
console.log(`max_context: ${model.max_context || ''}`);
}
console.log(`tavily_api_key: ${maskKey(config.tavily_api_key)}`);
console.log('');
return { exit: false };
}
if (cmd === '/compact') {
if (!state.conversation) return { exit: false };
const oldId = state.conversation.id;
const spinner = new Spinner();
spinner.start(() => '');
const messages = (state.messages || []).filter((msg) => msg.role === 'user' || msg.role === 'assistant');
const cleaned = messages.map((msg) => {
if (msg.role === 'assistant') {
const copy = { ...msg };
delete copy.tool_calls;
delete copy.tool_raw;
return copy;
}
return msg;
});
const newConv = 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 updated = updateConversation(workspace, newConv, cleaned, {
model_key: state.modelKey,
model_id: state.modelId,
thinking_mode: state.thinkingMode,
allow_mode: state.allowMode,
token_usage: state.tokenUsage,
cwd: workspace,
});
state.conversation = updated;
state.messages = cleaned;
spinner.stopSilent();
printNotice(`压缩完成:${oldId} -> ${state.conversation.id}`);
persist();
return { exit: false };
}
printNotice(`未知指令: ${cmd},使用 /help 查看指令列表。`);
return { exit: false };
}
function renderConversation(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
const toolCallMap = new Map();
for (const msg of messages) {
if (msg.role === 'user') {
console.log('');
const w = createIndentedWriter(' ');
w.write(`${cyan('用户:')}${msg.content || ''}`);
console.log('');
console.log('');
continue;
}
if (msg.role === 'assistant') {
if (Array.isArray(msg.tool_calls)) {
msg.tool_calls.forEach((tc) => {
if (tc && tc.id) toolCallMap.set(tc.id, tc);
});
}
if (msg.content) {
console.log('');
const w = createIndentedWriter(' ');
w.write(`${green('Eagent')}`);
if (msg.content) w.write(msg.content);
console.log('');
console.log('');
}
continue;
}
if (msg.role === 'tool') {
const toolCall = toolCallMap.get(msg.tool_call_id) || {};
const name = toolCall.function?.name || 'tool';
let args = {};
try {
args = JSON.parse(toolCall.function?.arguments || '{}');
} catch (_) {
args = {};
}
const line = buildFinalLine(name, args);
// 静态回放:直接输出完成态
process.stdout.write(`${line}\n`);
const raw = msg.tool_raw || { success: true };
const resultLines = formatResultLines(name, args, raw);
printResultLines(resultLines);
continue;
}
}
}
module.exports = { handleCommand };

851
Agent/src/cli/index.js Normal file
View File

@ -0,0 +1,851 @@
#!/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');
}
}

133
Agent/src/config.js Normal file
View File

@ -0,0 +1,133 @@
'use strict';
const fs = require('fs');
const path = require('path');
const DEFAULT_CONFIG_NAME = 'models.json';
const DEFAULT_CONFIG_PATH = path.resolve(__dirname, '..', DEFAULT_CONFIG_NAME);
function isNonEmptyString(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function parsePositiveInt(value) {
if (value === null || value === undefined || value === '') return null;
const num = Number(value);
if (!Number.isFinite(num)) return null;
const out = Math.floor(num);
return out > 0 ? out : null;
}
function normalizeModes(value) {
if (!value) return null;
const text = String(value).trim().toLowerCase();
if (!text) return null;
const parts = text.split(/[,\s]+/).filter(Boolean);
const hasFast = parts.some((p) => p.includes('fast') || p.includes('快速'));
const hasThinking = parts.some((p) => p.includes('thinking') || p.includes('思考'));
if (hasFast && hasThinking) return 'fast+thinking';
if (hasThinking) return 'thinking';
if (hasFast) return 'fast';
return null;
}
function normalizeMultimodal(value) {
if (value === null || value === undefined) return 'none';
const text = String(value).trim().toLowerCase();
if (!text) return 'none';
if (text.includes('无') || text.includes('none') || text.includes('no')) return 'none';
const parts = text.split(/[,\s]+/).filter(Boolean);
const hasImage = parts.some((p) => p.includes('图片') || p.includes('image'));
const hasVideo = parts.some((p) => p.includes('视频') || p.includes('video'));
if (hasVideo && hasImage) return 'image+video';
if (hasVideo) return 'image+video';
if (hasImage) return 'image';
return null;
}
function normalizeModel(raw) {
const name = String(raw.name || raw.model_name || raw.model || '').trim();
const url = String(raw.url || raw.base_url || '').trim();
const apiKey = String(raw.apikey || raw.api_key || '').trim();
const modes = normalizeModes(raw.modes || raw.mode || raw.supported_modes);
const multimodal = normalizeMultimodal(raw.multimodal || raw.multi_modal || raw.multi);
const maxOutput = parsePositiveInt(raw.max_output ?? raw.max_tokens ?? raw.max_output_tokens);
const maxContext = parsePositiveInt(raw.max_context ?? raw.context_window ?? raw.max_context_tokens);
const valid = Boolean(
isNonEmptyString(name)
&& isNonEmptyString(url)
&& isNonEmptyString(apiKey)
&& modes
&& multimodal
&& maxOutput
&& maxContext
);
return {
key: name,
name,
model_id: name,
base_url: url,
api_key: apiKey,
modes,
multimodal,
max_output: maxOutput,
max_context: maxContext,
valid,
};
}
function buildConfig(raw, filePath) {
const modelsRaw = Array.isArray(raw.models) ? raw.models : [];
const models = modelsRaw.map((item) => normalizeModel(item || {}));
const modelMap = new Map();
models.forEach((model) => {
if (model.key) modelMap.set(model.key, model);
});
const validModels = models.filter((model) => model.valid);
const defaultModelKey = String(raw.default_model || raw.default_model_key || '').trim();
const resolvedDefault = modelMap.get(defaultModelKey)?.valid
? defaultModelKey
: (validModels[0] ? validModels[0].key : '');
return {
path: filePath,
tavily_api_key: String(raw.tavily_api_key || '').trim(),
models,
valid_models: validModels,
model_map: modelMap,
default_model_key: resolvedDefault,
};
}
function ensureConfig() {
const file = DEFAULT_CONFIG_PATH;
if (!fs.existsSync(file)) {
const template = {
tavily_api_key: '',
default_model: '',
models: [],
};
fs.writeFileSync(file, JSON.stringify(template, null, 2), 'utf8');
}
const content = fs.readFileSync(file, 'utf8');
const raw = JSON.parse(content);
return buildConfig(raw, file);
}
function maskKey(key) {
if (!key) return '';
if (key.length <= 8) return key;
return `${key.slice(0, 3)}...${key.slice(-3)}`;
}
function getModelByKey(config, key) {
if (!config || !key) return null;
if (config.model_map && typeof config.model_map.get === 'function') {
return config.model_map.get(key) || null;
}
if (Array.isArray(config.models)) {
return config.models.find((m) => m && m.key === key) || null;
}
return null;
}
module.exports = { ensureConfig, maskKey, getModelByKey };

60
Agent/src/core/context.js Normal file
View File

@ -0,0 +1,60 @@
'use strict';
const os = require('os');
const path = require('path');
const { execSync } = require('child_process');
function getTerminalType() {
if (process.platform === 'win32') {
if (process.env.PSModulePath || process.env.POWERSHELL_DISTRIBUTION_CHANNEL) return 'powershell';
return 'cmd';
}
return 'terminal';
}
function getGitInfo(workspace) {
try {
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: workspace, stdio: ['ignore', 'pipe', 'ignore'] })
.toString()
.trim();
if (!branch) return '未初始化';
const status = execSync('git status --porcelain', { cwd: workspace, stdio: ['ignore', 'pipe', 'ignore'] })
.toString()
.trim();
const dirty = status ? 'dirty' : 'clean';
return `${branch} (${dirty})`;
} catch (_) {
return '未初始化';
}
}
function buildSystemPrompt(basePrompt, opts) {
const now = new Date();
const tzOffset = now.getTimezoneOffset();
const localMs = now.getTime() - tzOffset * 60 * 1000;
const localIso = new Date(localMs).toISOString().slice(0, 16);
const platform = os.platform();
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。若用户要求使用请告知当前无权限需要用户输入 /allow 切换权限。`
: opts.allowMode;
const replacements = {
current_time: localIso,
path: opts.workspace,
workspace: opts.workspace,
system: `${systemName} (${os.release()})`,
terminal: getTerminalType(),
allow_mode: allowModeValue,
permissions: allowModeValue,
git: getGitInfo(opts.workspace),
model_id: opts.modelId || '',
};
for (const [key, value] of Object.entries(replacements)) {
const token = `{${key}}`;
prompt = prompt.split(token).join(String(value));
}
return prompt.trim();
}
module.exports = { buildSystemPrompt };

29
Agent/src/core/state.js Normal file
View File

@ -0,0 +1,29 @@
'use strict';
function resolveDefaultModel(config) {
const key = config.default_model_key || '';
const model = config.model_map && typeof config.model_map.get === 'function'
? config.model_map.get(key)
: null;
const modelKey = model && model.key ? model.key : key;
const modelId = model && (model.model_id || model.name) ? (model.model_id || model.name) : modelKey;
const supportsThinking = model ? (model.modes === 'thinking' || model.modes === 'fast+thinking') : false;
const thinkingMode = supportsThinking && model.modes !== 'fast';
return { modelKey, modelId, thinkingMode };
}
function createState(config, workspace) {
const resolved = resolveDefaultModel(config);
return {
workspace,
allowMode: 'full_access',
modelKey: resolved.modelKey,
modelId: resolved.modelId,
thinkingMode: resolved.thinkingMode,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
conversation: null,
messages: [],
};
}
module.exports = { createState };

102
Agent/src/model/client.js Normal file
View File

@ -0,0 +1,102 @@
'use strict';
const { getModelProfile } = require('./model_profiles');
function computeMaxTokens(profile, currentContextTokens) {
const baseMax = Number.isFinite(profile.max_output) ? Math.max(1, Math.floor(profile.max_output)) : null;
if (!baseMax) return null;
const maxContext = Number.isFinite(profile.max_context) ? Math.max(1, Math.floor(profile.max_context)) : null;
if (!maxContext) return baseMax;
const used = Number.isFinite(currentContextTokens) ? Math.max(0, Math.floor(currentContextTokens)) : 0;
const available = maxContext - used;
if (available <= 0) return 1;
return Math.min(baseMax, available);
}
async function* streamChat({ config, modelKey, messages, tools, thinkingMode, currentContextTokens, abortSignal }) {
const profile = getModelProfile(config, modelKey);
const url = `${profile.base_url}/chat/completions`;
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${profile.api_key}`,
};
const payload = {
model: profile.model_id,
messages,
tools,
tool_choice: tools && tools.length ? 'auto' : undefined,
stream: true,
stream_options: { include_usage: true },
};
payload.include_usage = true;
payload.reasoning_split = true;
const maxTokens = computeMaxTokens(profile, currentContextTokens);
if (maxTokens) payload.max_tokens = maxTokens;
const useThinking = thinkingMode && profile.supports_thinking;
if (useThinking) {
Object.assign(payload, profile.thinking_params.thinking);
} else {
Object.assign(payload, profile.thinking_params.fast);
}
let res;
try {
res = await fetch(url, {
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}`);
}
if (!res.ok || !res.body) {
const text = await res.text();
throw new Error(`API错误 ${res.status}: ${text}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder('utf-8');
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 });
let idx;
while ((idx = buffer.indexOf('\n\n')) !== -1) {
const chunk = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const lines = chunk.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const data = trimmed.slice(5).trim();
if (!data) continue;
if (data === '[DONE]') return;
try {
const json = JSON.parse(data);
yield json;
} catch (_) {
continue;
}
}
}
}
}
module.exports = { streamChat };

View File

@ -0,0 +1,35 @@
'use strict';
const { getModelByKey } = require('../config');
function buildProfile(model) {
const supportsThinking = model.modes === 'thinking' || model.modes === 'fast+thinking';
return {
key: model.key,
name: model.name,
base_url: model.base_url,
api_key: model.api_key,
model_id: model.model_id || model.name,
modes: model.modes,
multimodal: model.multimodal,
max_output: Number.isFinite(model.max_output) ? model.max_output : null,
max_context: Number.isFinite(model.max_context) ? model.max_context : null,
supports_thinking: supportsThinking,
thinking_params: supportsThinking
? {
fast: { thinking: { type: 'disabled' } },
thinking: { thinking: { type: 'enabled' } },
}
: { fast: {}, thinking: {} },
};
}
function getModelProfile(config, modelKey) {
const model = getModelByKey(config, modelKey);
if (!model || !model.valid) {
throw new Error(`模型配置无效或不存在: ${modelKey || ''}`);
}
return buildProfile(model);
}
module.exports = { getModelProfile };

View File

@ -0,0 +1,151 @@
'use strict';
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');
const convDir = path.join(base, 'conversations');
const indexFile = path.join(base, 'index.json');
if (!fs.existsSync(convDir)) fs.mkdirSync(convDir, { recursive: true });
if (!fs.existsSync(indexFile)) fs.writeFileSync(indexFile, JSON.stringify({}, null, 2), 'utf8');
return { base, convDir, indexFile };
}
function newConversationId() {
return crypto.randomUUID();
}
function loadIndex(indexFile) {
try {
const raw = fs.readFileSync(indexFile, 'utf8');
return raw ? JSON.parse(raw) : {};
} catch (_) {
return {};
}
}
function saveIndex(indexFile, index) {
fs.writeFileSync(indexFile, JSON.stringify(index, null, 2), 'utf8');
}
function extractTitle(messages) {
for (const msg of messages) {
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 '新对话';
}
function countTools(messages) {
let count = 0;
for (const msg of messages) {
if (msg.role === 'assistant' && Array.isArray(msg.tool_calls)) count += msg.tool_calls.length;
if (msg.role === 'tool') count += 1;
}
return count;
}
function saveConversation(workspace, conversation) {
const { convDir, indexFile } = getWorkspaceStore(workspace);
const filePath = path.join(convDir, `${conversation.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(conversation, null, 2), 'utf8');
const index = loadIndex(indexFile);
index[conversation.id] = {
title: conversation.title || '新对话',
created_at: conversation.created_at,
updated_at: conversation.updated_at,
total_messages: (conversation.messages || []).length,
total_tools: countTools(conversation.messages || []),
thinking_mode: conversation.metadata?.thinking_mode || false,
run_mode: conversation.metadata?.thinking_mode ? 'thinking' : 'fast',
model_key: conversation.metadata?.model_key || '',
};
saveIndex(indexFile, index);
}
function createConversation(workspace, metadata = {}) {
const id = newConversationId();
const now = toISO();
const conversation = {
id,
title: '新对话',
created_at: now,
updated_at: now,
metadata: {
model_key: metadata.model_key || '',
model_id: metadata.model_id || '',
thinking_mode: !!metadata.thinking_mode,
allow_mode: metadata.allow_mode || 'full_access',
token_usage: normalizeTokenUsage(metadata.token_usage),
cwd: metadata.cwd || '',
},
messages: [],
};
saveConversation(workspace, conversation);
return conversation;
}
function loadConversation(workspace, id) {
const { convDir } = getWorkspaceStore(workspace);
const filePath = path.join(convDir, `${id}.json`);
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, 'utf8');
if (!raw) return null;
return JSON.parse(raw);
}
function listConversations(workspace) {
const { indexFile } = getWorkspaceStore(workspace);
const index = loadIndex(indexFile);
const entries = Object.entries(index).map(([id, meta]) => ({ id, ...meta }));
entries.sort((a, b) => {
const ta = new Date(a.updated_at || a.created_at || 0).getTime();
const tb = new Date(b.updated_at || b.created_at || 0).getTime();
return tb - ta;
});
return entries;
}
function updateConversation(workspace, conversation, messages, metadataUpdates = {}) {
const now = toISO();
const updated = {
...conversation,
messages,
updated_at: now,
};
updated.title = extractTitle(messages);
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;
}
module.exports = {
getWorkspaceStore,
createConversation,
loadConversation,
listConversations,
updateConversation,
saveConversation,
};

View File

@ -0,0 +1,217 @@
'use strict';
const { readFileTool } = require('./read_file');
const { editFileTool } = require('./edit_file');
const { runCommandTool } = require('./run_command');
const { webSearchTool, extractWebpageTool } = require('./web_search');
const { searchWorkspaceTool } = require('./search_workspace');
const { readMediafileTool } = require('./read_mediafile');
const path = require('path');
const MAX_RUN_COMMAND_CHARS = 10000;
const MAX_EXTRACT_WEBPAGE_CHARS = 80000;
function formatFailure(err) {
return `失败: ${err}`;
}
function formatReadFile(result) {
if (!result.success) return formatFailure(result.error || '读取失败');
if (result.type === 'read') {
return result.content || '';
}
if (result.type === 'search') {
const parts = [];
for (const match of result.matches || []) {
const hits = (match.hits || []).join(',') || '';
parts.push(`[${match.id}] L${match.line_start}-${match.line_end} hits:${hits}`);
parts.push(match.snippet || '');
parts.push('');
}
return parts.join('\n').trim();
}
if (result.type === 'extract') {
const parts = [];
for (const seg of result.segments || []) {
const label = seg.label || 'segment';
parts.push(`[${label}] L${seg.line_start}-${seg.line_end}`);
parts.push(seg.content || '');
parts.push('');
}
return parts.join('\n').trim();
}
return '';
}
function formatEditFile(result) {
if (!result.success) return formatFailure(result.error || '修改失败');
const count = typeof result.replacements === 'number' ? result.replacements : 0;
return `已替换 ${count} 处: ${result.path}`;
}
function formatWebSearch(result) {
if (!result.success) return formatFailure(result.error || '搜索失败');
const time = result.searched_at || new Date().toISOString();
const summary = result.answer || '';
const lines = [];
lines.push(`🔍 搜索查询: ${result.query}`);
lines.push(`📅 搜索时间: ${time}`);
lines.push('');
lines.push('📝 AI摘要:');
lines.push(summary || '');
lines.push('');
lines.push('---');
lines.push('');
lines.push('📊 搜索结果:');
const results = result.results || [];
results.forEach((item, idx) => {
lines.push(`${idx + 1}. ${item.title || ''}`);
lines.push(` 🔗 ${item.url || ''}`);
lines.push(` 📄 ${item.content || ''}`);
});
return lines.join('\n').trim();
}
function formatExtractWebpage(result, mode, url, targetPath) {
if (!result.success) return formatFailure(result.error || '提取失败');
if (mode === 'save') {
return `已保存: ${targetPath} (chars=${result.chars}, bytes=${result.bytes})`;
}
let content = result.content || '';
if (content.length > MAX_EXTRACT_WEBPAGE_CHARS) content = content.slice(0, MAX_EXTRACT_WEBPAGE_CHARS);
return `URL: ${url}\n${content}`;
}
function formatRunCommand(result) {
if (!result.success) {
if (result.status === 'timeout') {
const output = result.output ? result.output.slice(-MAX_RUN_COMMAND_CHARS) : '';
return `[timeout after ${result.timeout}s]\n${output}`.trim();
}
return `[error rc=${result.return_code ?? '1'}] ${result.error || ''}\n${result.output || ''}`.trim();
}
let output = result.output || '';
if (output.length > MAX_RUN_COMMAND_CHARS) output = output.slice(-MAX_RUN_COMMAND_CHARS);
return output || '[no_output]';
}
function formatSearchWorkspace(result) {
if (!result.success) return formatFailure(result.error || '搜索失败');
if (result.mode === 'file') {
const lines = [`命中文件(${result.matches.length}):`];
result.matches.forEach((p, idx) => lines.push(`${idx + 1}) ${p}`));
return lines.join('\n');
}
if (result.mode === 'content') {
const parts = [];
for (const item of result.results || []) {
parts.push(item.file);
for (const m of item.matches || []) {
parts.push(`- L${m.line}: ${m.snippet}`);
}
parts.push('');
}
return parts.join('\n').trim();
}
return '';
}
function formatReadMediafile(result) {
if (!result.success) return formatFailure(result.error || '读取失败');
if (result.type === 'image') return `已附加图片: ${result.path}`;
return `已附加视频: ${result.path}`;
}
function buildToolContent(result) {
if (result.type === 'image' || result.type === 'video') {
const payload = {
type: result.type === 'image' ? 'image_url' : 'video_url',
[result.type === 'image' ? 'image_url' : 'video_url']: {
url: `data:${result.mime};base64,${result.b64}`,
},
};
return [
{ type: 'text', text: formatReadMediafile(result) },
payload,
];
}
return null;
}
async function executeTool({ workspace, config, allowMode, toolCall, abortSignal }) {
const name = toolCall.function.name;
let args = {};
try {
args = JSON.parse(toolCall.function.arguments || '{}');
} catch (err) {
return {
success: false,
tool: name,
error: `JSON解析失败: ${err.message || err}`,
formatted: formatFailure('参数解析失败'),
};
}
if (allowMode === 'read_only' && (name === 'edit_file' || name === 'run_command')) {
const note = '当前为只读模式,已禁用 edit_file、run_command。若需使用请告知当前无权限需要用户输入 /allow 切换权限。';
return {
success: false,
tool: name,
error: note,
formatted: note,
};
}
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, 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;
if (args.mode === 'save') {
if (!targetPath || !targetPath.toLowerCase().endsWith('.md')) {
raw = { success: false, error: 'target_path 必须是 .md 文件' };
} else {
raw = await extractWebpageTool(config, args, targetPath, abortSignal);
}
} else {
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);
else raw = { success: false, error: '未知工具' };
let formatted = '';
if (name === 'read_file') formatted = formatReadFile(raw);
else if (name === 'edit_file') formatted = formatEditFile(raw);
else if (name === 'run_command') formatted = formatRunCommand(raw);
else if (name === 'web_search') formatted = formatWebSearch(raw);
else if (name === 'extract_webpage') formatted = formatExtractWebpage(raw, args.mode, args.url, args.target_path);
else if (name === 'search_workspace') formatted = formatSearchWorkspace(raw);
else if (name === 'read_mediafile') formatted = formatReadMediafile(raw);
else formatted = raw.success ? '' : formatFailure(raw.error || '失败');
const toolContent = buildToolContent(raw);
return {
success: raw.success,
tool: name,
raw,
formatted,
tool_content: toolContent,
};
}
module.exports = { executeTool };

View File

@ -0,0 +1,81 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { structuredPatch } = require('diff');
function resolvePath(workspace, p) {
if (path.isAbsolute(p)) return p;
return path.join(workspace, p);
}
function diffSummary(oldText, newText) {
const patch = structuredPatch('old', 'new', oldText, newText, '', '', { context: 1 });
let added = 0;
let removed = 0;
const hunks = patch.hunks.map((hunk) => {
let oldLine = hunk.oldStart;
let newLine = hunk.newStart;
const lines = hunk.lines.map((line) => {
const prefix = line[0];
const content = line.slice(1);
let lineNum = null;
if (prefix === ' ') {
lineNum = oldLine;
oldLine += 1;
newLine += 1;
} else if (prefix === '-') {
lineNum = oldLine;
removed += 1;
oldLine += 1;
} else if (prefix === '+') {
lineNum = newLine;
added += 1;
newLine += 1;
}
return { prefix, lineNum, content };
});
return lines;
});
return { added, removed, hunks };
}
function editFileTool(workspace, args) {
const target = resolvePath(workspace, args.file_path);
const oldString = args.old_string ?? '';
const newString = args.new_string ?? '';
try {
let creating = false;
if (!fs.existsSync(target)) {
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 (!creating && oldString === '') {
return { success: false, error: 'old_string 不能为空,请从 read_file 内容中精确复制' };
}
if (!creating && !original.includes(oldString)) {
return { success: false, error: 'old_string 未匹配到内容' };
}
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 {
success: true,
path: target,
replacements,
diff,
};
} catch (err) {
return { success: false, error: err.message || String(err) };
}
}
module.exports = { editFileTool, resolvePath };

View File

@ -0,0 +1,103 @@
'use strict';
const fs = require('fs');
const path = require('path');
function resolvePath(workspace, p) {
if (path.isAbsolute(p)) return p;
return path.join(workspace, p);
}
function readLines(content) {
return content.split(/\r?\n/);
}
function applyMaxChars(text, maxChars) {
if (!maxChars || text.length <= maxChars) return { text, truncated: false };
return { text: text.slice(0, maxChars), truncated: true };
}
function readFileTool(workspace, args) {
const target = resolvePath(workspace, args.path);
const type = args.type || 'read';
try {
const raw = fs.readFileSync(target, 'utf8');
if (type === 'read') {
const lines = readLines(raw);
const start = Math.max(1, args.start_line || 1);
const end = Math.min(lines.length, args.end_line || lines.length);
const slice = lines.slice(start - 1, end).join('\n');
const capped = applyMaxChars(slice, args.max_chars);
return {
success: true,
type: 'read',
path: target,
line_start: start,
line_end: end,
content: capped.text,
truncated: capped.truncated,
};
}
if (type === 'search') {
const lines = readLines(raw);
const query = args.query || '';
const caseSensitive = !!args.case_sensitive;
const maxMatches = args.max_matches || 20;
const before = args.context_before || 0;
const after = args.context_after || 0;
const matches = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const hay = caseSensitive ? line : line.toLowerCase();
const needle = caseSensitive ? query : query.toLowerCase();
if (!needle) break;
if (hay.includes(needle)) {
const start = Math.max(0, i - before);
const end = Math.min(lines.length - 1, i + after);
const snippet = lines.slice(start, end + 1).join('\n');
matches.push({
id: `match_${matches.length + 1}`,
line_start: start + 1,
line_end: end + 1,
hits: [i + 1],
snippet,
});
if (matches.length >= maxMatches) break;
}
}
return {
success: true,
type: 'search',
path: target,
query,
case_sensitive: caseSensitive,
matches,
};
}
if (type === 'extract') {
const lines = readLines(raw);
const segments = Array.isArray(args.segments) ? args.segments : [];
const extracted = segments.map((seg, idx) => {
const start = Math.max(1, seg.start_line || 1);
const end = Math.min(lines.length, seg.end_line || lines.length);
return {
label: seg.label || `segment_${idx + 1}`,
line_start: start,
line_end: end,
content: lines.slice(start - 1, end).join('\n'),
};
});
return {
success: true,
type: 'extract',
path: target,
segments: extracted,
};
}
return { success: false, error: '未知 read_file type' };
} catch (err) {
return { success: false, error: err.message || String(err) };
}
}
module.exports = { readFileTool, resolvePath };

View File

@ -0,0 +1,31 @@
'use strict';
const fs = require('fs');
const path = require('path');
const mime = require('mime-types');
function resolvePath(workspace, p) {
if (path.isAbsolute(p)) return p;
return path.join(workspace, p);
}
function readMediafileTool(workspace, args) {
const target = resolvePath(workspace, args.path);
try {
const stat = fs.statSync(target);
if (!stat.isFile()) return { success: false, error: '不是文件' };
const mt = mime.lookup(target);
if (!mt) return { success: false, error: '无法识别文件类型' };
if (!mt.startsWith('image/') && !mt.startsWith('video/')) {
return { success: false, error: '禁止的文件类型' };
}
const data = fs.readFileSync(target);
const b64 = data.toString('base64');
const type = mt.startsWith('image/') ? 'image' : 'video';
return { success: true, path: target, mime: mt, type, b64 };
} catch (err) {
return { success: false, error: err.message || String(err) };
}
}
module.exports = { readMediafileTool };

View File

@ -0,0 +1,53 @@
'use strict';
const { exec } = require('child_process');
const path = require('path');
function resolvePath(workspace, p) {
if (!p) return workspace;
if (path.isAbsolute(p)) return p;
return path.join(workspace, p);
}
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;
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';
return resolve({
success: false,
status: isTimeout ? 'timeout' : 'error',
error: err.message,
return_code: err.code,
output,
timeout: timeoutSec,
});
}
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 });
});
}
module.exports = { runCommandTool };

View File

@ -0,0 +1,119 @@
'use strict';
const fs = require('fs');
const path = require('path');
const fg = require('fast-glob');
const { execSync } = require('child_process');
function resolvePath(workspace, p) {
if (!p) return workspace;
if (path.isAbsolute(p)) return p;
return path.join(workspace, p);
}
function hasRg() {
try {
execSync('rg --version', { stdio: 'ignore' });
return true;
} catch (_) {
return false;
}
}
async function searchWorkspaceTool(workspace, args) {
const root = resolvePath(workspace, args.root || '.');
const mode = args.mode;
const query = args.query || '';
const useRegex = !!args.use_regex;
const caseSensitive = !!args.case_sensitive;
const maxResults = args.max_results || 20;
const maxMatchesPerFile = args.max_matches_per_file || 3;
const includeGlob = Array.isArray(args.include_glob) && args.include_glob.length ? args.include_glob : ['**/*'];
const excludeGlob = Array.isArray(args.exclude_glob) ? args.exclude_glob : [];
const maxFileSize = args.max_file_size || null;
if (mode === 'file') {
const files = await fg(includeGlob, { cwd: root, dot: true, ignore: excludeGlob, onlyFiles: true, absolute: true });
const matcher = useRegex ? new RegExp(query, caseSensitive ? '' : 'i') : null;
const matches = [];
for (const file of files) {
const name = path.basename(file);
const hay = caseSensitive ? name : name.toLowerCase();
const needle = caseSensitive ? query : query.toLowerCase();
const ok = useRegex ? matcher.test(name) : hay.includes(needle);
if (ok) {
matches.push(file);
if (matches.length >= maxResults) break;
}
}
return { success: true, mode: 'file', root, query, matches };
}
if (mode === 'content') {
if (hasRg()) {
const argsList = ['--line-number', '--no-heading', '--color=never'];
if (!useRegex) argsList.push('--fixed-strings');
if (!caseSensitive) argsList.push('-i');
if (maxMatchesPerFile) argsList.push(`--max-count=${maxMatchesPerFile}`);
if (maxFileSize) argsList.push(`--max-filesize=${maxFileSize}`);
for (const g of includeGlob) argsList.push('-g', g);
for (const g of excludeGlob) argsList.push('-g', `!${g}`);
argsList.push(query);
argsList.push(root);
let output = '';
try {
output = execSync(`rg ${argsList.map((a) => JSON.stringify(a)).join(' ')}`, { maxBuffer: 10 * 1024 * 1024 }).toString();
} catch (err) {
output = err.stdout ? err.stdout.toString() : '';
}
const lines = output.split(/\r?\n/).filter(Boolean);
const files = new Map();
for (const line of lines) {
const [filePath, lineNum, ...rest] = line.split(':');
const snippet = rest.join(':').trim();
if (!files.has(filePath)) files.set(filePath, []);
if (files.get(filePath).length < maxMatchesPerFile) {
files.get(filePath).push({ line: Number(lineNum), snippet });
}
if (files.size >= maxResults) break;
}
const results = Array.from(files.entries()).map(([file, matches]) => ({ file, matches }));
return { success: true, mode: 'content', root, query, results };
}
// fallback
const files = await fg(includeGlob, { cwd: root, dot: true, ignore: excludeGlob, onlyFiles: true, absolute: true });
const matcher = useRegex ? new RegExp(query, caseSensitive ? '' : 'i') : null;
const results = [];
for (const file of files) {
if (maxFileSize) {
try {
const stat = fs.statSync(file);
if (stat.size > maxFileSize) continue;
} catch (_) {}
}
const content = fs.readFileSync(file, 'utf8');
const lines = content.split(/\r?\n/);
const matches = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const hay = caseSensitive ? line : line.toLowerCase();
const needle = caseSensitive ? query : query.toLowerCase();
const ok = useRegex ? matcher.test(line) : hay.includes(needle);
if (ok) {
matches.push({ line: i + 1, snippet: line.trim() });
if (matches.length >= maxMatchesPerFile) break;
}
}
if (matches.length) {
results.push({ file, matches });
if (results.length >= maxResults) break;
}
}
return { success: true, mode: 'content', root, query, results };
}
return { success: false, error: '未知 search_workspace mode' };
}
module.exports = { searchWorkspaceTool };

View File

@ -0,0 +1,74 @@
'use strict';
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,
};
if (args.max_results) body.max_results = args.max_results;
if (args.topic) body.topic = args.topic;
if (args.time_range) body.time_range = args.time_range;
if (args.days) body.days = args.days;
if (args.start_date) body.start_date = args.start_date;
if (args.end_date) body.end_date = args.end_date;
if (args.country) body.country = args.country;
try {
const res = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: abortSignal,
});
const json = await res.json();
if (!res.ok) {
return { success: false, error: json.error || JSON.stringify(json) };
}
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, abortSignal) {
if (abortSignal && abortSignal.aborted) {
return { success: false, error: '任务被用户取消', cancelled: true };
}
const body = {
api_key: config.tavily_api_key,
urls: [args.url],
include_images: false,
};
try {
const res = await fetch('https://api.tavily.com/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: abortSignal,
});
const json = await res.json();
if (!res.ok) {
return { success: false, error: json.error || JSON.stringify(json) };
}
const content = (json.results && json.results[0] && json.results[0].content) || '';
if (args.mode === 'save') {
const fs = require('fs');
fs.writeFileSync(savePath, content, 'utf8');
return { success: true, saved_path: savePath, content, chars: content.length, bytes: Buffer.byteLength(content) };
}
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) };
}
}
module.exports = { webSearchTool, extractWebpageTool };

40
Agent/src/ui/banner.js Normal file
View File

@ -0,0 +1,40 @@
'use strict';
const { bold } = require('../utils/colors');
const { visibleWidth, truncateVisible, padEndVisible } = require('../utils/text_width');
function renderBox({ title, lines }) {
const cols = Number(process.stdout.columns) || 80;
const safeTitle = title ? String(title) : '';
const safeLines = Array.isArray(lines) ? lines.map((line) => String(line)) : [];
if (cols < 20) {
console.log('');
if (safeTitle) console.log(safeTitle);
safeLines.forEach((line) => console.log(line));
console.log('');
return;
}
const contentWidth = Math.max(visibleWidth(safeTitle), ...safeLines.map(visibleWidth));
const innerWidth = Math.max(10, Math.min(cols - 2, contentWidth + 2));
const top = `+${'-'.repeat(innerWidth)}+`;
const maxLine = innerWidth - 2;
const renderLine = (text) => `| ${padEndVisible(truncateVisible(text, maxLine), maxLine)} |`;
console.log('');
console.log(top);
if (safeTitle) console.log(renderLine(safeTitle));
safeLines.forEach((line) => console.log(renderLine(line)));
console.log(top);
console.log('');
}
function renderBanner({ modelKey, workspace, conversationId }) {
const title = `>_ Welcome to ${bold('EasyAgent')}`;
const lines = [
`model: ${modelKey || ''}`,
`path: ${workspace || ''}`,
`conversation: ${conversationId || 'none'}`,
];
renderBox({ title, lines });
}
module.exports = { renderBanner, renderBox };

View File

@ -0,0 +1,89 @@
'use strict';
const readline = require('readline');
const COMMAND_CHOICES = [
{ value: '/new', desc: '创建新对话' },
{ value: '/resume', desc: '加载旧对话' },
{ value: '/allow', desc: '切换运行模式(只读/无限制)' },
{ value: '/model', desc: '切换模型和思考模式' },
{ value: '/status', desc: '查看当前对话状态' },
{ value: '/compact', desc: '压缩当前对话' },
{ value: '/config', desc: '查看当前配置' },
{ value: '/help', desc: '显示指令列表' },
{ 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 = '';
rl.pause();
rl.line = '';
rl.cursor = 0;
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
try {
const { default: search } = await import('@inquirer/search');
const chosen = await search({
message: prompt.trimEnd(),
pageSize,
theme: {
helpMode: 'never',
prefix: '',
icon: { cursor: '>' },
style: {
message: (text) => text,
searchTerm: (text) => `/${(text || '').replace(/^\/+/, '')}`,
highlight: (text) => (colorEnabled ? `\x1b[94m${text}${resetAnsi}` : text),
error: () => '无效的指令',
keysHelpTip: () => '',
description: (text) => text,
answer: () => '',
},
},
source: async (input) => {
latestInput = input || '';
if (typeof onInput === 'function') onInput(latestInput);
const term = latestInput.replace(/^\/+/, '').toLowerCase();
const maxCmdLen = Math.max(...COMMAND_CHOICES.map((c) => c.value.length));
const indentLen = Math.max(0, prompt.length - 2);
const indent = ' '.repeat(indentLen);
const format = (c) => {
const pad = ' '.repeat(Math.max(1, maxCmdLen - c.value.length + 2));
return `${indent}${c.value}${pad}${c.desc}`;
};
const filtered = term
? COMMAND_CHOICES.filter((c) =>
c.value.toLowerCase().includes(term) || c.desc.toLowerCase().includes(term)
)
: COMMAND_CHOICES;
return filtered.map((c) => ({ name: format(c), value: c.value }));
},
}, abortSignal ? { signal: abortSignal, clearPromptOnDone: true } : { clearPromptOnDone: true });
return { chosen, term: latestInput, cancelled: false };
} catch (err) {
if (err && (err.name === 'AbortPromptError' || err.name === 'CancelPromptError')) {
return { chosen: null, term: latestInput, cancelled: true };
}
console.log('指令菜单不可用,请先安装依赖: npm i @inquirer/search');
return { chosen: null, term: '', cancelled: true };
} finally {
if (process.stdin.isTTY) process.stdin.setRawMode(true);
process.stdin.resume();
try {
rl.resume();
} catch (_) {}
}
}
module.exports = { openCommandMenu, hasCommandMatch };

View File

@ -0,0 +1,40 @@
'use strict';
function createIndentedWriter(indent = ' ') {
let col = 0;
function width() {
return process.stdout.columns || 80;
}
function write(text) {
const str = String(text ?? '');
for (const ch of str) {
if (col === 0) {
process.stdout.write(indent);
col = indent.length;
}
if (ch === '\r') continue;
if (ch === '\n') {
process.stdout.write('\n');
col = 0;
continue;
}
process.stdout.write(ch);
col += 1;
if (col >= width()) {
process.stdout.write('\n');
col = 0;
}
}
}
function writeLine(text = '') {
write(text);
process.stdout.write('\n');
col = 0;
}
function reset() {
col = 0;
}
return { write, writeLine, reset };
}
module.exports = { createIndentedWriter };

173
Agent/src/ui/resume_menu.js Normal file
View File

@ -0,0 +1,173 @@
'use strict';
const readline = require('readline');
const { blue, gray } = require('../utils/colors');
function isFullwidthCodePoint(code) {
return (
code >= 0x1100 &&
(code <= 0x115f ||
code === 0x2329 ||
code === 0x232a ||
(code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
(code >= 0xac00 && code <= 0xd7a3) ||
(code >= 0xf900 && code <= 0xfaff) ||
(code >= 0xfe10 && code <= 0xfe19) ||
(code >= 0xfe30 && code <= 0xfe6f) ||
(code >= 0xff00 && code <= 0xff60) ||
(code >= 0xffe0 && code <= 0xffe6) ||
(code >= 0x1f300 && code <= 0x1f64f) ||
(code >= 0x1f900 && code <= 0x1f9ff))
);
}
function stringWidth(text) {
if (!text) return 0;
let width = 0;
for (const char of text) {
const code = char.codePointAt(0);
if (code == null) continue;
width += isFullwidthCodePoint(code) ? 2 : 1;
}
return width;
}
function padEndDisplay(text, targetWidth) {
const current = stringWidth(text);
if (current >= targetWidth) return text;
return text + ' '.repeat(targetWidth - current);
}
function truncateDisplay(text, width) {
if (!text || width <= 0) return '';
let out = '';
let w = 0;
for (const ch of text) {
const c = ch.codePointAt(0);
const cw = c != null && isFullwidthCodePoint(c) ? 2 : 1;
if (w + cw > width) break;
out += ch;
w += cw;
}
return out;
}
function fitLine(text, width) {
if (!text) return '';
if (stringWidth(text) <= width) return text;
return truncateDisplay(text, Math.max(0, width - 1));
}
async function runResumeMenu({ rl, items }) {
rl.pause();
rl.line = '';
rl.cursor = 0;
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
// Keep stdin flowing for keypress events while readline is paused.
readline.emitKeypressEvents(process.stdin);
if (process.stdin.isTTY) process.stdin.setRawMode(true);
process.stdin.resume();
let selected = 0;
let start = 0;
function render() {
const rows = process.stdout.rows || 24;
const cols = process.stdout.columns || 80;
const leftPad = ' ';
const gap = ' ';
const timeWidth = Math.min(
16,
Math.max(8, ...items.map((it) => (it.time ? stringWidth(it.time) : 0)))
);
const headerLines = [
`${leftPad}Resume a previous session`,
'',
`${leftPad} ${padEndDisplay('Updated', timeWidth)}${gap}Conversation`,
];
const footer = 'enter to resume esc to start new ctrl + c to quit ↑/↓ to browse';
const headerCount = headerLines.length;
const footerCount = 1;
const listHeight = Math.max(1, rows - headerCount - footerCount);
if (selected < start) start = selected;
if (selected >= start + listHeight) start = selected - listHeight + 1;
const lines = [];
headerLines.forEach((line) => lines.push(fitLine(line, cols)));
for (let i = 0; i < listHeight; i++) {
const idx = start + i;
if (idx < items.length) {
const prefix = idx === selected ? '>' : ' ';
const time = items[idx].time || '';
const title = items[idx].title || '';
const line = `${leftPad}${prefix}${padEndDisplay(time, timeWidth)}${gap}${title}`;
lines.push(idx === selected ? blue(fitLine(line, cols)) : fitLine(line, cols));
} else {
lines.push('');
}
}
lines.push(gray(fitLine(footer, cols)));
process.stdout.write('\x1b[2J\x1b[H');
process.stdout.write(lines.join('\n'));
}
render();
return new Promise((resolve) => {
let resolved = false;
const cleanup = () => {
process.stdin.off('keypress', onKey);
if (process.stdin.isTTY) process.stdin.setRawMode(true);
process.stdin.resume();
try {
rl.resume();
} catch (_) {}
process.stdout.write('\x1b[2J\x1b[H');
};
const finish = (val) => {
if (resolved) return;
resolved = true;
cleanup();
resolve(val);
};
const onKey = (str, key) => {
if (key && key.ctrl && key.name === 'c') {
process.stdout.write('\n');
process.exit(0);
}
if (key && key.name === 'up') {
if (items.length) {
selected = Math.max(0, selected - 1);
render();
}
return;
}
if (key && key.name === 'down') {
if (items.length) {
selected = Math.min(items.length - 1, selected + 1);
render();
}
return;
}
if (key && key.name === 'return') {
const chosen = items[selected];
finish({ type: 'resume', id: chosen ? chosen.id : null });
return;
}
if (key && key.name === 'escape') {
finish({ type: 'new' });
return;
}
};
process.stdin.on('keypress', onKey);
});
}
module.exports = { runResumeMenu };

View File

@ -0,0 +1,39 @@
'use strict';
const readline = require('readline');
async function runSelect({ rl, message, choices, pageSize = 6 }) {
rl.pause();
rl.line = '';
rl.cursor = 0;
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
try {
const { default: select } = await import('@inquirer/select');
const value = await select({
message: message || '',
choices,
pageSize,
theme: {
prefix: '',
icon: { cursor: '' },
style: {
message: (t) => t,
keysHelpTip: () => '',
},
},
}, { clearPromptOnDone: true });
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
return value;
} finally {
if (process.stdin.isTTY) process.stdin.setRawMode(true);
process.stdin.resume();
try {
rl.resume();
} catch (_) {}
}
}
module.exports = { runSelect };

151
Agent/src/ui/spinner.js Normal file
View File

@ -0,0 +1,151 @@
'use strict';
const readline = require('readline');
const { gray } = require('../utils/colors');
const FRAMES = ['⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
class Spinner {
constructor(indent = '', leadingLines = 0) {
this.timer = null;
this.index = 0;
this.textProvider = () => '';
this.active = false;
this.indent = indent;
this.leadingLines = Math.max(0, Number(leadingLines) || 0);
this.leadingWritten = false;
this.thinkingLineReady = false;
}
setIndent(indent) {
this.indent = indent || '';
}
start(textProvider) {
this.textProvider = textProvider || (() => '');
this.active = true;
this.thinkingLineReady = false;
if (this.leadingLines > 0 && !this.leadingWritten) {
process.stdout.write('\n'.repeat(this.leadingLines));
this.leadingWritten = true;
}
this.render();
this.timer = setInterval(() => this.render(), 120);
}
ensureThinkingLine() {
if (this.thinkingLineReady) return;
process.stdout.write('\n');
this.thinkingLineReady = true;
readline.moveCursor(process.stdout, 0, -1);
readline.cursorTo(process.stdout, 0);
}
render() {
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
const frame = FRAMES[this.index % FRAMES.length];
this.index += 1;
const provided = this.textProvider(frame);
let suffix = '';
let thinking = '';
let colorThinking = false;
let hasThinkingLine = false;
if (typeof provided === 'string') {
suffix = provided;
} else if (provided && typeof provided === 'object') {
suffix = provided.label || '';
thinking = provided.thinking || '';
colorThinking = !!provided.colorThinking;
hasThinkingLine = Object.prototype.hasOwnProperty.call(provided, 'thinking');
}
const linePrefix = `${this.indent}${frame}${suffix}`;
process.stdout.write(linePrefix);
if (hasThinkingLine) {
this.ensureThinkingLine();
const width = process.stdout.columns || 80;
let visibleThinking = thinking;
const maxThinking = Math.max(0, width - this.indent.length - 1);
if (visibleThinking && maxThinking > 0 && visibleThinking.length > maxThinking) {
visibleThinking = visibleThinking.slice(0, maxThinking);
}
if (visibleThinking && maxThinking === 0) visibleThinking = '';
const line = visibleThinking ? (colorThinking ? gray(visibleThinking) : visibleThinking) : '';
const leadSpaces = (suffix.match(/^\s*/) || [''])[0].length;
const alignCol = this.indent.length + frame.length + leadSpaces;
readline.moveCursor(process.stdout, 0, 1);
readline.cursorTo(process.stdout, alignCol);
readline.clearLine(process.stdout, 0);
process.stdout.write(line);
readline.moveCursor(process.stdout, 0, -1);
readline.cursorTo(process.stdout, 0);
}
}
stop(finalText) {
if (this.timer) clearInterval(this.timer);
this.timer = null;
this.active = false;
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
const provided = this.textProvider('');
let thinking = '';
let colorThinking = false;
let hasThinkingLine = false;
if (provided && typeof provided === 'object' && Object.prototype.hasOwnProperty.call(provided, 'thinking')) {
thinking = provided.thinking || '';
colorThinking = !!provided.colorThinking;
hasThinkingLine = true;
}
const linePrefix = `${this.indent}${finalText}`;
process.stdout.write(linePrefix);
if (hasThinkingLine) {
this.ensureThinkingLine();
const width = process.stdout.columns || 80;
let visibleThinking = thinking;
const maxThinking = Math.max(0, width - this.indent.length - 1);
if (visibleThinking && maxThinking > 0 && visibleThinking.length > maxThinking) {
visibleThinking = visibleThinking.slice(0, maxThinking);
}
if (visibleThinking && maxThinking === 0) visibleThinking = '';
const line = visibleThinking ? (colorThinking ? gray(visibleThinking) : visibleThinking) : '';
let alignCol = this.indent.length;
const firstNonSpace = finalText.search(/\S/);
if (firstNonSpace >= 0) {
const afterFirst = finalText.slice(firstNonSpace + 1);
const spaceAfter = (afterFirst.match(/^\s*/) || [''])[0].length;
alignCol = this.indent.length + firstNonSpace + 1 + spaceAfter;
}
readline.moveCursor(process.stdout, 0, 1);
readline.cursorTo(process.stdout, alignCol);
readline.clearLine(process.stdout, 0);
process.stdout.write(line);
process.stdout.write('\n');
} else {
process.stdout.write('\n');
}
this.thinkingLineReady = false;
}
stopSilent() {
if (this.timer) clearInterval(this.timer);
this.timer = null;
this.active = false;
this.thinkingLineReady = false;
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
}
}
function truncateThinking(text, max = 50) {
const sanitized = String(text || '').replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim();
if (sanitized.length <= max) return sanitized;
return sanitized.slice(0, max) + '...';
}
function formatThinkingLine(text) {
if (!text) return '';
return gray(truncateThinking(text));
}
module.exports = { Spinner, formatThinkingLine, truncateThinking };

105
Agent/src/ui/status_bar.js Normal file
View File

@ -0,0 +1,105 @@
'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, getMaxTokens }) {
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 maxValue = typeof getMaxTokens === 'function' ? getMaxTokens() : maxTokens;
const maxText = typeof maxValue === 'number' ? formatCount(maxValue) : (maxValue || '?');
const right = `当前上下文 ${formatCount(total)}/${maxText}`;
const rightTail = ` ${formatCount(total)}/${maxText}`;
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 };

21
Agent/src/utils/colors.js Normal file
View File

@ -0,0 +1,21 @@
'use strict';
const enabled = process.stdout.isTTY;
function wrap(code, text) {
if (!enabled) return String(text);
return `\x1b[${code}m${text}\x1b[0m`;
}
module.exports = {
enabled,
blue: (t) => wrap('34', t),
cyan: (t) => wrap('36', t),
gray: (t) => wrap('90', t),
red: (t) => wrap('31', t),
green: (t) => wrap('32', t),
dim: (t) => wrap('2', t),
bold: (t) => wrap('1', t),
invert: (t) => wrap('7', t),
reset: enabled ? '\x1b[0m' : '',
};

View File

@ -0,0 +1,428 @@
'use strict';
const ANSI_REGEX = /\x1B\[[0-?]*[ -/]*[@-~]/g;
const COMBINING_RANGES = [
[0x0300, 0x036f],
[0x0483, 0x0489],
[0x0591, 0x05bd],
[0x05bf, 0x05bf],
[0x05c1, 0x05c2],
[0x05c4, 0x05c5],
[0x05c7, 0x05c7],
[0x0610, 0x061a],
[0x064b, 0x065f],
[0x0670, 0x0670],
[0x06d6, 0x06dc],
[0x06df, 0x06e4],
[0x06e7, 0x06e8],
[0x06ea, 0x06ed],
[0x0711, 0x0711],
[0x0730, 0x074a],
[0x07a6, 0x07b0],
[0x07eb, 0x07f3],
[0x0816, 0x0819],
[0x081b, 0x0823],
[0x0825, 0x0827],
[0x0829, 0x082d],
[0x0859, 0x085b],
[0x0900, 0x0902],
[0x093a, 0x093a],
[0x093c, 0x093c],
[0x0941, 0x0948],
[0x094d, 0x094d],
[0x0951, 0x0957],
[0x0962, 0x0963],
[0x0981, 0x0981],
[0x09bc, 0x09bc],
[0x09c1, 0x09c4],
[0x09cd, 0x09cd],
[0x09e2, 0x09e3],
[0x0a01, 0x0a02],
[0x0a3c, 0x0a3c],
[0x0a41, 0x0a42],
[0x0a47, 0x0a48],
[0x0a4b, 0x0a4d],
[0x0a51, 0x0a51],
[0x0a70, 0x0a71],
[0x0a75, 0x0a75],
[0x0a81, 0x0a82],
[0x0abc, 0x0abc],
[0x0ac1, 0x0ac5],
[0x0ac7, 0x0ac8],
[0x0acd, 0x0acd],
[0x0ae2, 0x0ae3],
[0x0b01, 0x0b01],
[0x0b3c, 0x0b3c],
[0x0b3f, 0x0b3f],
[0x0b41, 0x0b44],
[0x0b4d, 0x0b4d],
[0x0b56, 0x0b56],
[0x0b62, 0x0b63],
[0x0b82, 0x0b82],
[0x0bc0, 0x0bc0],
[0x0bcd, 0x0bcd],
[0x0c00, 0x0c00],
[0x0c3e, 0x0c40],
[0x0c46, 0x0c48],
[0x0c4a, 0x0c4d],
[0x0c55, 0x0c56],
[0x0c62, 0x0c63],
[0x0c81, 0x0c81],
[0x0cbc, 0x0cbc],
[0x0cbf, 0x0cbf],
[0x0cc6, 0x0cc6],
[0x0ccc, 0x0ccd],
[0x0ce2, 0x0ce3],
[0x0d00, 0x0d01],
[0x0d3b, 0x0d3c],
[0x0d41, 0x0d44],
[0x0d4d, 0x0d4d],
[0x0d62, 0x0d63],
[0x0dca, 0x0dca],
[0x0dd2, 0x0dd4],
[0x0dd6, 0x0dd6],
[0x0e31, 0x0e31],
[0x0e34, 0x0e3a],
[0x0e47, 0x0e4e],
[0x0eb1, 0x0eb1],
[0x0eb4, 0x0ebc],
[0x0ec8, 0x0ecd],
[0x0f18, 0x0f19],
[0x0f35, 0x0f35],
[0x0f37, 0x0f37],
[0x0f39, 0x0f39],
[0x0f71, 0x0f7e],
[0x0f80, 0x0f84],
[0x0f86, 0x0f87],
[0x0f8d, 0x0f97],
[0x0f99, 0x0fbc],
[0x0fc6, 0x0fc6],
[0x102d, 0x1030],
[0x1032, 0x1037],
[0x1039, 0x103a],
[0x103d, 0x103e],
[0x1058, 0x1059],
[0x105e, 0x1060],
[0x1071, 0x1074],
[0x1082, 0x1082],
[0x1085, 0x1086],
[0x108d, 0x108d],
[0x109d, 0x109d],
[0x135d, 0x135f],
[0x1712, 0x1714],
[0x1732, 0x1734],
[0x1752, 0x1753],
[0x1772, 0x1773],
[0x17b4, 0x17b5],
[0x17b7, 0x17bd],
[0x17c6, 0x17c6],
[0x17c9, 0x17d3],
[0x17dd, 0x17dd],
[0x180b, 0x180d],
[0x1885, 0x1886],
[0x18a9, 0x18a9],
[0x1920, 0x1922],
[0x1927, 0x1928],
[0x1932, 0x1932],
[0x1939, 0x193b],
[0x1a17, 0x1a18],
[0x1a1b, 0x1a1b],
[0x1a56, 0x1a56],
[0x1a58, 0x1a5e],
[0x1a60, 0x1a60],
[0x1a62, 0x1a62],
[0x1a65, 0x1a6c],
[0x1a73, 0x1a7c],
[0x1a7f, 0x1a7f],
[0x1ab0, 0x1ace],
[0x1b00, 0x1b03],
[0x1b34, 0x1b34],
[0x1b36, 0x1b3a],
[0x1b3c, 0x1b3c],
[0x1b42, 0x1b42],
[0x1b6b, 0x1b73],
[0x1b80, 0x1b81],
[0x1ba2, 0x1ba5],
[0x1ba8, 0x1ba9],
[0x1bab, 0x1bad],
[0x1be6, 0x1be6],
[0x1be8, 0x1be9],
[0x1bed, 0x1bed],
[0x1bef, 0x1bf1],
[0x1c2c, 0x1c33],
[0x1c36, 0x1c37],
[0x1cd0, 0x1cd2],
[0x1cd4, 0x1ce0],
[0x1ce2, 0x1ce8],
[0x1ced, 0x1ced],
[0x1cf4, 0x1cf4],
[0x1cf8, 0x1cf9],
[0x1dc0, 0x1df9],
[0x1dfb, 0x1dff],
[0x200b, 0x200f],
[0x202a, 0x202e],
[0x2060, 0x2064],
[0x2066, 0x206f],
[0x20d0, 0x20f0],
[0x2cef, 0x2cf1],
[0x2d7f, 0x2d7f],
[0x2de0, 0x2dff],
[0x302a, 0x302f],
[0x3099, 0x309a],
[0xa66f, 0xa672],
[0xa674, 0xa67d],
[0xa69e, 0xa69f],
[0xa6f0, 0xa6f1],
[0xa802, 0xa802],
[0xa806, 0xa806],
[0xa80b, 0xa80b],
[0xa825, 0xa826],
[0xa8c4, 0xa8c5],
[0xa8e0, 0xa8f1],
[0xa926, 0xa92d],
[0xa947, 0xa951],
[0xa980, 0xa982],
[0xa9b3, 0xa9b3],
[0xa9b6, 0xa9b9],
[0xa9bc, 0xa9bc],
[0xa9e5, 0xa9e5],
[0xaa29, 0xaa2e],
[0xaa31, 0xaa32],
[0xaa35, 0xaa36],
[0xaa43, 0xaa43],
[0xaa4c, 0xaa4c],
[0xaa7c, 0xaa7c],
[0xaab0, 0xaab0],
[0xaab2, 0xaab4],
[0xaab7, 0xaab8],
[0xaabe, 0xaabf],
[0xaac1, 0xaac1],
[0xaaec, 0xaaed],
[0xaaf6, 0xaaf6],
[0xabe5, 0xabe5],
[0xabe8, 0xabe8],
[0xabed, 0xabed],
[0xfb1e, 0xfb1e],
[0xfe00, 0xfe0f],
[0xfe20, 0xfe2f],
[0xfeff, 0xfeff],
[0xfff9, 0xfffb],
[0x101fd, 0x101fd],
[0x102e0, 0x102e0],
[0x10376, 0x1037a],
[0x10a01, 0x10a03],
[0x10a05, 0x10a06],
[0x10a0c, 0x10a0f],
[0x10a38, 0x10a3a],
[0x10a3f, 0x10a3f],
[0x10ae5, 0x10ae6],
[0x10d24, 0x10d27],
[0x10eab, 0x10eac],
[0x10f46, 0x10f50],
[0x11001, 0x11001],
[0x11038, 0x11046],
[0x1107f, 0x11081],
[0x110b3, 0x110b6],
[0x110b9, 0x110ba],
[0x110c2, 0x110c2],
[0x11100, 0x11102],
[0x11127, 0x1112b],
[0x1112d, 0x11134],
[0x11173, 0x11173],
[0x11180, 0x11181],
[0x111b6, 0x111be],
[0x111c9, 0x111cc],
[0x1122f, 0x11231],
[0x11234, 0x11234],
[0x11236, 0x11237],
[0x1123e, 0x1123e],
[0x112df, 0x112df],
[0x112e3, 0x112ea],
[0x11300, 0x11301],
[0x1133b, 0x1133c],
[0x11340, 0x11340],
[0x11366, 0x1136c],
[0x11370, 0x11374],
[0x11438, 0x1143f],
[0x11442, 0x11444],
[0x11446, 0x11446],
[0x1145e, 0x1145e],
[0x114b3, 0x114b8],
[0x114ba, 0x114ba],
[0x114bf, 0x114c0],
[0x114c2, 0x114c3],
[0x115b2, 0x115b5],
[0x115bc, 0x115bd],
[0x115bf, 0x115c0],
[0x115dc, 0x115dd],
[0x11633, 0x1163a],
[0x1163d, 0x1163d],
[0x1163f, 0x11640],
[0x116ab, 0x116ab],
[0x116ad, 0x116ad],
[0x116b0, 0x116b5],
[0x116b7, 0x116b7],
[0x1171d, 0x1171f],
[0x11722, 0x11725],
[0x11727, 0x1172b],
[0x1182f, 0x11837],
[0x11839, 0x1183a],
[0x1193b, 0x1193c],
[0x1193e, 0x1193e],
[0x11943, 0x11943],
[0x119d4, 0x119d7],
[0x119da, 0x119db],
[0x119e0, 0x119e0],
[0x11a01, 0x11a0a],
[0x11a33, 0x11a38],
[0x11a3b, 0x11a3e],
[0x11a47, 0x11a47],
[0x11a51, 0x11a56],
[0x11a59, 0x11a5b],
[0x11a8a, 0x11a96],
[0x11a98, 0x11a99],
[0x11c30, 0x11c36],
[0x11c38, 0x11c3d],
[0x11c3f, 0x11c3f],
[0x11c92, 0x11ca7],
[0x11caa, 0x11cb0],
[0x11cb2, 0x11cb3],
[0x11cb5, 0x11cb6],
[0x11d31, 0x11d36],
[0x11d3a, 0x11d3a],
[0x11d3c, 0x11d3d],
[0x11d3f, 0x11d45],
[0x11d47, 0x11d47],
[0x11d90, 0x11d91],
[0x11ef3, 0x11ef4],
[0x16af0, 0x16af4],
[0x16b30, 0x16b36],
[0x16f4f, 0x16f4f],
[0x16f8f, 0x16f92],
[0x16fe4, 0x16fe4],
[0x1bc9d, 0x1bc9e],
[0x1cf00, 0x1cf2d],
[0x1cf30, 0x1cf46],
[0x1d167, 0x1d169],
[0x1d17b, 0x1d182],
[0x1d185, 0x1d18b],
[0x1d1aa, 0x1d1ad],
[0x1d242, 0x1d244],
[0xe0100, 0xe01ef],
];
function stripAnsi(text) {
return String(text ?? '').replace(ANSI_REGEX, '');
}
function isCombining(codePoint) {
for (const [start, end] of COMBINING_RANGES) {
if (codePoint >= start && codePoint <= end) return true;
}
return false;
}
function isFullWidth(codePoint) {
if (codePoint >= 0x1100 && (
codePoint <= 0x115f ||
codePoint === 0x2329 ||
codePoint === 0x232a ||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
(codePoint >= 0x20000 && codePoint <= 0x3fffd)
)) {
return true;
}
return false;
}
function visibleWidth(text) {
const str = stripAnsi(text);
let width = 0;
for (let i = 0; i < str.length; i += 1) {
const codePoint = str.codePointAt(i);
if (codePoint > 0xffff) i += 1;
if (codePoint === 0) continue;
if (codePoint < 32 || (codePoint >= 0x7f && codePoint <= 0x9f)) continue;
if (isCombining(codePoint)) continue;
width += isFullWidth(codePoint) ? 2 : 1;
}
return width;
}
function truncatePlain(text, maxCols) {
const str = String(text ?? '');
if (maxCols <= 0) return '';
if (visibleWidth(str) <= maxCols) return str;
const useEllipsis = maxCols > 3;
const limit = maxCols - (useEllipsis ? 3 : 0);
let out = '';
let width = 0;
for (let i = 0; i < str.length && width < limit; i += 1) {
const codePoint = str.codePointAt(i);
if (codePoint > 0xffff) i += 1;
if (codePoint === 0) continue;
if (codePoint < 32 || (codePoint >= 0x7f && codePoint <= 0x9f)) continue;
if (isCombining(codePoint)) continue;
const nextWidth = isFullWidth(codePoint) ? 2 : 1;
if (width + nextWidth > limit) break;
out += String.fromCodePoint(codePoint);
width += nextWidth;
}
if (useEllipsis) out += '...';
return out;
}
function truncateVisible(text, maxCols) {
const str = String(text ?? '');
if (maxCols <= 0) return '';
if (visibleWidth(str) <= maxCols) return str;
const useEllipsis = maxCols > 3;
const limit = maxCols - (useEllipsis ? 3 : 0);
let out = '';
let width = 0;
const ansiRegex = new RegExp(ANSI_REGEX.source, 'y');
for (let i = 0; i < str.length && width < limit; ) {
ansiRegex.lastIndex = i;
const match = ansiRegex.exec(str);
if (match && match.index === i) {
out += match[0];
i += match[0].length;
continue;
}
const codePoint = str.codePointAt(i);
const char = String.fromCodePoint(codePoint);
if (codePoint > 0xffff) i += 2;
else i += 1;
if (codePoint === 0) continue;
if (codePoint < 32 || (codePoint >= 0x7f && codePoint <= 0x9f)) continue;
if (isCombining(codePoint)) continue;
const nextWidth = isFullWidth(codePoint) ? 2 : 1;
if (width + nextWidth > limit) break;
out += char;
width += nextWidth;
}
if (useEllipsis) out += '...';
if (str.includes('\x1b[')) out += '\x1b[0m';
return out;
}
function padEndVisible(text, targetWidth) {
const str = String(text ?? '');
const width = visibleWidth(str);
if (width >= targetWidth) return str;
return str + ' '.repeat(targetWidth - width);
}
module.exports = {
stripAnsi,
visibleWidth,
truncatePlain,
truncateVisible,
padEndVisible,
};

27
Agent/src/utils/time.js Normal file
View File

@ -0,0 +1,27 @@
'use strict';
function toISO(dt = new Date()) {
return dt.toISOString();
}
function formatRelativeTime(iso) {
if (!iso) return '';
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return '';
const now = Date.now();
const diff = Math.max(0, now - t);
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}秒前`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}分钟前`;
const hour = Math.floor(min / 60);
if (hour < 24) return `${hour}小时前`;
const day = Math.floor(hour / 24);
if (day < 30) return `${day}天前`;
const month = Math.floor(day / 30);
if (month < 12) return `${month}个月前`;
const year = Math.floor(month / 12);
return `${year}年前`;
}
module.exports = { toISO, formatRelativeTime };

View File

@ -0,0 +1,39 @@
'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;
}
function normalizeUsagePayload(usage) {
if (!usage || typeof usage !== 'object') return null;
const toNum = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0);
const prompt = toNum(usage.prompt_tokens ?? usage.input_tokens ?? usage.prompt ?? usage.input);
const completion = toNum(usage.completion_tokens ?? usage.output_tokens ?? usage.completion ?? usage.output);
let total = toNum(usage.total_tokens ?? usage.total);
if (!total && (prompt || completion)) total = prompt + completion;
if (!prompt && !completion && !total) return null;
return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: total };
}
module.exports = { normalizeTokenUsage, applyUsage, normalizeUsagePayload };

View File

@ -94,6 +94,7 @@ dmg: sign
@rm -rf $(DMG_TMP) $(DMG_NAME)
@mkdir -p $(DMG_TMP)
@cp -R $(APP_BUNDLE) $(DMG_TMP)/
@echo '{"models":[],"default_model":"","tavily_api_key":""}' > $(DMG_TMP)/$(APP_NAME).app/Contents/Resources/Agent/models.json
@ln -s /Applications $(DMG_TMP)/Applications
@hdiutil create -volname "$(APP_NAME)" -srcfolder $(DMG_TMP) -ov -format UDZO "$(DMG_NAME)" > /dev/null 2>&1
@rm -rf $(DMG_TMP)

View File

@ -5,7 +5,7 @@ import SwiftUI
import DynamicNotchKit
/// Fn Control
final class AppDelegate: NSObject, NSApplicationDelegate {
final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDelegate {
/// ARC NSApplication.delegate weak
private static var retainedInstance: AppDelegate?
@ -16,6 +16,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private let recordingPanel = RecordingPanel()
private let menuBarController = MenuBarController()
private let llmRefiner = LLMRefiner()
private let speechSynthesizer = NSSpeechSynthesizer()
///
private var speakingMode: LLMMode? = nil
///
private var speakAutoHideWorkItem: DispatchWorkItem? = nil
private let agentBridge: AgentBridge
private let agentTracker = AgentProgressTracker()
private var agentNotch: DynamicNotch<AgentNotchView>?
@ -29,10 +34,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var autoHideWorkItem: DispatchWorkItem?
override init() {
let agentPath = Bundle.main.bundlePath + "/../../../Agent"
agentBridge = AgentBridge(agentDir: agentPath)
agentBridge = AgentBridge(agentDir: Config.shared.agentDir)
super.init()
Self.retainedInstance = self
speechSynthesizer.delegate = self
}
func applicationDidFinishLaunching(_ notification: Notification) {
@ -286,7 +291,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let result = try await llmRefiner.chat(text: text, config: config)
if Config.shared.assistOutputMode == .notch {
recordingPanel.hideAnimated()
NotchDisplayManager.shared.show(text: result.text)
if Config.shared.assistSpeakEnabled {
NotchDisplayManager.shared.show(text: result.text, autoHideAfter: nil)
speakingMode = .assist
speechSynthesizer.startSpeaking(result.text)
} else {
NotchDisplayManager.shared.show(text: result.text)
}
} else {
recordingPanel.hideAnimated { TextInjector.inject(result.text) }
}
@ -301,6 +312,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Agent Mode
private func handleAgentMode(text: String) {
speakAutoHideWorkItem?.cancel()
speechSynthesizer.stopSpeaking()
speakingMode = nil
let modelsFile = Config.shared.loadAgentModels()
let validModels = modelsFile.models.filter { !$0.url.isEmpty && !$0.name.isEmpty && !$0.apikey.isEmpty && !$0.modes.isEmpty }
let defaultModel = modelsFile.defaultModel.isEmpty ? validModels.first?.name : modelsFile.defaultModel
@ -369,9 +383,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
case .done(let content, _):
agentLog("[App] handleEvent done contentLen=\(content.count)")
agentTracker.finish(content: content)
let willSpeak = Config.shared.agentSpeakEnabled && !content.isEmpty
if willSpeak {
speakingMode = .agent
speechSynthesizer.startSpeaking(content)
}
autoHideWorkItem?.cancel()
//
if !agentIsTextMode {
//
if !agentIsTextMode && !willSpeak {
let workItem = DispatchWorkItem { [weak self] in self?.hideAgentNotch() }
autoHideWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 20, execute: workItem)
@ -421,16 +440,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
NSPasteboard.general.setString(self.agentTracker.finalContent, forType: .string)
},
onStop: { [weak self] in
self?.speakingMode = nil
self?.speakAutoHideWorkItem?.cancel()
self?.speechSynthesizer.stopSpeaking()
self?.autoHideWorkItem?.cancel()
self?.agentExitingCleanly = true
self?.agentBridge.stop()
self?.hideAgentNotch()
},
onClose: { [weak self] in
self?.speakingMode = nil
self?.speakAutoHideWorkItem?.cancel()
self?.speechSynthesizer.stopSpeaking()
self?.autoHideWorkItem?.cancel()
self?.hideAgentNotch()
},
onNewConversation: { [weak self] in
self?.speakingMode = nil
self?.speakAutoHideWorkItem?.cancel()
self?.speechSynthesizer.stopSpeaking()
self?.autoHideWorkItem?.cancel()
self?.agentExitingCleanly = true
self?.agentBridge.stop()
@ -454,9 +482,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
func applicationWillTerminate(_ notification: Notification) {
speechSynthesizer.stopSpeaking()
keyMonitor.stop()
audioMonitor.stop()
speechRecognizer.cancel()
recordingPanel.close()
}
// MARK: - NSSpeechSynthesizerDelegate
func speechSynthesizer(_ sender: NSSpeechSynthesizer, didFinishSpeaking finishedSpeaking: Bool) {
guard finishedSpeaking else { return }
DispatchQueue.main.async { [weak self] in
guard let self, let mode = self.speakingMode else { return }
self.speakingMode = nil
// 5
let workItem = DispatchWorkItem { [weak self] in
switch mode {
case .assist:
NotchDisplayManager.shared.extendAutoHide(to: 0.1)
case .agent:
//
if self?.agentIsTextMode != true {
self?.agentNotch?.show(for: 0.1)
}
default:
break
}
}
self.speakAutoHideWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: workItem)
}
}
}

View File

@ -164,7 +164,12 @@ final class AgentBridge {
// MARK: - Private
private func findNode() -> String? {
//
// 0bundle Node.js
let bundledNode = agentDir + "/bin/node"
if FileManager.default.fileExists(atPath: bundledNode) {
return bundledNode
}
// 退
let paths = [
"/opt/homebrew/bin/node",
"/usr/local/bin/node",

View File

@ -13,7 +13,8 @@ final class NotchDisplayManager {
private init() {}
/// / AI
func show(text: String) {
/// - Parameter autoHideAfter: nil
func show(text: String, autoHideAfter: TimeInterval? = 15) {
currentNotch?.hide()
let content = NotchContentView(
@ -30,8 +31,16 @@ final class NotchDisplayManager {
}
isActive = true
// 15
currentNotch?.show(for: 15)
if let duration = autoHideAfter {
currentNotch?.show(for: duration)
} else {
currentNotch?.show()
}
}
/// /
func extendAutoHide(to duration: TimeInterval) {
currentNotch?.show(for: duration)
}
func hide() {

View File

@ -24,6 +24,7 @@ final class AgentProgressTracker: ObservableObject {
func start() {
startTime = Date()
elapsedSeconds = 0
mode = "thinking"
toolEntries = []
finalContent = ""

View File

@ -16,6 +16,12 @@ final class SettingsWindow: NSWindow {
private let outputModeLabel: NSTextField
private let outputModePopup: NSPopUpButton
// Assist
private let speakCheckbox: NSButton
// Agent
private let agentSpeakCheckbox: NSButton
// Agent
private let workspaceField: NSTextField
private let browseButton: NSButton
@ -43,6 +49,8 @@ final class SettingsWindow: NSWindow {
statusLabel = NSTextField(labelWithString: "")
outputModeLabel = NSTextField(labelWithString: "")
outputModePopup = NSPopUpButton(frame: .zero, pullsDown: false)
speakCheckbox = NSButton(checkboxWithTitle: "朗读 AI 回复", target: nil, action: nil)
agentSpeakCheckbox = NSButton(checkboxWithTitle: "任务完成时朗读 AI 最终输出", target: nil, action: nil)
workspaceField = NSTextField(frame: .zero)
browseButton = NSButton(title: "浏览...", target: nil, action: nil)
modelPopup = NSPopUpButton(frame: .zero, pullsDown: false)
@ -55,7 +63,7 @@ final class SettingsWindow: NSWindow {
editModelButton = NSButton(title: "编辑", target: nil, action: nil)
deleteModelButton = NSButton(title: "删除", target: nil, action: nil)
let height: CGFloat = mode == .agent ? 660 : (mode == .assist ? 360 : 320)
let height: CGFloat = mode == .agent ? 700 : (mode == .assist ? 400 : 320)
let width: CGFloat = mode == .agent ? 500 : 420
let windowRect = NSRect(x: 0, y: 0, width: width, height: height)
super.init(
@ -209,12 +217,31 @@ final class SettingsWindow: NSWindow {
outputModePopup.centerYAnchor.constraint(equalTo: label.centerYAnchor),
outputModePopup.widthAnchor.constraint(equalToConstant: 160)
])
outputLabel = label
//
speakCheckbox.translatesAutoresizingMaskIntoConstraints = false
speakCheckbox.state = Config.shared.assistSpeakEnabled ? .on : .off
speakCheckbox.target = self
speakCheckbox.action = #selector(speakToggled)
content.addSubview(speakCheckbox)
NSLayoutConstraint.activate([
speakCheckbox.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
speakCheckbox.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 10)
])
outputLabel = nil
} else {
outputLabel = nil
}
let buttonTopItem = (outputLabel ?? grid)!
let buttonTopItem: NSView
if mode == .assist {
buttonTopItem = speakCheckbox
} else {
buttonTopItem = outputLabel ?? grid
}
NSLayoutConstraint.activate([
grid.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
@ -241,6 +268,7 @@ final class SettingsWindow: NSWindow {
modelField.stringValue = config.model
if mode == .assist {
outputModePopup.selectItem(at: Config.shared.assistOutputMode == .notch ? 1 : 0)
speakCheckbox.state = Config.shared.assistSpeakEnabled ? .on : .off
}
}
@ -301,6 +329,15 @@ final class SettingsWindow: NSWindow {
}
}
@objc private func speakToggled() {
Config.shared.assistSpeakEnabled = speakCheckbox.state == .on
statusLabel.stringValue = speakCheckbox.state == .on ? "✅ 朗读已开启" : "✅ 朗读已关闭"
statusLabel.textColor = .systemGreen
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
self?.statusLabel.stringValue = ""
}
}
@objc private func saveConfig() {
let url = baseURLField.stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let key = apiKeyField.stringValue
@ -348,6 +385,7 @@ final class SettingsWindow: NSWindow {
tavilyKeyField.stringValue = modelsFile.tavilyApiKey
workspaceField.stringValue = Config.shared.agentWorkspace
agentSpeakCheckbox.state = Config.shared.agentSpeakEnabled ? .on : .off
}
private func buildAgentUI() {
@ -381,6 +419,11 @@ final class SettingsWindow: NSWindow {
thinkingModePopup.target = self
thinkingModePopup.action = #selector(agentThinkingModeChanged)
agentSpeakCheckbox.translatesAutoresizingMaskIntoConstraints = false
agentSpeakCheckbox.state = Config.shared.agentSpeakEnabled ? .on : .off
agentSpeakCheckbox.target = self
agentSpeakCheckbox.action = #selector(agentSpeakToggled)
let tavilyLabel = makeLabel("Tavily Key:")
tavilyKeyField.placeholderString = "tvly-...(网络搜索用)"
tavilyKeyField.font = .systemFont(ofSize: 13)
@ -472,6 +515,7 @@ final class SettingsWindow: NSWindow {
content.addSubview(inputModePopup)
content.addSubview(thinkLabel)
content.addSubview(thinkingModePopup)
content.addSubview(agentSpeakCheckbox)
content.addSubview(tavilyLabel)
content.addSubview(tavilyKeyField)
content.addSubview(wsLabel)
@ -513,8 +557,11 @@ final class SettingsWindow: NSWindow {
thinkingModePopup.centerYAnchor.constraint(equalTo: thinkLabel.centerYAnchor),
thinkingModePopup.widthAnchor.constraint(equalToConstant: 200),
agentSpeakCheckbox.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
agentSpeakCheckbox.topAnchor.constraint(equalTo: thinkLabel.bottomAnchor, constant: 10),
tavilyLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
tavilyLabel.topAnchor.constraint(equalTo: thinkLabel.bottomAnchor, constant: 16),
tavilyLabel.topAnchor.constraint(equalTo: agentSpeakCheckbox.bottomAnchor, constant: 14),
tavilyLabel.widthAnchor.constraint(equalToConstant: 85),
tavilyKeyField.leadingAnchor.constraint(equalTo: tavilyLabel.trailingAnchor, constant: 8),
tavilyKeyField.centerYAnchor.constraint(equalTo: tavilyLabel.centerYAnchor),
@ -581,6 +628,15 @@ final class SettingsWindow: NSWindow {
Config.shared.agentThinkingMode = thinkingModePopup.indexOfSelectedItem == 1
}
@objc private func agentSpeakToggled() {
Config.shared.agentSpeakEnabled = agentSpeakCheckbox.state == .on
statusLabel.stringValue = agentSpeakCheckbox.state == .on ? "✅ 朗读已开启" : "✅ 朗读已关闭"
statusLabel.textColor = .systemGreen
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
self?.statusLabel.stringValue = ""
}
}
@objc private func saveAgentConfig() {
var modelsFile = Config.shared.loadAgentModels()
modelsFile.tavilyApiKey = tavilyKeyField.stringValue.trimmingCharacters(in: .whitespaces)

View File

@ -118,6 +118,12 @@ final class Config {
//
static let assistOutputMode = "assist_output_mode"
//
static let assistSpeakEnabled = "assist_speak_enabled"
//
static let agentSpeakEnabled = "agent_speak_enabled"
//
static let agentLLMEnabled = "agent_llm_enabled"
static let agentLLMBaseURL = "agent_llm_base_url"
@ -246,6 +252,13 @@ final class Config {
}
}
// MARK: -
var assistSpeakEnabled: Bool {
get { defaults.bool(forKey: Keys.assistSpeakEnabled) }
set { defaults.set(newValue, forKey: Keys.assistSpeakEnabled) }
}
// MARK: -
var agentLLM: LLMConfig {
@ -270,6 +283,11 @@ final class Config {
set { defaults.set(newValue, forKey: Keys.agentWorkspace) }
}
var agentSpeakEnabled: Bool {
get { defaults.bool(forKey: Keys.agentSpeakEnabled) }
set { defaults.set(newValue, forKey: Keys.agentSpeakEnabled) }
}
// Agent
var agentAllowMode: String {
get { defaults.string(forKey: "agent_allow_mode") ?? "full_access" }

View File

@ -30,11 +30,15 @@ if [ -f "$PROJECT_DIR/Sources/Resources/AppIcon.icns" ]; then
cp "$PROJECT_DIR/Sources/Resources/AppIcon.icns" "$RESOURCES_DIR/AppIcon.icns"
fi
# 复制 Agent 目录(智能体配置和运行时
# 复制 Agent 目录(智能体配置、运行时、Node.js
if [ -d "$PROJECT_DIR/Agent" ]; then
mkdir -p "$RESOURCES_DIR/Agent"
cp -R "$PROJECT_DIR/Agent/"* "$RESOURCES_DIR/Agent/"
echo " ✅ Agent directory copied"
if [ -f "$RESOURCES_DIR/Agent/bin/node" ]; then
chmod +x "$RESOURCES_DIR/Agent/bin/node"
echo " ✅ Node.js bundled"
fi
fi
# 更新 Info.plist 引用图标