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:
parent
94b05c28b3
commit
76741877a8
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,4 +1,6 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
*.app
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
Agent/bin/node
|
||||
agent_debug.log
|
||||
|
||||
170
AGENTS.md
170
AGENTS.md
@ -2,7 +2,14 @@
|
||||
|
||||
## 项目概述
|
||||
|
||||
VoiceInput 是一款 **macOS 菜单栏语音输入应用**。按住 Fn / Globe 键录音,松开后通过剪贴板 + Cmd+V 自动将识别文本注入光标位置。支持 Apple 原生语音识别实时转录、LLM(DeepSeek)纠错优化、多语言切换,以及双击 Control 切换 Input / Assist 模式。
|
||||
VoiceInput 是一款 **macOS 菜单栏语音输入应用**。按住 Fn / Globe 键录音,松开后通过剪贴板 + Cmd+V 自动将识别文本注入光标位置。支持 Apple 原生语音识别实时转录、LLM 纠错优化、AI 对话、Agent 智能体调用、多语言切换。
|
||||
|
||||
**三种模式**(双击 Control 循环切换):
|
||||
| 模式 | 图标 | 功能 |
|
||||
|------|------|------|
|
||||
| Input(语音输入) | 🎤 | 语音转文字 + LLM 纠错 → 注入光标 |
|
||||
| Assist(AI 助手) | 💬 | 语音提问 → 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 终端 UI(Agent 内部使用)
|
||||
└── 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
15
Agent/models.example.json
Normal 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
437
Agent/package-lock.json
generated
Normal 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
20
Agent/package.json
Normal 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
13
Agent/prompts/system.txt
Normal 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
363
Agent/src/cli/commands.js
Normal 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
851
Agent/src/cli/index.js
Normal 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
133
Agent/src/config.js
Normal 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
60
Agent/src/core/context.js
Normal 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
29
Agent/src/core/state.js
Normal 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
102
Agent/src/model/client.js
Normal 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 };
|
||||
35
Agent/src/model/model_profiles.js
Normal file
35
Agent/src/model/model_profiles.js
Normal 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 };
|
||||
151
Agent/src/storage/conversation_store.js
Normal file
151
Agent/src/storage/conversation_store.js
Normal 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,
|
||||
};
|
||||
217
Agent/src/tools/dispatcher.js
Normal file
217
Agent/src/tools/dispatcher.js
Normal 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 };
|
||||
81
Agent/src/tools/edit_file.js
Normal file
81
Agent/src/tools/edit_file.js
Normal 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 };
|
||||
103
Agent/src/tools/read_file.js
Normal file
103
Agent/src/tools/read_file.js
Normal 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 };
|
||||
31
Agent/src/tools/read_mediafile.js
Normal file
31
Agent/src/tools/read_mediafile.js
Normal 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 };
|
||||
53
Agent/src/tools/run_command.js
Normal file
53
Agent/src/tools/run_command.js
Normal 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 };
|
||||
119
Agent/src/tools/search_workspace.js
Normal file
119
Agent/src/tools/search_workspace.js
Normal 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 };
|
||||
74
Agent/src/tools/web_search.js
Normal file
74
Agent/src/tools/web_search.js
Normal 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
40
Agent/src/ui/banner.js
Normal 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 };
|
||||
89
Agent/src/ui/command_menu.js
Normal file
89
Agent/src/ui/command_menu.js
Normal 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 };
|
||||
40
Agent/src/ui/indented_writer.js
Normal file
40
Agent/src/ui/indented_writer.js
Normal 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
173
Agent/src/ui/resume_menu.js
Normal 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 };
|
||||
39
Agent/src/ui/select_prompt.js
Normal file
39
Agent/src/ui/select_prompt.js
Normal 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
151
Agent/src/ui/spinner.js
Normal 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
105
Agent/src/ui/status_bar.js
Normal 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
21
Agent/src/utils/colors.js
Normal 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' : '',
|
||||
};
|
||||
428
Agent/src/utils/text_width.js
Normal file
428
Agent/src/utils/text_width.js
Normal 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
27
Agent/src/utils/time.js
Normal 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 };
|
||||
39
Agent/src/utils/token_usage.js
Normal file
39
Agent/src/utils/token_usage.js
Normal 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 };
|
||||
1
Makefile
1
Makefile
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,7 +164,12 @@ final class AgentBridge {
|
||||
// MARK: - Private
|
||||
|
||||
private func findNode() -> String? {
|
||||
// 尝试常见路径
|
||||
// 优先级 0:bundle 内打包的 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",
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -24,6 +24,7 @@ final class AgentProgressTracker: ObservableObject {
|
||||
|
||||
func start() {
|
||||
startTime = Date()
|
||||
elapsedSeconds = 0
|
||||
mode = "thinking"
|
||||
toolEntries = []
|
||||
finalContent = ""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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 引用图标
|
||||
|
||||
Loading…
Reference in New Issue
Block a user