diff --git a/.gitignore b/.gitignore index 7ad988b..927d6bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .build/ .swiftpm/ *.app -.DS_Store \ No newline at end of file +.DS_Store +Agent/bin/node +agent_debug.log diff --git a/AGENTS.md b/AGENTS.md index 252d495..cd6f5b8 100644 --- a/AGENTS.md +++ b/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。 diff --git a/Agent/models.example.json b/Agent/models.example.json new file mode 100644 index 0000000..5329775 --- /dev/null +++ b/Agent/models.example.json @@ -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 + } + ] +} diff --git a/Agent/package-lock.json b/Agent/package-lock.json new file mode 100644 index 0000000..763c98e --- /dev/null +++ b/Agent/package-lock.json @@ -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" + } + } + } +} diff --git a/Agent/package.json b/Agent/package.json new file mode 100644 index 0000000..df26ca4 --- /dev/null +++ b/Agent/package.json @@ -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" + } +} diff --git a/Agent/prompts/system.txt b/Agent/prompts/system.txt new file mode 100644 index 0000000..97067da --- /dev/null +++ b/Agent/prompts/system.txt @@ -0,0 +1,13 @@ +你是 VoiceInput 的 AI 智能助手,通过语音或文字与用户交互。 +目标:帮助用户完成开发与日常任务,回答简洁准确。 +输出格式:使用纯文字,避免 Markdown 语法。 + +重要提示:用户的输入来自语音识别,可能存在同音字、转录错误或模糊表达。请结合上下文推测用户真实意图。如果确实无法理解,应向用户确认而非猜测回复。 + +- 当前时间:{current_time} +- 当前模型:{model_id} +- 工作区路径:{path} +- 系统信息:{system} +- 终端类型:{terminal} +- 权限:{allow_mode} +- Git:{git} diff --git a/Agent/src/cli/commands.js b/Agent/src/cli/commands.js new file mode 100644 index 0000000..7581225 --- /dev/null +++ b/Agent/src/cli/commands.js @@ -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 }; diff --git a/Agent/src/cli/index.js b/Agent/src/cli/index.js new file mode 100644 index 0000000..7e4c22f --- /dev/null +++ b/Agent/src/cli/index.js @@ -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'); + } +} diff --git a/Agent/src/config.js b/Agent/src/config.js new file mode 100644 index 0000000..78538b9 --- /dev/null +++ b/Agent/src/config.js @@ -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 }; diff --git a/Agent/src/core/context.js b/Agent/src/core/context.js new file mode 100644 index 0000000..f6fbf7f --- /dev/null +++ b/Agent/src/core/context.js @@ -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 }; diff --git a/Agent/src/core/state.js b/Agent/src/core/state.js new file mode 100644 index 0000000..dea2990 --- /dev/null +++ b/Agent/src/core/state.js @@ -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 }; diff --git a/Agent/src/model/client.js b/Agent/src/model/client.js new file mode 100644 index 0000000..3cfc54e --- /dev/null +++ b/Agent/src/model/client.js @@ -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 }; diff --git a/Agent/src/model/model_profiles.js b/Agent/src/model/model_profiles.js new file mode 100644 index 0000000..f37c452 --- /dev/null +++ b/Agent/src/model/model_profiles.js @@ -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 }; diff --git a/Agent/src/storage/conversation_store.js b/Agent/src/storage/conversation_store.js new file mode 100644 index 0000000..c00ea4d --- /dev/null +++ b/Agent/src/storage/conversation_store.js @@ -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, +}; diff --git a/Agent/src/tools/dispatcher.js b/Agent/src/tools/dispatcher.js new file mode 100644 index 0000000..44c86ae --- /dev/null +++ b/Agent/src/tools/dispatcher.js @@ -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 }; diff --git a/Agent/src/tools/edit_file.js b/Agent/src/tools/edit_file.js new file mode 100644 index 0000000..4a19244 --- /dev/null +++ b/Agent/src/tools/edit_file.js @@ -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 }; diff --git a/Agent/src/tools/read_file.js b/Agent/src/tools/read_file.js new file mode 100644 index 0000000..41df509 --- /dev/null +++ b/Agent/src/tools/read_file.js @@ -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 }; diff --git a/Agent/src/tools/read_mediafile.js b/Agent/src/tools/read_mediafile.js new file mode 100644 index 0000000..ed6c007 --- /dev/null +++ b/Agent/src/tools/read_mediafile.js @@ -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 }; diff --git a/Agent/src/tools/run_command.js b/Agent/src/tools/run_command.js new file mode 100644 index 0000000..76d3ba1 --- /dev/null +++ b/Agent/src/tools/run_command.js @@ -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 }; diff --git a/Agent/src/tools/search_workspace.js b/Agent/src/tools/search_workspace.js new file mode 100644 index 0000000..bb24ae5 --- /dev/null +++ b/Agent/src/tools/search_workspace.js @@ -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 }; diff --git a/Agent/src/tools/web_search.js b/Agent/src/tools/web_search.js new file mode 100644 index 0000000..6ececd8 --- /dev/null +++ b/Agent/src/tools/web_search.js @@ -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 }; diff --git a/Agent/src/ui/banner.js b/Agent/src/ui/banner.js new file mode 100644 index 0000000..ba6f2d7 --- /dev/null +++ b/Agent/src/ui/banner.js @@ -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 }; diff --git a/Agent/src/ui/command_menu.js b/Agent/src/ui/command_menu.js new file mode 100644 index 0000000..4bc0fe3 --- /dev/null +++ b/Agent/src/ui/command_menu.js @@ -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 }; diff --git a/Agent/src/ui/indented_writer.js b/Agent/src/ui/indented_writer.js new file mode 100644 index 0000000..6c36b18 --- /dev/null +++ b/Agent/src/ui/indented_writer.js @@ -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 }; diff --git a/Agent/src/ui/resume_menu.js b/Agent/src/ui/resume_menu.js new file mode 100644 index 0000000..eaa69e3 --- /dev/null +++ b/Agent/src/ui/resume_menu.js @@ -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 }; diff --git a/Agent/src/ui/select_prompt.js b/Agent/src/ui/select_prompt.js new file mode 100644 index 0000000..a78ef8a --- /dev/null +++ b/Agent/src/ui/select_prompt.js @@ -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 }; diff --git a/Agent/src/ui/spinner.js b/Agent/src/ui/spinner.js new file mode 100644 index 0000000..7265c10 --- /dev/null +++ b/Agent/src/ui/spinner.js @@ -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 }; diff --git a/Agent/src/ui/status_bar.js b/Agent/src/ui/status_bar.js new file mode 100644 index 0000000..cb23335 --- /dev/null +++ b/Agent/src/ui/status_bar.js @@ -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 }; diff --git a/Agent/src/utils/colors.js b/Agent/src/utils/colors.js new file mode 100644 index 0000000..a99ca72 --- /dev/null +++ b/Agent/src/utils/colors.js @@ -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' : '', +}; diff --git a/Agent/src/utils/text_width.js b/Agent/src/utils/text_width.js new file mode 100644 index 0000000..a59ca79 --- /dev/null +++ b/Agent/src/utils/text_width.js @@ -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, +}; diff --git a/Agent/src/utils/time.js b/Agent/src/utils/time.js new file mode 100644 index 0000000..71d4de2 --- /dev/null +++ b/Agent/src/utils/time.js @@ -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 }; diff --git a/Agent/src/utils/token_usage.js b/Agent/src/utils/token_usage.js new file mode 100644 index 0000000..d1cf130 --- /dev/null +++ b/Agent/src/utils/token_usage.js @@ -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 }; diff --git a/Makefile b/Makefile index aeebc39..57f1a22 100644 --- a/Makefile +++ b/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) diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 3788034..9d1d861 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -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? @@ -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) + } + } } diff --git a/Sources/Core/AgentBridge.swift b/Sources/Core/AgentBridge.swift index bb851ee..180b80d 100644 --- a/Sources/Core/AgentBridge.swift +++ b/Sources/Core/AgentBridge.swift @@ -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", diff --git a/Sources/Core/NotchDisplayManager.swift b/Sources/Core/NotchDisplayManager.swift index 9d68a98..cae6514 100644 --- a/Sources/Core/NotchDisplayManager.swift +++ b/Sources/Core/NotchDisplayManager.swift @@ -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() { diff --git a/Sources/UI/AgentProgressTracker.swift b/Sources/UI/AgentProgressTracker.swift index e74f122..4dee226 100644 --- a/Sources/UI/AgentProgressTracker.swift +++ b/Sources/UI/AgentProgressTracker.swift @@ -24,6 +24,7 @@ final class AgentProgressTracker: ObservableObject { func start() { startTime = Date() + elapsedSeconds = 0 mode = "thinking" toolEntries = [] finalContent = "" diff --git a/Sources/UI/SettingsWindow.swift b/Sources/UI/SettingsWindow.swift index 831dd6c..2b3c006 100644 --- a/Sources/UI/SettingsWindow.swift +++ b/Sources/UI/SettingsWindow.swift @@ -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) diff --git a/Sources/Utils/Config.swift b/Sources/Utils/Config.swift index 83b6c80..0e33ca6 100644 --- a/Sources/Utils/Config.swift +++ b/Sources/Utils/Config.swift @@ -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" } diff --git a/scripts/create_app_bundle.sh b/scripts/create_app_bundle.sh index deb4c66..e99db86 100755 --- a/scripts/create_app_bundle.sh +++ b/scripts/create_app_bundle.sh @@ -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 引用图标