diff --git a/.env.example b/.env.example index 8162668..df02200 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,8 @@ # ────────────────────────────────────────────── # 管理面板配置 +# 注:管理面板已移除账号 / 密码鉴权,请通过网络层(防火墙 / 反向代理)控制访问 # ────────────────────────────────────────────── ADMIN_PORT=4098 -# ADMIN_PASSWORD 可选,首次访问 Web 管理面板时会提示设置密码 -# ADMIN_PASSWORD=your_admin_password_here # ────────────────────────────────────────────── # 飞书配置 diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 3d2b892..14d3fbd 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -48,10 +48,14 @@ jobs: with: python-version: '3.11' - - name: Install Xcode tools (macOS) + - name: Setup build tools (macOS) if: matrix.platform == 'mac' run: | - xcode-select --print-path || xcode-select --install 2>/dev/null || true + # xcode-select --install opens a GUI dialog and always fails in CI. + # GitHub macOS runners already have CLT pre-installed; just verify. + xcode-select --print-path + xcrun --show-sdk-path + echo "SDKROOT=$(xcrun --show-sdk-path)" >> $GITHUB_ENV - name: Install pnpm run: npm install -g pnpm @@ -81,13 +85,16 @@ jobs: run: pnpm run build:all - name: Rebuild native modules for Electron - env: - npm_config_python: python3 + shell: bash run: | echo "Node version: $(node --version)" echo "npm version: $(npm --version)" - echo "Python version: $(python3 --version)" - pnpm run rebuild + PYTHON_BIN=$(command -v python3 || command -v python) + echo "Python: $PYTHON_BIN" + "$PYTHON_BIN" --version + ELECTRON_VERSION=$(node -p "require('./node_modules/electron/package.json').version") + echo "Rebuilding better-sqlite3 against Electron $ELECTRON_VERSION" + pnpm exec electron-rebuild -f -w better-sqlite3 --version "$ELECTRON_VERSION" --python "$PYTHON_BIN" - name: Build Electron app (Windows) if: matrix.platform == 'win' diff --git a/.gitignore b/.gitignore index 6b69669..d97bdf5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,12 +15,19 @@ release/ data/config.db data/ +# 临时文档和计划文件 +plan*.md +PLAN*.md +PLAN.md +.codex + # 前端依赖 web/node_modules/ # 日志文件 logs/ *.log +.codex-logs/ # 会话数据(用户隐私) .session-*.json diff --git a/.npmrc b/.npmrc index 48ca9a8..7ead932 100644 --- a/.npmrc +++ b/.npmrc @@ -1,5 +1,5 @@ # 自动生成的镜像配置 (by setup-mirror.mjs) -# 生成时间: 2026-03-29T13:27:10.446Z +# 生成时间: 2026-05-02T14:27:14.613Z # npm registry registry=https://mirrors.huaweicloud.com/repository/npm/ @@ -13,3 +13,82 @@ better_sqlite3_binary_host=https://registry.npmmirror.com/-/binary/better-sqlite # puppeteer chromium 镜像 puppeteer_download_host=https://registry.npmmirror.com/-/binary/chromium-browser-snapshots + +# 用户自定义配置 +# npm registry + +# sharp 二进制镜像 + +# better-sqlite3 二进制镜像 + +# puppeteer chromium 镜像 + +# 用户自定义配置 +# npm registry + +# sharp 二进制镜像 + +# better-sqlite3 二进制镜像 + +# puppeteer chromium 镜像 + +# 用户自定义配置 +# npm registry + +# sharp 二进制镜像 + +# better-sqlite3 二进制镜像 + +# puppeteer chromium 镜像 + +# 用户自定义配置 +# npm registry + +# sharp 二进制镜像 + +# better-sqlite3 二进制镜像 + +# puppeteer chromium 镜像 + +# 用户自定义配置 +# npm registry + +# sharp 二进制镜像 + +# better-sqlite3 二进制镜像 + +# puppeteer chromium 镜像 + +# 用户自定义配置 +# npm registry + +# sharp 二进制镜像 + +# better-sqlite3 二进制镜像 + +# puppeteer chromium 镜像 + +# 用户自定义配置 +# npm registry + +# sharp 二进制镜像 + +# better-sqlite3 二进制镜像 + +# puppeteer chromium 镜像 + +# 用户自定义配置 +# npm registry + +# sharp 二进制镜像 + +# better-sqlite3 二进制镜像 + +# puppeteer chromium 镜像 + +# 用户自定义配置 +# 强制 pnpm 生成 npm 兼容的扁平 node_modules(避免 .pnpm/ 符号链接结构)。 +# 原因:Electron 打包后 backend 以 ELECTRON_RUN_AS_NODE 模式从 resources/app/ 下运行, +# 通过 extraResources 拷贝的 node_modules 必须是扁平结构才能被 Node 的模块解析器正常向上查找; +# pnpm 的默认 isolated 链接器会让传递依赖丢失,导致 Cannot find module 报错。 +node-linker=hoisted diff --git a/README-en.md b/README-en.md index e12110f..6b927e6 100644 --- a/README-en.md +++ b/README-en.md @@ -1,6 +1,6 @@ # OpenCode Bridge -[![v2.9.59](https://img.shields.io/badge/v2.9.59-3178C6)]() +[![v3.1.0](https://img.shields.io/badge/v3.1.0-3178C6)]() [![Node.js >= 20](https://img.shields.io/badge/Node.js-%3E%3D20-339933?logo=node.js&logoColor=white)](https://nodejs.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![License: GPLv3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) @@ -9,7 +9,20 @@ --- -> **OpenCode Bridge** is an enterprise-grade AI programming collaboration bridge service that seamlessly integrates OpenCode (AI coding assistant) with mainstream instant messaging platforms, enabling cross-platform, cross-device intelligent programming collaboration. +> **OpenCode Bridge** is an enterprise-grade full-featured OpenCode wrapper application that seamlessly integrates OpenCode's AI programming capabilities and intelligent conversation capabilities into mainstream instant messaging platforms, enabling a unified cross-platform, cross-device intelligent collaboration experience. + +--- + +## 🎯 Project Positioning + +**OpenCode Bridge** is not just a simple message bridge, but a complete OpenCode wrapper application: + +- **🤖 AI Programming Assistant**: Full access to OpenCode's intelligent programming capabilities, supporting code generation, debugging, refactoring, and more +- **💬 Intelligent Conversation System**: Integrated Chat capabilities, providing natural language interaction, knowledge Q&A, task assistance, and other conversational services +- **🔌 Full Platform Adaptation**: One codebase supports 8 major mainstream communication platforms with unified interaction management +- **⚙️ Programmatic Bridge**: Deeply integrated with OpenCode SDK, implementing complete functionality including session management, permission control, and file transfer + +Unlike simple message forwarding, OpenCode Bridge provides a complete OpenCode experience wrapper, allowing users to get native OpenCode functionality on any platform. --- @@ -33,72 +46,189 @@ | Feature | Feishu | Discord | WeCom | Telegram | QQ | WhatsApp | WeChat | DingTalk | |---------|:------:|:-------:|:-----:|:--------:|:--:|:--------:|:------:|:--------:| | Text Message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Rich Media/Card | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | +| Rich Media/Card | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | | Streaming Output | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Permission Interaction | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | File Transfer | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | | Group Chat | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Private Chat | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Message Recall | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | +| Message Recall | ✅ | ✅ | ⚠️ | ✅ | ✅ | ✅ | ⚠️ | ✅ | +| +**> ⚠️ Partial Support**: WeCom and WeChat cannot recall platform messages, but the `/undo` command can revert OpenCode session and send a notification message. --- ## ✨ Key Features ### 🔄 Smart Session Management + - **Independent Session Binding**: Each group/private chat binds to an independent OpenCode session with isolated context - **Session Migration**: Support session binding, migration, and renaming with context preserved across devices - **Multi-Project Support**: Multiple project directory switching with alias configuration - **Auto Cleanup**: Automatic cleanup of invalid sessions to prevent resource leaks -### 🤖 AI Interaction Capabilities +### 🤖 AI Programming Capabilities + +- **Intelligent Code Generation**: Support multi-language code generation with real-time syntax highlighting +- **Code Debugging & Analysis**: Automatic error location with fix suggestions +- **Project Context Understanding**: Intelligent analysis based on complete project codebase +- **Shell Command Execution**: Whitelisted commands can be executed directly in chat +- **File Operations**: AI can read/write project files, supporting code refactoring + +### 💬 Intelligent Conversation System + +- **Natural Language Interaction**: Support multi-turn conversations with complex semantic understanding +- **Knowledge Q&A**: Intelligent Q&A based on OpenCode knowledge base +- **Task Assistance**: Provide task decomposition, step guidance, and other auxiliary functions +- **Context Memory**: Cross-session context preservation and memory management + +### 🔌 Deep Integration Capabilities + - **Streaming Output**: Real-time AI response display with thinking chain support - **Permission Interaction**: AI permission requests confirmed within the chat platform - **Question Answering**: AI questions answered within the chat platform - **File Transfer**: AI can send files/screenshots to the chat platform -- **Shell Passthrough**: Whitelisted commands can be executed directly in chat +- **Multimodal Support**: Support images, documents, and other formats ### 🛡️ Reliability Assurance + - **Heartbeat Monitoring**: Periodic OpenCode health probing - **Auto Rescue**: Automatic restart and recovery when OpenCode crashes - **Cron Tasks**: Runtime dynamic management of scheduled tasks - **Log Auditing**: Complete operation logs and error tracking -### 🎛️ Web Management Panel -- **Visual Configuration**: Real-time modification of all configuration parameters in browser -- **Platform Management**: View connection status of each platform -- **Cron Management**: Create, enable/disable, delete scheduled tasks -- **Service Control**: View service status and remote restart +### 🎛️ Three Configuration Entries (Web / TUI / Config File) + +- **🌐 Web Management Panel**: Real-time visual configuration in the browser — platforms, cron, service control all in one place +- **🧙 First-run Onboarding**: First Web visit pops a guided wizard (language → pick a starter platform → driver.js highlight tour over the left sidebar). Skippable and won't reappear; use the top-right "Help" menu anytime to reopen README and platform docs +- **💻 TUI Terminal Wizard**: `opencode-bridge` / `opencode-bridge init` — full configuration in a pure CLI environment (Chinese + English, sharing the same SQLite config file with the web UI) +- **🔌 Decoupled web toggle**: turn off the web admin from the TUI while keeping platform adapters running (suited for hardened intranet deployments) +- **🆓 No login, no password**: the admin panel ships without account/password auth — secure access at the network layer (firewall / reverse proxy) instead + +--- + +## 🏗️ Architecture Overview + +### System Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ 📱 Platform Adapter Layer │ +│ Feishu | Discord | WeCom | Telegram | QQ | │ +│ WhatsApp | WeChat | DingTalk │ +└──────────────────────┬──────────────────────────────┘ + │ Unified Message Format +┌──────────────────────▼──────────────────────────────┐ +│ ⚙️ Core Processing Layer │ +│ RootRouter → Session Mgmt / Permission / Q&A │ +│ Programming / Chat / Output Buffer │ +└──────────────────────┬──────────────────────────────┘ + │ +┌──────────────────────▼──────────────────────────────┐ +│ 🔗 Integration Layer │ +│ OpenCode Client SDK │ +│ (Programming API + Chat API + Session Mgmt) │ +└──────────────────────┬──────────────────────────────┘ + │ +┌──────────────────────▼──────────────────────────────┐ +│ 🌐 OpenCode Core │ +│ AI Programming Service | Chat Service | CLI Tools │ +└─────────────────────────────────────────────────────┘ +``` + +### Architecture Description + +| Layer | Responsibility | Key Components | +|-------|----------------|----------------| +| 📱 Platform Adapter Layer | Receive messages from each platform, unified format conversion | 8 Platform Adapters | +| ⚙️ Core Processing Layer | Message routing, session management, business processing | RootRouter, SessionManager, Permission, Question | +| 🔗 Integration Layer | Deep integration with OpenCode, complete functionality calls | OpencodeClient SDK (Programming + Chat) | +| 🌐 OpenCode Core | AI services, conversation services, toolchain | OpenCode Full-Featured Service | + +### Comparison with Traditional Bridge + +| Feature | Traditional Message Bridge | OpenCode Bridge | +|---------|---------------------------|-----------------| +| Function Scope | Message Forwarding | Complete Feature Wrapper | +| Session Management | Simple Mapping | Deep Integration | +| Capability Support | Single AI | Programming + Chat Dual Capabilities | +| Permission Control | None | Complete Permission Interaction System | +| File Operations | None | Support File Read/Write/Transfer | +| Extensibility | Limited | Support Plugin-based Extensions | --- ## 🚀 Quick Start -### Desktop App (Recommended) +### Desktop App (Windows / macOS, Recommended) -Windows and macOS users can download installers directly: +Download the installer for your platform from [GitHub Releases](https://github.com/HNGM-HP/opencode-bridge/releases): -| Platform | Download | -|----------|----------| -| Windows | Download `.exe` installer from [GitHub Releases](https://github.com/HNGM-HP/opencode-bridge/releases) | -| macOS | Download `.dmg` installer from [GitHub Releases](https://github.com/HNGM-HP/opencode-bridge/releases) | +| Platform | Installer | Notes | +|----------|-----------|-------| +| Windows | `.exe` | Double-click to install. If "unrecognized app" appears, click "Run anyway" | +| macOS | `.dmg` | Drag to Applications. First launch: right-click → Open | -**Installation Notes:** -- **Windows**: Double-click `.exe` installer and follow the wizard. If you see "unrecognized app" warning, select "Run anyway" -- **macOS**: Double-click `.dmg` to open, drag the app to Applications. First launch requires right-click and select "Open" +The app launches your browser at `http://localhost:4098` automatically. **On first visit a guided onboarding wizard pops up**: -After installation, start the app and access `http://localhost:4098` to configure platforms. +1. Choose UI language (中文 / English) +2. Pick a starter platform to connect (skippable) +3. driver.js highlight tour over the left navigation (skippable, won't reappear) -### Source Deployment (Linux / Developers) +After onboarding, the top-right "Help" menu lets you reopen the README and platform-specific docs anytime. -#### 1. Clone Repository +--- + +### NPM Install (Linux / Server / Headless) + +```bash +npm install -g opencode-bridge +``` + +> Or `npx opencode-bridge` to run without installation. + +#### Subcommands + +| Command | What it does | +|---------|--------------| +| `opencode-bridge` | **First run** → enters the TUI wizard; **already configured** → starts the bridge service | +| `opencode-bridge init` | Force re-enter the TUI wizard (re-configure / edit any setting) | +| `opencode-bridge start` | Skip the wizard and start the service immediately | +| `opencode-bridge --config-dir /path` | Override config directory (default `./data`) | +| `opencode-bridge --version` / `--help` | Show version / usage | + +#### TUI wizard flow + +1. **Language selection** (中文 / English, persisted) +2. **How would you like to configure?** + - Configure here in the terminal (recommended for headless) + - Launch the web admin UI and configure in a browser + - Skip — start the service now + - Show help / documentation +3. **Polling main menu**: pick the primary platform → enable/disable platforms & set credentials → OpenCode connection → group behaviour & allow-list → reliability / cron / heartbeat → output display → web admin on/off → help → save & start service / exit + +> The TUI and the Web panel share the same SQLite store (`data/config.db`); changes on either side are visible immediately on the other. + +#### Run platforms without exposing the web UI + +Toggle "Web admin UI" off in the TUI, or use the env var: + +```bash +BRIDGE_DISABLE_ADMIN=1 opencode-bridge start +``` + +Suited for hardened intranets — platform adapters keep relaying messages while the web port stays closed. + +--- + +### Source Deployment (Developers) ```bash git clone https://github.com/HNGM-HP/opencode-bridge.git cd opencode-bridge ``` -#### 2. One-Click Deployment +#### One-Click Deployment **Linux/macOS:** ```bash @@ -111,30 +241,22 @@ chmod +x ./scripts/deploy.sh .\scripts\deploy.ps1 ``` -This command will automatically: -- Detect and guide Node.js installation -- Detect and guide OpenCode installation -- Install project dependencies and compile -- Generate initial configuration file +This will automatically: detect Node.js / OpenCode → install dependencies and compile → generate config file. -#### 3. Start Service +#### Start Service -**Linux/macOS:** ```bash +# Linux/macOS ./scripts/start.sh -``` -**Windows PowerShell:** -```powershell +# Windows PowerShell .\scripts\start.ps1 -``` -**Development Mode:** -```bash +# Development Mode npm run dev ``` -#### 4. Configure Platform +--- After service starts, access the Web configuration panel: @@ -142,7 +264,102 @@ After service starts, access the Web configuration panel: http://localhost:4098 ``` -You will be prompted to set an administrator password on first access. +> The admin panel ships without account/password authentication. Make sure port 4098 is exposed only on a trusted network, or front it with a reverse proxy / firewall. + +--- + +## ❓ Common Installation Issues + +### macOS: "App is damaged" Error + +**Problem**: +``` +"OpenCode Bridge" is damaged and can't be opened. You should move it to the Trash. +``` + +**Reason**: +- macOS security mechanism (Gatekeeper) blocks unsigned apps +- This is a free open-source project without Apple Developer certificate + +**Solutions** (choose one): + +#### Method 1: Right-click to Open (Recommended) +``` +1. Right-click on "OpenCode Bridge.app" +2. Hold the "Option" key on your keyboard +3. Double-click the "Open" button +4. Click "Open" in the confirmation dialog +``` + +#### Method 2: System Settings Override +``` +1. Open "System Settings" → "Privacy & Security" +2. Find the "OpenCode Bridge was blocked" message +3. Click "Open Anyway" +``` + +#### Method 3: Command Line Remove Quarantine +```bash +# Execute in Terminal (replace with actual path) +xattr -cr /Applications/OpenCode\ Bridge.app +``` + +**After this one-time operation**, you can launch normally by double-clicking. + +--- + +### Windows: "Unrecognized App" Warning + +**Problem**: +``` +Windows protected your PC +Microsoft Defender SmartScreen blocked an unrecognized app +``` + +**Solution**: +1. Click "More info" +2. Click "Run anyway" + +**Note**: This is normal Windows Defender protection for unsigned apps. Confirm once and it will run normally. + +--- + +### Can't Access Management Panel After Launch + +**Troubleshooting Steps**: + +1. **Check if app is running**: + - **Windows**: Look for OpenCode Bridge icon in system tray (bottom-right) + - **macOS**: Look for icon in top menu bar + +2. **Manually open management panel**: + ``` + Visit in browser: http://localhost:4098 + ``` + +3. **Check port usage**: + ```bash + # Windows PowerShell + netstat -ano | findstr :4098 + + # macOS/Linux + lsof -i :4098 + ``` + +4. **Check whether the web admin was disabled**: the TUI can disable the web panel. To re-enable, run `opencode-bridge init` and toggle "Web admin UI" back on, or make sure neither `WEB_ADMIN_DISABLED=true` nor `BRIDGE_DISABLE_ADMIN=1` is set. + +5. **View log files**: + - **Windows**: `%APPDATA%/opencode-bridge/logs/` + - **macOS**: `~/Library/Application Support/opencode-bridge/logs/` + +--- + +### Other Issues + +If you encounter other issues: +1. Check [Troubleshooting Guide](./assets/docs/troubleshooting.md) +2. Search similar issues in [GitHub Issues](https://github.com/HNGM-HP/opencode-bridge/issues) +3. Submit a new Issue with error logs --- @@ -199,71 +416,6 @@ The following commands are available on all platforms: --- -## 🏗️ Architecture Overview - -### System Architecture Diagram - -```mermaid -flowchart LR - %% Style definitions - classDef platform fill:#e1f5fe,stroke:#0288d1,stroke-width:2px,rx:8px - classDef core fill:#fff3e0,stroke:#f57c00,stroke-width:2px,rx:8px - classDef handler fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,rx:8px - classDef opencode fill:#e8f5e9,stroke:#388e3c,stroke-width:2px,rx:8px - classDef external fill:#fce4ec,stroke:#c2185b,stroke-width:2px,rx:8px,stroke-dasharray:5 5 - - subgraph PlatformLayer["📱 Platform Adapter Layer"] - direction TB - feishu["✈️ Feishu"]:::platform - discord["🎮 Discord"]:::platform - wecom["💼 WeCom"]:::platform - telegram["📤 Telegram"]:::platform - qq["🐧 QQ"]:::platform - whatsapp["📞 WhatsApp"]:::platform - weixin["💬 WeChat"]:::platform - dingtalk["📌 DingTalk"]:::platform - end - - subgraph CoreLayer["⚙️ Core Processing Layer"] - direction TB - router["🔀 Router Center
RootRouter"]:::core - - subgraph Handlers["Handler Modules"] - direction LR - permission["🔐 Permission Handler"]:::handler - question["❓ Q&A Handler"]:::handler - output["📤 Output Buffer"]:::handler - end - end - - subgraph IntegrationLayer["🔗 Integration Layer"] - sdk["🔌 OpenCode SDK
OpencodeClient"]:::opencode - end - - subgraph External["🌐 External Services"] - opencode["🤖 OpenCode Service"]:::external - cli["💻 OpenCode CLI"]:::external - end - - %% Connections - PlatformLayer --> router - router --> Handlers - Handlers --> sdk - sdk --> opencode - opencode -.-> cli -``` - -**Architecture Description:** - -| Layer | Responsibility | Key Components | -|-------|----------------|----------------| -| 📱 Platform Adapter Layer | Receive messages from each platform, unified format conversion | 8 Platform Adapters | -| ⚙️ Core Processing Layer | Message routing, permission validation, business processing | RootRouter, Permission, Question, Output | -| 🔗 Integration Layer | Communicate with OpenCode, send/receive requests | OpencodeClient SDK | -| 🌐 External Services | Actual AI service and CLI tools | OpenCode Service, CLI | - ---- - ## 📚 Documentation ### Core Documentation @@ -316,9 +468,10 @@ flowchart LR | Method | Description | |--------|-------------| -| Web Panel (Recommended) | Access `http://localhost:4098` for visual configuration | -| SQLite Database | Configuration stored in `data/config.db` | -| .env File | Only stores Admin panel startup parameters | +| Web Panel | Access `http://localhost:4098` for visual configuration (recommended in GUI environments) | +| TUI Terminal Wizard | Run `opencode-bridge init` for an offline polling menu (recommended in headless environments) | +| SQLite Database | Configuration stored in `data/config.db`, shared by both the Web and the TUI | +| `.env` File | Only used as a first-time migration source; runtime config lives in SQLite | ### Core Configuration Options @@ -328,34 +481,23 @@ flowchart LR | `DISCORD_ENABLED` | `false` | Enable Discord adapter | | `OPENCODE_HOST` | `localhost` | OpenCode host address | | `OPENCODE_PORT` | `4096` | OpenCode port | -| `ADMIN_PORT` | `4098` | Web configuration panel port | +| `ADMIN_PORT` | `4098` | Web admin panel port | +| `WEB_ADMIN_DISABLED` | `false` | When `true`, skip starting the web admin (platform adapters keep running) | +| `CLI_LANG` | `zh` / `en` | TUI wizard language preference (asked on first run, then persisted) | For complete configuration parameters, refer to the [Configuration Center Documentation](assets/docs/environment-en.md). --- -## 📄 License - -This project is licensed under [GNU General Public License v3.0](LICENSE) - -**GPL v3 means:** -- ✅ Free to use, modify and distribute -- ✅ Can be used for commercial purposes -- ✅ Must open source modified versions -- ✅ Must retain original author copyright -- ✅ Derivative works must use GPL v3 license - ---- - ## 🌟 Contributing If this project helps you, please give it a Star! -For issues or suggestions, feel free to submit an [Issue](https://github.com/HNGM-HP/opencode-bridge/issues) or [Pull Request](https://github.com/HNGM-HP/opencode-bridge/pulls). +[![Star History Chart](https://api.star-history.com/svg?repos=HNGM-HP/opencode-bridge&type=github&theme=hand-drawn)](https://star-history.com/#HNGM-HP/opencode-bridge&Date) --- -## 📞 Support +## 📄 License + +This project is licensed under [GNU General Public License v3.0](LICENSE) -- **GitHub Issues**: [Report Issues](https://github.com/HNGM-HP/opencode-bridge/issues) -- **Project Home**: [GitHub Repository](https://github.com/HNGM-HP/opencode-bridge) diff --git a/README.md b/README.md index 967d04b..56106cc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenCode Bridge -[![v2.9.59](https://img.shields.io/badge/v2.9.59-3178C6)](https://github.com/HNGM-HP/opencode-bridge/blob/main) +[![v3.1.0](https://img.shields.io/badge/v3.1.0-760031c)](https://github.com/HNGM-HP/opencode-bridge/blob/main) [![Node.js >= 20](https://img.shields.io/badge/Node.js-%3E%3D20-339933?logo=node.js&logoColor=white)](https://nodejs.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![License: GPLv3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) @@ -9,7 +9,20 @@ --- -**OpenCode Bridge** 是一款企业级 AI 编程协作桥接服务,将 OpenCode(AI 编程助手)无缝接入主流即时通讯平台,实现跨平台、跨设备的智能编程协作体验。 +> **OpenCode Bridge** 是一款将 OpenCode 的 AI 编程能力与智能对话能力无缝集成到主流即时通讯平台,实现跨平台、跨设备的一体化智能协作体验。 + +--- + +## 🎯 项目定位 + +**OpenCode Bridge** 不仅仅是一个简单的消息桥接工具,而是一个完整的 OpenCode 套壳应用: + +- **🤖 AI 编程助手**:完整接入 OpenCode 的智能编程能力,支持代码生成、调试、重构等功能 +- **💬 智能对话系统**:集成 Chat 能力,提供自然语言交互、知识问答、任务协助等对话式服务 +- **🔌 全平台适配**:一套代码支持 8 大主流通讯平台,统一管理所有交互 +- **⚙️ 程序化桥接**:深度集成 OpenCode SDK,实现会话管理、权限控制、文件传输等完整功能 + +与简单的消息转发不同,OpenCode Bridge 提供了完整的 OpenCode 体验套壳,让用户在任何平台上都能获得原生 OpenCode 的功能体验。 --- @@ -33,13 +46,15 @@ | 功能 | 飞书 | Discord | 企业微信 | Telegram | QQ | WhatsApp | 微信 | 钉钉 | |------|------|---------|---------|---------|-----|---------|------|------| | 文本消息 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| 富媒体/卡片 | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | +| 富媒体/卡片 | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | | 流式输出 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | 权限交互 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | 文件传输 | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | | 群聊支持 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | 私聊支持 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| 消息撤回 | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | +| 消息撤回 | ✅ | ✅ | ⚠️ | ✅ | ✅ | ✅ | ⚠️ | ✅ | +| +**> ⚠️ 部分支持说明**:企业微信、微信不支持撤回平台消息,但 `/undo` 命令可撤回 OpenCode 侧会话并发送提示消息。 --- @@ -52,13 +67,28 @@ - **多项目支持**:支持多项目目录切换及项目别名配置 - **自动清理**:自动回收无效会话,防止资源泄漏 -### 🤖 AI 交互能力 +### 🤖 AI 编程能力 + +- **智能代码生成**:支持多语言代码生成,实时语法高亮 +- **代码调试与分析**:自动错误定位,提供修复建议 +- **项目上下文理解**:基于完整项目代码库的智能分析 +- **Shell 命令执行**:白名单命令可直接在聊天中执行 +- **文件操作**:AI 可读写项目文件,支持代码重构 + +### 💬 智能对话系统 + +- **自然语言交互**:支持多轮对话,理解复杂语义 +- **知识问答**:基于 OpenCode 知识库的智能问答 +- **任务协助**:提供任务分解、步骤指导等辅助功能 +- **上下文记忆**:跨会话的上下文保持与记忆管理 + +### 🔌 深度集成能力 - **流式输出**:实时显示 AI 响应,支持思维链可视化 - **权限交互**:AI 权限请求直接在聊天平台内完成确认 - **问题回答**:AI 提问可在聊天平台内直接作答 - **文件传输**:AI 可将文件或截图主动发送至聊天平台 -- **Shell 透传**:白名单命令可直接在聊天中执行 +- **多模态支持**:支持图片、文档等多种格式 ### 🛡️ 可靠性保障 @@ -67,18 +97,20 @@ - **Cron 任务**:支持运行时动态管理定时任务 - **日志审计**:完整的操作日志与错误追踪记录 -### 🎛️ Web 管理面板 +### 🎛️ 三套配置入口(Web / TUI / 配置文件) -- **可视化配置**:通过浏览器实时修改所有配置参数 -- **平台管理**:查看各平台的连接状态 -- **Cron 管理**:创建、启用/禁用及删除定时任务 -- **服务控制**:查看服务运行状态,支持远程重启 +- **🌐 Web 管理面板**:浏览器可视化配置所有参数,平台 / Cron / 服务控制一站式 +- **🧙 首次安装引导**:首次访问 Web 自动弹出向导(语言 → 选一个初始平台 → 基于 driver.js 的左侧菜单高亮气泡讲解),可跳过且不再打扰;右上角"帮助"菜单随时回看 README 与平台文档链接 +- **💻 TUI 终端向导**:`opencode-bridge` / `opencode-bridge init` 在纯 CLI 环境下即可完成全部配置(中英双语,与 Web 共用同一份 SQLite 配置文件) +- **🔌 平台开关解耦**:可在 TUI 内单独关闭 Web 面板而保留接入平台运行(适合内网安全场景) +- **🆓 无登录无密码**:管理后台不再设置账号 / 密码,部署侧请通过防火墙 / 反向代理控制访问 ---
🖼️ Web 可视化界面截图(点击展开) +![web0](./assets/demo/web0.png) ![web1](./assets/demo/web1.png) ![web2](./assets/demo/web2.png) ![web3](./assets/demo/web3.png) @@ -93,31 +125,128 @@ --- +## 🏗️ 架构概览 + +### 系统架构 + +``` +┌─────────────────────────────────────────────────────┐ +│ 📱 平台适配层 │ +│ 飞书 | Discord | 企业微信 | Telegram | QQ | │ +│ WhatsApp | 微信 | 钉钉 │ +└──────────────────────┬──────────────────────────────┘ + │ 统一消息格式 +┌──────────────────────▼──────────────────────────────┐ +│ ⚙️ 核心处理层 │ +│ RootRouter → 会话管理 / 权限处理 / 问题作答 │ +│ 编程能力 / 对话能力 / 输出缓冲 │ +└──────────────────────┬──────────────────────────────┘ + │ +┌──────────────────────▼──────────────────────────────┐ +│ 🔗 集成层 │ +│ OpenCode Client SDK │ +│ (编程接口 + 对话接口 + 会话管理) │ +└──────────────────────┬──────────────────────────────┘ + │ +┌──────────────────────▼──────────────────────────────┐ +│ 🌐 OpenCode 核心 │ +│ AI 编程服务 | Chat 对话服务 | CLI 工具链 │ +└─────────────────────────────────────────────────────┘ +``` + +### 架构说明 + +| 层级 | 职责 | 关键组件 | +|------|------|----------| +| 📱 平台适配层 | 接收各平台消息,统一格式转换 | 8 个平台适配器 | +| ⚙️ 核心处理层 | 消息路由、会话管理、业务处理 | RootRouter、SessionManager、Permission、Question | +| 🔗 集成层 | 与 OpenCode 深度集成,完整功能调用 | OpencodeClient SDK (编程 + 对话) | +| 🌐 OpenCode 核心 | AI 服务、对话服务、工具链 | OpenCode 全功能服务 | + +### 与传统桥接的区别 + +| 特性 | 传统消息桥接 | OpenCode Bridge | +|------|-------------|-----------------| +| 功能范围 | 消息转发 | 完整功能套壳 | +| 会话管理 | 简单映射 | 深度集成会话系统 | +| 能力支持 | 单一 AI | 编程 + 对话双能力 | +| 权限控制 | 无 | 完整权限交互体系 | +| 文件操作 | 无 | 支持文件读写传输 | +| 可扩展性 | 有限 | 支持插件化扩展 | + +--- + ## 🚀 快速开始 -### 桌面应用(推荐) +### 桌面应用(Windows / macOS,推荐) -Windows 和 macOS 用户可直接在 [GitHub Releases](https://github.com/HNGM-HP/opencode-bridge/releases) 下载对应安装包: +在 [GitHub Releases](https://github.com/HNGM-HP/opencode-bridge/releases) 下载对应安装包: | 平台 | 安装包 | 说明 | |------|--------|------| | Windows | `.exe` | 双击安装,若提示"未识别应用"请选择"仍要运行" | | macOS | `.dmg` | 拖拽至 Applications,首次启动请右键选择"打开" | -安装完成后启动应用,访问 `http://localhost:4098` 进行平台配置。 +启动应用后会自动弹出浏览器到 `http://localhost:4098`,**首次访问会自动进入安装引导**: + +1. 选择界面语言(中文 / English) +2. 选择一个先要接入的平台(也可跳过) +3. driver.js 风格的左侧菜单高亮逐项讲解(可跳过,后续不再显示) + +引导完成后随时可在右上角的"帮助"菜单回看 README 与平台配置文档链接。 --- -### 源码部署(Linux / 开发者) +### NPM 安装部署(Linux / 服务器 / 无桌面环境) -#### 第一步:克隆项目 +```bash +npm install -g opencode-bridge +``` + +> 也可使用 `npx opencode-bridge` 免安装直接运行。 + +#### 子命令一览 + +| 命令 | 说明 | +|------|------| +| `opencode-bridge` | **首次运行**进入 TUI 交互式向导;**已配置**则直接启动桥接服务 | +| `opencode-bridge init` | 强制重新进入 TUI 向导(重新配置 / 修改任何项) | +| `opencode-bridge start` | 跳过向导,直接启动服务 | +| `opencode-bridge --config-dir /path` | 指定配置目录(默认 `./data`) | +| `opencode-bridge --version` / `--help` | 版本号 / 用法帮助 | + +#### TUI 向导流程 + +1. **选择语言**(中文 / English,偏好持久化) +2. **选择配置方式**: + - 在终端中通过 TUI 完成配置(推荐用于无桌面环境) + - 启动 Web 管理面板,在浏览器中配置 + - 跳过配置,直接启动服务 + - 查看帮助 / 文档 +3. **进入轮询主菜单**:选择初始接入平台 → 平台增删/凭据 → OpenCode 连接 → 群聊行为 / 白名单 → 可靠性 / Cron / 心跳 → 输出显示 → Web 管理面板启停 → 帮助 → 启动服务 / 退出 + +> TUI 与 Web 面板共用同一份 SQLite 配置(`data/config.db`),任意一侧修改对另一侧立即生效。 + +#### 仅启用平台不启用 Web 面板 + +在 TUI 的"Web 管理面板"菜单关闭,或临时通过环境变量: + +```bash +BRIDGE_DISABLE_ADMIN=1 opencode-bridge start +``` + +适用于内网安全场景:平台适配器照常收发消息,但不暴露 Web 端口。 + +--- + +### 源码部署(开发者) ```bash git clone https://github.com/HNGM-HP/opencode-bridge.git cd opencode-bridge ``` -#### 第二步:一键部署 +#### 一键部署 **Linux / macOS:** @@ -132,42 +261,125 @@ chmod +x ./scripts/deploy.sh .\scripts\deploy.ps1 ``` -部署脚本将自动完成以下操作: +部署脚本将自动完成:检测 Node.js / OpenCode → 安装依赖并编译 → 生成配置文件。 -- 检测并引导安装 Node.js -- 检测并引导安装 OpenCode -- 安装项目依赖并编译 -- 生成初始配置文件 - -#### 第三步:启动服务 - -**Linux / macOS:** +#### 启动服务 ```bash +# Linux / macOS ./scripts/start.sh + +# Windows PowerShell +.\scripts\start.ps1 + +# 开发模式 +npm run dev ``` -**Windows PowerShell:** +--- + +服务启动后,访问 Web 配置面板完成各平台接入配置: -```powershell -.\scripts\start.ps1 +``` +http://localhost:4098 +``` + +> 管理后台不再设置账号 / 密码,请确保 4098 端口仅在受信网络暴露,或通过反向代理 + 防火墙控制访问。 + +--- + +## ❓ 常见安装问题 + +### macOS 提示"已损坏" + +**问题现象**: +``` +"OpenCode Bridge" 已损坏,无法打开。你应该将它移到废纸篓。 ``` -**开发模式:** +**原因说明**: +- macOS 的安全机制(Gatekeeper)阻止了未签名的应用运行 +- 本项目为开源免费项目,未购买 Apple Developer 证书进行签名 +**解决方案**(任选其一): + +#### 方法 1:右键强制打开(推荐) +``` +1. 右键点击 "OpenCode Bridge.app" +2. 按住键盘上的 "Option" 键 +3. 双击 "打开" 按钮 +4. 在弹出对话框中点击 "打开" 确认 +``` + +#### 方法 2:系统设置解除限制 +``` +1. 打开 "系统设置" → "隐私与安全性" +2. 找到 "OpenCode Bridge 被阻止" 的提示 +3. 点击 "仍要打开" +``` + +#### 方法 3:命令行移除隔离属性 ```bash -npm run dev +# 在终端中执行(需要替换实际路径) +xattr -cr /Applications/OpenCode\ Bridge.app ``` -#### 第四步:配置平台 +**一次性操作后**,以后就可以正常双击启动了。 -服务启动后,访问 Web 配置面板完成各平台接入配置: +--- + +### Windows 提示"未识别的应用" +**问题现象**: ``` -http://localhost:4098 +Windows 已保护你的电脑 +Microsoft Defender SmartScreen 筛选器已阻止无法识别的应用启动 ``` -> 首次访问时系统将提示设置管理员密码。 +**解决方案**: +1. 点击 "更多信息" +2. 点击 "仍要运行" + +**原因说明**:这是 Windows Defender 的正常保护机制,对无签名的应用都会提示。确认后即可正常运行。 + +--- + +### 应用启动后无法访问管理面板 + +**排查步骤**: + +1. **检查应用是否运行**: + - **Windows**:查看系统托盘(右下角)是否有 OpenCode Bridge 图标 + - **macOS**:查看顶部菜单栏是否有图标 + +2. **手动打开管理面板**: + ``` + 在浏览器中访问:http://localhost:4098 + ``` + +3. **检查端口占用**: + ```bash + # Windows PowerShell + netstat -ano | findstr :4098 + + # macOS/Linux + lsof -i :4098 + ``` + +4. **检查 Web 是否被关闭**:在 TUI 中可手动关闭 Web 面板。如需重新打开,运行 `opencode-bridge init` 进入"Web 管理面板"菜单启用,或确认未设置 `WEB_ADMIN_DISABLED=true` / `BRIDGE_DISABLE_ADMIN=1`。 + +5. **查看日志文件**: + - **Windows**:`%APPDATA%/opencode-bridge/logs/` + - **macOS**:`~/Library/Application Support/opencode-bridge/logs/` + +--- + +### 其他问题 + +如果遇到其他问题,请: +1. 查看 [故障排查文档](./assets/docs/troubleshooting.md) +2. 在 [GitHub Issues](https://github.com/HNGM-HP/opencode-bridge/issues) 搜索类似问题 +3. 提交新的 Issue 并附上错误日志 --- @@ -222,40 +434,6 @@ http://localhost:4098 --- -## 🏗️ 架构概览 - -``` -┌─────────────────────────────────────────────────────┐ -│ 📱 平台适配层 │ -│ 飞书 | Discord | 企业微信 | Telegram | QQ | │ -│ WhatsApp | 微信 | 钉钉 │ -└──────────────────────┬──────────────────────────────┘ - │ 统一消息格式 -┌──────────────────────▼──────────────────────────────┐ -│ ⚙️ 核心处理层 │ -│ RootRouter → 权限处理 / 问题作答 / 输出缓冲 │ -└──────────────────────┬──────────────────────────────┘ - │ -┌──────────────────────▼──────────────────────────────┐ -│ 🔗 集成层 │ -│ OpencodeClient SDK │ -└──────────────────────┬──────────────────────────────┘ - │ -┌──────────────────────▼──────────────────────────────┐ -│ 🌐 外部服务 │ -│ OpenCode 服务 + OpenCode CLI │ -└─────────────────────────────────────────────────────┘ -``` - -| 层级 | 职责 | 关键组件 | -|------|------|----------| -| 📱 平台适配层 | 接收各平台消息,统一格式转换 | 8 个平台适配器 | -| ⚙️ 核心处理层 | 消息路由、权限验证、业务处理 | RootRouter、Permission、Question、Output | -| 🔗 集成层 | 与 OpenCode 通信,收发请求 | OpencodeClient SDK | -| 🌐 外部服务 | 实际 AI 服务与命令行工具 | OpenCode 服务、CLI | - ---- - ## 📚 文档导航 ### 核心文档 @@ -310,9 +488,10 @@ http://localhost:4098 | 方式 | 说明 | |------|------| -| Web 面板(推荐) | 访问 `http://localhost:4098` 进行可视化配置 | -| SQLite 数据库 | 配置存储于 `data/config.db` | -| `.env` 文件 | 仅存储 Admin 面板启动参数 | +| Web 面板 | 访问 `http://localhost:4098` 进行可视化配置(推荐有 GUI 环境) | +| TUI 终端向导 | `opencode-bridge init` 进入轮询菜单,离线可用(推荐无 GUI 环境) | +| SQLite 数据库 | 配置存储于 `data/config.db`,Web / TUI 共用同一份 | +| `.env` 文件 | 仅作首次迁移来源;运行时配置以 SQLite 为准 | ### 核心配置项 @@ -323,34 +502,24 @@ http://localhost:4098 | `OPENCODE_HOST` | `localhost` | OpenCode 服务地址 | | `OPENCODE_PORT` | `4096` | OpenCode 服务端口 | | `ADMIN_PORT` | `4098` | Web 配置面板监听端口 | +| `WEB_ADMIN_DISABLED` | `false` | 设为 `true` 启动时不开 Web 面板(仅运行平台适配器) | +| `CLI_LANG` | `zh` / `en` | TUI 向导语言偏好(首次运行自动询问后保存) | 完整配置参数请参考 [配置中心文档](./assets/docs/environment.md)。 --- -## 📄 许可证 - -本项目采用 [GNU General Public License v3.0](./LICENSE)。 +## 🌟 贡献与反馈 -GPL v3 的核心要义: +如果这个项目对你有帮助,欢迎点个 **Star** ⭐ 支持! -- ✅ 可自由使用、修改和分发 -- ✅ 可用于商业目的 -- ✅ 修改版本必须开源 -- ✅ 必须保留原作者版权声明 -- ✅ 衍生作品须采用 GPL v3 协议 +[![Star History Chart](https://api.star-history.com/svg?repos=HNGM-HP/opencode-bridge&type=github&theme=hand-drawn)](https://star-history.com/#HNGM-HP/opencode-bridge&Date) --- -## 🌟 贡献与反馈 - -如果这个项目对你有帮助,欢迎点个 **Star** ⭐ 支持! +## 📄 许可证 -遇到问题或有改进建议,请提交 [Issue](https://github.com/HNGM-HP/opencode-bridge/issues) 或 [Pull Request](https://github.com/HNGM-HP/opencode-bridge/pulls),期待你的参与。 +本项目采用 [GNU General Public License v3.0](./LICENSE)。 --- -## 📞 技术支持 - -- **GitHub Issues**:[问题反馈](https://github.com/HNGM-HP/opencode-bridge/issues) -- **项目主页**:[GitHub Repository](https://github.com/HNGM-HP/opencode-bridge) \ No newline at end of file diff --git "a/assets/demo/1-1\347\247\201\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png" "b/assets/demo/1-1\347\247\201\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png" deleted file mode 100644 index 92a1416..0000000 Binary files "a/assets/demo/1-1\347\247\201\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png" and /dev/null differ diff --git "a/assets/demo/1-2\347\247\201\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png" "b/assets/demo/1-2\347\247\201\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png" deleted file mode 100644 index 9bc5bc2..0000000 Binary files "a/assets/demo/1-2\347\247\201\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png" and /dev/null differ diff --git "a/assets/demo/1-3\347\247\201\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png" "b/assets/demo/1-3\347\247\201\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png" deleted file mode 100644 index 66f838d..0000000 Binary files "a/assets/demo/1-3\347\247\201\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png" and /dev/null differ diff --git "a/assets/demo/1-4\347\247\201\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png" "b/assets/demo/1-4\347\247\201\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png" deleted file mode 100644 index 69d7312..0000000 Binary files "a/assets/demo/1-4\347\247\201\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png" and /dev/null differ diff --git "a/assets/demo/2-1\345\244\232\347\276\244\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png" "b/assets/demo/2-1\345\244\232\347\276\244\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png" deleted file mode 100644 index 9d0a0f7..0000000 Binary files "a/assets/demo/2-1\345\244\232\347\276\244\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png" and /dev/null differ diff --git "a/assets/demo/2-2\345\244\232\347\276\244\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png.png" "b/assets/demo/2-2\345\244\232\347\276\244\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png.png" deleted file mode 100644 index a2747be..0000000 Binary files "a/assets/demo/2-2\345\244\232\347\276\244\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png.png" and /dev/null differ diff --git "a/assets/demo/2-3\345\244\232\347\276\244\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png.png" "b/assets/demo/2-3\345\244\232\347\276\244\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png.png" deleted file mode 100644 index 974f6f7..0000000 Binary files "a/assets/demo/2-3\345\244\232\347\276\244\350\201\212\347\213\254\347\253\213\344\274\232\350\257\235.png.png" and /dev/null differ diff --git "a/assets/demo/3-1\345\233\276\347\211\207\351\231\204\344\273\266\350\247\243\346\236\220.png" "b/assets/demo/3-1\345\233\276\347\211\207\351\231\204\344\273\266\350\247\243\346\236\220.png" deleted file mode 100644 index fcc52d6..0000000 Binary files "a/assets/demo/3-1\345\233\276\347\211\207\351\231\204\344\273\266\350\247\243\346\236\220.png" and /dev/null differ diff --git "a/assets/demo/3-2\345\233\276\347\211\207\351\231\204\344\273\266\350\247\243\346\236\220.png.png" "b/assets/demo/3-2\345\233\276\347\211\207\351\231\204\344\273\266\350\247\243\346\236\220.png.png" deleted file mode 100644 index d0a25d2..0000000 Binary files "a/assets/demo/3-2\345\233\276\347\211\207\351\231\204\344\273\266\350\247\243\346\236\220.png.png" and /dev/null differ diff --git "a/assets/demo/3-3\345\233\276\347\211\207\351\231\204\344\273\266\350\247\243\346\236\220.png.png" "b/assets/demo/3-3\345\233\276\347\211\207\351\231\204\344\273\266\350\247\243\346\236\220.png.png" deleted file mode 100644 index 4c3a839..0000000 Binary files "a/assets/demo/3-3\345\233\276\347\211\207\351\231\204\344\273\266\350\247\243\346\236\220.png.png" and /dev/null differ diff --git "a/assets/demo/4-1\344\272\244\344\272\222\345\267\245\345\205\267\346\265\213\350\257\225.png" "b/assets/demo/4-1\344\272\244\344\272\222\345\267\245\345\205\267\346\265\213\350\257\225.png" deleted file mode 100644 index c0c4392..0000000 Binary files "a/assets/demo/4-1\344\272\244\344\272\222\345\267\245\345\205\267\346\265\213\350\257\225.png" and /dev/null differ diff --git "a/assets/demo/4-2\344\272\244\344\272\222\345\267\245\345\205\267\346\265\213\350\257\225.png.png" "b/assets/demo/4-2\344\272\244\344\272\222\345\267\245\345\205\267\346\265\213\350\257\225.png.png" deleted file mode 100644 index ba2bbe2..0000000 Binary files "a/assets/demo/4-2\344\272\244\344\272\222\345\267\245\345\205\267\346\265\213\350\257\225.png.png" and /dev/null differ diff --git "a/assets/demo/5-1\345\272\225\345\261\202\346\235\203\351\231\220\346\265\213\350\257\225.png" "b/assets/demo/5-1\345\272\225\345\261\202\346\235\203\351\231\220\346\265\213\350\257\225.png" deleted file mode 100644 index 4c719e7..0000000 Binary files "a/assets/demo/5-1\345\272\225\345\261\202\346\235\203\351\231\220\346\265\213\350\257\225.png" and /dev/null differ diff --git "a/assets/demo/5-2\345\272\225\345\261\202\346\235\203\351\231\220\346\265\213\350\257\225.png.png" "b/assets/demo/5-2\345\272\225\345\261\202\346\235\203\351\231\220\346\265\213\350\257\225.png.png" deleted file mode 100644 index 961471b..0000000 Binary files "a/assets/demo/5-2\345\272\225\345\261\202\346\235\203\351\231\220\346\265\213\350\257\225.png.png" and /dev/null differ diff --git "a/assets/demo/5-3\345\272\225\345\261\202\346\235\203\351\231\220\346\265\213\350\257\225.png.png" "b/assets/demo/5-3\345\272\225\345\261\202\346\235\203\351\231\220\346\265\213\350\257\225.png.png" deleted file mode 100644 index 5620789..0000000 Binary files "a/assets/demo/5-3\345\272\225\345\261\202\346\235\203\351\231\220\346\265\213\350\257\225.png.png" and /dev/null differ diff --git "a/assets/demo/5-4\345\272\225\345\261\202\346\235\203\351\231\220\346\265\213\350\257\225.png.png" "b/assets/demo/5-4\345\272\225\345\261\202\346\235\203\351\231\220\346\265\213\350\257\225.png.png" deleted file mode 100644 index 54239c3..0000000 Binary files "a/assets/demo/5-4\345\272\225\345\261\202\346\235\203\351\231\220\346\265\213\350\257\225.png.png" and /dev/null differ diff --git "a/assets/demo/6-1\344\274\232\350\257\235\346\270\205\347\220\206.png" "b/assets/demo/6-1\344\274\232\350\257\235\346\270\205\347\220\206.png" deleted file mode 100644 index b42498d..0000000 Binary files "a/assets/demo/6-1\344\274\232\350\257\235\346\270\205\347\220\206.png" and /dev/null differ diff --git "a/assets/demo/6-2\344\274\232\350\257\235\346\270\205\347\220\206.png.png" "b/assets/demo/6-2\344\274\232\350\257\235\346\270\205\347\220\206.png.png" deleted file mode 100644 index b615bca..0000000 Binary files "a/assets/demo/6-2\344\274\232\350\257\235\346\270\205\347\220\206.png.png" and /dev/null differ diff --git "a/assets/demo/6-3\344\274\232\350\257\235\346\270\205\347\220\206.png.png" "b/assets/demo/6-3\344\274\232\350\257\235\346\270\205\347\220\206.png.png" deleted file mode 100644 index b84e8a9..0000000 Binary files "a/assets/demo/6-3\344\274\232\350\257\235\346\270\205\347\220\206.png.png" and /dev/null differ diff --git a/assets/demo/web0.png b/assets/demo/web0.png new file mode 100644 index 0000000..ac873e5 Binary files /dev/null and b/assets/demo/web0.png differ diff --git a/assets/docs/Windows-ALLOWED_DIRECTORIES.md b/assets/docs/Windows-ALLOWED_DIRECTORIES.md new file mode 100644 index 0000000..4c8d9e1 --- /dev/null +++ b/assets/docs/Windows-ALLOWED_DIRECTORIES.md @@ -0,0 +1,64 @@ +# Windows ALLOWED_DIRECTORIES 配置指南 + +## 路径格式示例 + +### 1. 绝对路径(推荐使用正斜杠) + +```json +{ + "ALLOWED_DIRECTORIES": [ + "C:/Users/YourName/Projects", + "D:/Development", + "E:/Work/Code" + ] +} +``` + +### 2. 使用反斜杠(需要双写) + +```json +{ + "ALLOWED_DIRECTORIES": [ + "C:\\Users\\YourName\\Projects", + "D:\\Development", + "E:\\Work\\Code" + ] +} +``` + +### 3. 相对路径 + +```json +{ + "ALLOWED_DIRECTORIES": [ + ".", + "./projects", + "./workspace" + ] +} +``` + +### 4. 环境变量 + +```json +{ + "ALLOWED_DIRECTORIES": [ + "%USERPROFILE%/Projects", + "%USERPROFILE%\\Documents", + "C:/Users/%USERNAME%/Desktop" + ] +} +``` + +## 配置文件位置 + +Windows 系统下的配置文件位置: +- 用户设置:`C:\Users\YourName\.claude\settings.json` +- 本地设置:`C:\Users\YourName\.claude\settings.local.json` + +## 注意事项 + +- 推荐使用正斜杠 `/` 避免转义问题 +- 路径不要以分隔符结尾(如 `C:/Projects/`) +- 确保目录存在且有访问权限 +- 支持中文目录名和网络路径(UNC) diff --git a/assets/docs/environment-en.md b/assets/docs/environment-en.md index a3ac0ed..e43b284 100644 --- a/assets/docs/environment-en.md +++ b/assets/docs/environment-en.md @@ -73,7 +73,7 @@ The `.env` file now only stores Admin panel startup parameters: | `GROUP_REQUIRE_MENTION` | No | `false` | Group chat only responds when explicitly @mentioned | | `OPENCODE_HOST` | No | `localhost` | OpenCode address | | `OPENCODE_PORT` | No | `4096` | OpenCode port | -| `OPENCODE_AUTO_START` | No | `false` | Auto-start OpenCode on Bridge startup | +| `OPENCODE_AUTO_START` | No | `true` | Auto-start OpenCode on Bridge startup | | `OPENCODE_AUTO_START_CMD` | No | `opencode serve` | Custom OpenCode startup command | --- diff --git a/assets/docs/environment.md b/assets/docs/environment.md index da1d806..6a5a377 100644 --- a/assets/docs/environment.md +++ b/assets/docs/environment.md @@ -49,7 +49,7 @@ | `GROUP_REQUIRE_MENTION` | 否 | `false` | 为 `true` 时,群聊仅在明确 @ 机器人时响应 | | `OPENCODE_HOST` | 否 | `localhost` | OpenCode 服务器地址 | | `OPENCODE_PORT` | 否 | `4096` | OpenCode 服务器端口 | -| `OPENCODE_AUTO_START` | 否 | `false` | 设置为 `true` 时,Bridge 启动时自动启动 OpenCode | +| `OPENCODE_AUTO_START` | 否 | `true` | 设置为 `true` 时,Bridge 启动时自动启动 OpenCode | | `OPENCODE_AUTO_START_CMD` | 否 | `opencode serve` | 自定义 OpenCode 启动命令 | ### Discord 配置 diff --git a/assets/docs/troubleshooting-en.md b/assets/docs/troubleshooting-en.md index cbdae02..6cec27d 100644 --- a/assets/docs/troubleshooting-en.md +++ b/assets/docs/troubleshooting-en.md @@ -28,7 +28,185 @@ Problem Occurs --- -## 1. Feishu Issues +## 1. Desktop App Installation Issues + +### 1.1 macOS: "App is damaged" Error + +**Problem**: +``` +"OpenCode Bridge" is damaged and can't be opened. You should move it to the Trash. +``` + +**Reason**: +- macOS security mechanism (Gatekeeper) blocks unsigned apps +- This is a free open-source project without Apple Developer certificate +- This is normal macOS protection behavior for all unsigned apps + +**Solutions** (choose one): + +#### Method 1: Right-click to Open (Simplest) +``` +1. Find "OpenCode Bridge.app" in Finder +2. Right-click on the app icon +3. Hold the "Option" (⌥) key on your keyboard +4. Double-click the "Open" menu item +5. Click "Open" in the confirmation dialog +``` + +**After this one-time operation**, you can launch normally by double-clicking. + +#### Method 2: System Settings Override +``` +1. Click "Cancel" to close the error dialog +2. Open "System Settings" → "Privacy & Security" +3. Scroll down to find "OpenCode Bridge was blocked" message +4. Click "Open Anyway" button +5. Enter administrator password to confirm again +``` + +#### Method 3: Command Line Remove Quarantine +```bash +# Open Terminal and execute +xattr -cr /Applications/OpenCode\ Bridge.app + +# If app is in another location, replace with actual path +xattr -cr "/path/to/OpenCode Bridge.app" +``` + +**How it works**: +- `xattr` command views and modifies file extended attributes +- `-c` flag clears all extended attributes +- `-r` flag recursively processes all files in the app bundle +- macOS marks downloaded files with `com.apple.quarantine` attribute; removing it bypasses Gatekeeper + +#### Method 4: Launch from Terminal +```bash +# Execute in Terminal (no arguments needed) +open /Applications/OpenCode\ Bridge.app +``` + +--- + +### 1.2 Windows: "Unrecognized App" Warning + +**Problem**: +``` +Windows protected your PC +Microsoft Defender SmartScreen blocked an unrecognized app from starting. Running this app might put your PC at risk. +``` + +**Solution**: +``` +1. Click "More info" link +2. Click "Run anyway" button +``` + +**Explanation**: +- Windows Defender SmartScreen shows this warning for apps without digital signatures +- This is normal protection mechanism, not a virus or malware +- After confirming once, SmartScreen will remember this app and won't prompt again + +--- + +### 1.3 Can't Access Management Panel After Launch + +**Symptom**: Desktop app is running, but browser cannot access `http://localhost:4098` + +**Troubleshooting Steps**: + +#### 1. Confirm app is running +- **Windows**: Check system tray (bottom-right notification area) for OpenCode Bridge icon +- **macOS**: Check top menu bar for tray icon + +#### 2. Check if port is in use +```bash +# Windows PowerShell +netstat -ano | findstr :4098 + +# macOS/Linux +lsof -i :4098 +``` + +If port is occupied, you can: +1. Stop the process occupying the port +2. Or modify `ADMIN_PORT` in `.env` file + +#### 3. Manually open management panel +Enter directly in browser address bar: +``` +http://localhost:4098 +``` + +#### 4. Check log files +- **Windows**: + ``` + %APPDATA%\opencode-bridge\logs\service.log + %APPDATA%\opencode-bridge\logs\service.err + ``` +- **macOS**: + ``` + ~/Library/Application Support/opencode-bridge/logs/service.log + ~/Library/Application Support/opencode-bridge/logs/service.err + ``` + +#### 5. Restart app +- Right-click tray icon → Select "Stop Service" → Wait 3 seconds → Select "Start Service" +- Or exit the app completely and restart + +--- + +### 1.4 App Launches But Exits Immediately + +**Possible Causes**: + +#### Cause 1: Corrupted configuration file +```bash +# Delete config file (will lose all settings, be careful) +# Windows +del %APPDATA%\opencode-bridge\data\config.db + +# macOS +rm ~/Library/Application\ Support/opencode-bridge/data/config.db +``` + +#### Cause 2: OpenCode service not running +1. Confirm OpenCode is installed and running +2. Check if OpenCode default port (4096) is accessible +```bash +curl http://localhost:4096 +``` + +#### Cause 3: Node.js version incompatibility +- Ensure Node.js 20.0.0 or higher is installed +- Download: https://nodejs.org/ + +--- + +### 1.5 How to Completely Uninstall + +**Windows**: +``` +1. Uninstall via "Settings" → "Apps" → "OpenCode Bridge" → "Uninstall" +2. Manually delete remaining files: + - %APPDATA%\opencode-bridge + - %LOCALAPPDATA%\opencode-bridge +``` + +**macOS**: +```bash +# 1. Quit app (right-click tray icon → Quit) +# 2. Delete app +sudo rm -rf /Applications/OpenCode\ Bridge.app + +# 3. Delete config files (optional) +rm -rf ~/Library/Application\ Support/opencode-bridge +rm -rf ~/Library/Caches/opencode-bridge +rm -rf ~/Library/Preferences/com.github.hngm-hp.opencode-bridge.plist +``` + +--- + +## 2. Feishu Issues | Symptom | Priority Check | |---------|----------------| diff --git a/assets/docs/troubleshooting.md b/assets/docs/troubleshooting.md index daef04c..4640f45 100644 --- a/assets/docs/troubleshooting.md +++ b/assets/docs/troubleshooting.md @@ -27,7 +27,185 @@ --- -## 1. 飞书相关 +## 1. 桌面应用安装问题 + +### 1.1 macOS 提示"已损坏" + +**问题现象**: +``` +"OpenCode Bridge" 已损坏,无法打开。你应该将它移到废纸篓。 +``` + +**原因说明**: +- macOS 的安全机制(Gatekeeper)阻止了未签名的应用运行 +- 本项目为开源免费项目,未购买 Apple Developer 证书进行代码签名 +- 这是 macOS 对所有未签名应用的正常保护行为 + +**解决方案**(任选其一): + +#### 方法 1:右键强制打开(最简单) +``` +1. 在 Finder 中找到 "OpenCode Bridge.app" +2. 右键点击应用图标 +3. 按住键盘上的 "Option"(⌥)键 +4. 双击 "打开" 菜单项 +5. 在弹出的确认对话框中点击 "打开" +``` + +**一次性操作后**,以后就可以正常双击启动了。 + +#### 方法 2:系统设置解除限制 +``` +1. 点击 "取消" 关闭错误对话框 +2. 打开 "系统设置" → "隐私与安全性" +3. 向下滚动找到 "OpenCode Bridge 被阻止" 的提示 +4. 点击 "仍要打开" 按钮 +5. 再次输入管理员密码确认 +``` + +#### 方法 3:命令行移除隔离属性 +```bash +# 打开终端(Terminal),执行以下命令 +xattr -cr /Applications/OpenCode\ Bridge.app + +# 如果应用在其他位置,替换为实际路径 +xattr -cr "/path/to/OpenCode Bridge.app" +``` + +**原理解释**: +- `xattr` 命令用于查看和修改文件的扩展属性 +- `-c` 参数清除所有扩展属性 +- `-r` 参数递归处理应用包内的所有文件 +- macOS 通过 `com.apple.quarantine` 属性标记下载的文件,移除后 Gatekeeper 不再拦截 + +#### 方法 4:终端直接启动 +```bash +# 在终端中执行(无需任何参数) +open /Applications/OpenCode\ Bridge.app +``` + +--- + +### 1.2 Windows 提示"未识别的应用" + +**问题现象**: +``` +Windows 已保护你的电脑 +Microsoft Defender SmartScreen 筛选器已阻止无法识别的应用启动。运行此应用可能会导致你的电脑存在风险。 +``` + +**解决方案**: +``` +1. 点击 "更多信息" 链接 +2. 点击 "仍要运行" 按钮 +``` + +**原因说明**: +- Windows Defender SmartScreen 对没有数字签名的应用会显示此警告 +- 这是正常的保护机制,不是病毒或恶意软件 +- 确认一次后,SmartScreen 会记住此应用,下次不再提示 + +--- + +### 1.3 应用启动后无法访问管理面板 + +**现象**:桌面应用已启动,但浏览器无法访问 `http://localhost:4098` + +**排查步骤**: + +#### 1. 确认应用是否运行 +- **Windows**:查看系统托盘(右下角通知区域)是否有 OpenCode Bridge 图标 +- **macOS**:查看顶部菜单栏是否有托盘图标 + +#### 2. 检查端口是否被占用 +```bash +# Windows PowerShell +netstat -ano | findstr :4098 + +# macOS/Linux +lsof -i :4098 +``` + +如果端口被占用,可以: +1. 停止占用端口的进程 +2. 或修改 `.env` 文件中的 `ADMIN_PORT` 配置 + +#### 3. 手动打开管理面板 +直接在浏览器地址栏输入: +``` +http://localhost:4098 +``` + +#### 4. 查看日志文件 +- **Windows**: + ``` + %APPDATA%\opencode-bridge\logs\service.log + %APPDATA%\opencode-bridge\logs\service.err + ``` +- **macOS**: + ``` + ~/Library/Application Support/opencode-bridge/logs/service.log + ~/Library/Application Support/opencode-bridge/logs/service.err + ``` + +#### 5. 重启应用 +- 右键托盘图标 → 选择 "停止服务" → 等待 3 秒 → 选择 "启动服务" +- 或直接退出应用后重新启动 + +--- + +### 1.4 应用启动但立即退出 + +**可能原因**: + +#### 原因 1:配置文件损坏 +```bash +# 删除配置文件(会丢失所有配置,请谨慎操作) +# Windows +del %APPDATA%\opencode-bridge\data\config.db + +# macOS +rm ~/Library/Application\ Support/opencode-bridge/data/config.db +``` + +#### 原因 2:依赖的 OpenCode 服务未运行 +1. 确认 OpenCode 已安装并运行 +2. 检查 OpenCode 的默认端口(4096)是否可访问 +```bash +curl http://localhost:4096 +``` + +#### 原因 3:Node.js 版本不兼容 +- 确保系统安装了 Node.js 20.0.0 或更高版本 +- 下载地址:https://nodejs.org/ + +--- + +### 1.5 如何完全卸载 + +**Windows**: +``` +1. 通过 "设置" → "应用" → "OpenCode Bridge" → "卸载" +2. 手动删除残留文件: + - %APPDATA%\opencode-bridge + - %LOCALAPPDATA%\opencode-bridge +``` + +**macOS**: +```bash +# 1. 退出应用(右键托盘图标 → 退出) +# 2. 删除应用 +sudo rm -rf /Applications/OpenCode\ Bridge.app + +# 3. 删除配置文件(可选) +rm -rf ~/Library/Application\ Support/opencode-bridge +rm -rf ~/Library/Caches/opencode-bridge +rm -rf ~/Library/Preferences/com.github.hngm-hp.opencode-bridge.plist +``` + +--- + +## 2. 飞书相关 | 现象 | 优先检查 | |------|----------| diff --git a/bin/opencode-bridge.js b/bin/opencode-bridge.js index 53a9329..97f09f0 100644 --- a/bin/opencode-bridge.js +++ b/bin/opencode-bridge.js @@ -1,5 +1,16 @@ #!/usr/bin/env node +/** + * opencode-bridge CLI 入口 + * + * 职责(PR3): + * 1. 解析顶层标志(--config-dir 等) + * 2. 设置 BRIDGE_CLI_MODE=1 抑制 dist/index.js 的自动 main() + * 3. 委托给 dist/cli/index.js 路由各子命令(init / start / help / 默认) + * + * 注意:本脚本必须保持纯 ESM 且不依赖外部包,以便 npm install -g 后立即可用。 + */ + import path from 'node:path'; const argv = process.argv.slice(2); @@ -26,6 +37,11 @@ if (configDir) { process.env.OPENCODE_BRIDGE_CONFIG_DIR = path.resolve(configDir); } +// 标记 CLI 模式:dist/index.js 见到此变量后不会自动调用 main() +// dist/cli/index.js 内会按子命令显式调用 startBridge() / runWizard() +process.env.BRIDGE_CLI_MODE = '1'; + process.argv = [process.argv[0], process.argv[1], ...passthroughArgs]; -await import('../dist/index.js'); +const cli = await import('../dist/cli/index.js'); +await cli.run(passthroughArgs); diff --git a/electron/main.ts b/electron/main.ts index 7ac9941..5d85de0 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -13,6 +13,7 @@ import { app, Tray, Menu, nativeImage, shell, dialog } from 'electron'; import path from 'node:path'; +import fs from 'node:fs'; import { spawn, spawnSync, ChildProcess } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import http from 'node:http'; @@ -25,6 +26,114 @@ let backendProcess: ChildProcess | null = null; // 托盘图标 let tray: Tray | null = null; +// --------------------------------------------------------------------------- +// 日志落盘(修复:GUI 启动时 stdout/stderr 没地方看的问题) +// --------------------------------------------------------------------------- +// 所有 [Electron] / [Backend] / [Backend Error] / [Admin] 输出都会写入 +// /logs/electron-main.log,并在进程崩溃、启动失败时保留足够上下文, +// 让用户无需命令行运行 exe 即可定位问题。 +let logStream: fs.WriteStream | null = null; +let logFilePath = ''; +// 保留最近的后端输出,弹窗时直接显示最后几行,省去翻日志的步骤 +const recentBackendOutput: string[] = []; +const MAX_RECENT_LINES = 40; + +// 日志轮转参数:单文件上限 1MB,保留 .1 / .2 共 3 份,总占用 ≤ 3MB +const LOG_MAX_BYTES = 1 * 1024 * 1024; // 1 MB +const LOG_BACKUPS = 2; // .1 和 .2,循环覆盖 +let logBytesWritten = 0; // 追踪当前文件本次累计写入量(近似值) + +/** 将旧日志文件循环后移:.2 删除,.1→.2,当前→.1,重建当前 */ +function rotateLogFile(): void { + try { logStream?.end(); } catch { /* ignore */ } + logStream = null; + + // 循环右移备份:先删最老的 .2,再逐级重命名 + for (let i = LOG_BACKUPS; i >= 1; i--) { + const older = `${logFilePath}.${i}`; + const newer = i === 1 ? logFilePath : `${logFilePath}.${i - 1}`; + try { fs.unlinkSync(older); } catch { /* 不存在时忽略 */ } + try { fs.renameSync(newer, older); } catch { /* 不存在时忽略 */ } + } + + logStream = fs.createWriteStream(logFilePath, { flags: 'a' }); + logBytesWritten = 0; +} + +function initFileLogger(): void { + try { + const logsDir = path.join(app.getPath('userData'), 'logs'); + fs.mkdirSync(logsDir, { recursive: true }); + logFilePath = path.join(logsDir, 'electron-main.log'); + + // 启动时若当前文件已超限,立即轮转一次,避免带着上次的大文件继续追加 + try { + const st = fs.statSync(logFilePath); + if (st.size > LOG_MAX_BYTES) { + // 临时创建 stream 指向旧文件,rotateLogFile 会关闭并移走它 + logStream = fs.createWriteStream(logFilePath, { flags: 'a' }); + rotateLogFile(); + } + } catch { + // 文件不存在,忽略 + } + + if (!logStream) { + logStream = fs.createWriteStream(logFilePath, { flags: 'a' }); + } + + const header = `\n===== OpenCode Bridge started at ${new Date().toISOString()} =====\n` + + `pid=${process.pid} platform=${process.platform} electron=${process.versions.electron}\n` + + `userData=${app.getPath('userData')}\n` + + `execPath=${process.execPath}\n`; + logStream.write(header); + logBytesWritten += Buffer.byteLength(header); + + // 劫持 console 的四个常用方法,让任何 console.log/error 同时落盘 + const origLog = console.log.bind(console); + const origErr = console.error.bind(console); + const origWarn = console.warn.bind(console); + const origInfo = console.info.bind(console); + + const writeLine = (level: string, args: unknown[]) => { + const line = `[${new Date().toISOString()}] [${level}] ` + + args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n'; + try { + // 运行时超限检测:写入前先检查,确保单次长会话也不会无限增长 + if (logBytesWritten + Buffer.byteLength(line) > LOG_MAX_BYTES) { + rotateLogFile(); + } + logStream?.write(line); + logBytesWritten += Buffer.byteLength(line); + } catch { /* ignore */ } + }; + + console.log = (...args: unknown[]) => { writeLine('log', args); origLog(...args); }; + console.error = (...args: unknown[]) => { writeLine('error', args); origErr(...args); }; + console.warn = (...args: unknown[]) => { writeLine('warn', args); origWarn(...args); }; + console.info = (...args: unknown[]) => { writeLine('info', args); origInfo(...args); }; + + // 捕获未处理异常,否则主进程崩溃时用户只看到弹窗没有线索 + process.on('uncaughtException', (err) => { + console.error('[Electron] uncaughtException:', err?.stack || err); + }); + process.on('unhandledRejection', (reason) => { + console.error('[Electron] unhandledRejection:', reason); + }); + } catch (err) { + // 日志系统自身失败不应该阻止主程序启动 + // eslint-disable-next-line no-console + console.error('[Electron] Failed to init file logger:', err); + } +} + +function rememberBackendLine(line: string): void { + recentBackendOutput.push(line); + if (recentBackendOutput.length > MAX_RECENT_LINES) { + recentBackendOutput.shift(); + } +} + // 开发模式检测 const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged; @@ -50,16 +159,29 @@ function startBackend() { return; } - // 获取应用根目录 - const appPath = isDev ? path.resolve(__dirname, '..') : app.getAppPath(); - // 启动 Admin 独立进程,它会管理 Bridge 子进程 - const backendPath = path.join(appPath, 'dist/admin/index.js'); + let backendPath: string; + if (isDev) { + backendPath = path.resolve(__dirname, '../dist/admin/index.js'); + } else { + backendPath = path.join(process.resourcesPath, 'app', 'dist', 'admin', 'index.js'); + } const dataPath = getUserDataPath(); console.log('[Electron] __dirname:', __dirname); - console.log('[Electron] App path:', appPath); + console.log('[Electron] isDev:', isDev); + console.log('[Electron] process.resourcesPath:', process.resourcesPath); + console.log('[Electron] App path:', app.getAppPath()); console.log('[Electron] Data directory:', dataPath); console.log('[Electron] Starting backend from:', backendPath); + console.log(`[Electron] platform=${process.platform} arch=${process.arch} electron=${process.versions.electron} node=${process.versions.node}`); + + if (!fs.existsSync(backendPath)) { + const msg = `[Backend] FATAL: 后端入口不存在: ${backendPath}\n` + + `[Backend] 请检查应用安装是否完整。如果是 macOS,尝试: xattr -cr "/Applications/OpenCode Bridge.app"`; + rememberBackendLine(msg); + console.error(msg); + return; + } backendProcess = spawn(process.execPath, [backendPath], { env: { @@ -70,21 +192,45 @@ function startBackend() { OPENCODE_BRIDGE_CONFIG_DIR: dataPath, // 设置工作目录 NODE_ENV: isDev ? 'development' : 'production', + // 把版本号通过 env 传给 backend(打包后 backend 跑在 ELECTRON_RUN_AS_NODE 模式, + // 读不到 asar 内的 package.json;由主进程用 app.getVersion() 读取后注入) + APP_VERSION: app.getVersion(), }, stdio: ['ignore', 'pipe', 'pipe'], cwd: dataPath, // 设置工作目录 }); backendProcess.stdout?.on('data', (data) => { - console.log(`[Backend] ${data.toString().trim()}`); + const text = data.toString(); + for (const line of text.split(/\r?\n/)) { + if (!line) continue; + const formatted = `[Backend] ${line}`; + rememberBackendLine(formatted); + console.log(formatted); + } }); backendProcess.stderr?.on('data', (data) => { - console.error(`[Backend Error] ${data.toString().trim()}`); + const text = data.toString(); + for (const line of text.split(/\r?\n/)) { + if (!line) continue; + const formatted = `[Backend Error] ${line}`; + rememberBackendLine(formatted); + console.error(formatted); + } }); - backendProcess.on('exit', (code) => { - console.log(`[Backend] Exited with code ${code}`); + // 关键:监听 spawn 自身的失败(exe 找不到、权限不足等),原代码会静默 + backendProcess.on('error', (err) => { + const msg = `[Backend] spawn error: ${err?.stack || err}`; + rememberBackendLine(msg); + console.error(msg); + }); + + backendProcess.on('exit', (code, signal) => { + const msg = `[Backend] Exited with code=${code} signal=${signal ?? 'null'}`; + rememberBackendLine(msg); + console.log(msg); backendProcess = null; }); } @@ -190,77 +336,6 @@ function createTray() { }, }, { type: 'separator' }, - { - label: '重置管理密码', - click: async () => { - const result = await dialog.showMessageBox(null, { - type: 'warning', - title: '重置管理密码', - message: '确定要重置管理密码吗?', - detail: '重置后需要重新设置密码才能访问管理面板。\n密码文件位于数据目录中的 config.db。', - buttons: ['确定重置', '取消'], - defaultId: 1, - cancelId: 1, - }); - - if (result.response === 0) { - // 通过 HTTP API 重置密码 - try { - const req = http.request({ - hostname: 'localhost', - port: ADMIN_PORT, - path: '/api/admin/reset-password', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }, (res) => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - if (res.statusCode === 200) { - dialog.showMessageBox(null, { - type: 'info', - title: '密码已重置', - message: '管理密码已重置,请在浏览器中打开管理面板设置新密码。', - buttons: ['打开管理面板', '确定'], - }).then((result) => { - if (result.response === 0) { - shell.openExternal(`http://localhost:${ADMIN_PORT}`); - } - }); - } else { - dialog.showMessageBox(null, { - type: 'error', - title: '重置失败', - message: `密码重置失败: ${data || '请检查服务是否运行。'}`, - buttons: ['确定'], - }); - } - }); - }); - req.on('error', (err) => { - dialog.showMessageBox(null, { - type: 'error', - title: '重置失败', - message: `无法连接到服务: ${err.message}`, - buttons: ['确定'], - }); - }); - req.end(); - } catch (err) { - const errorMsg = err instanceof Error ? err.message : '密码重置操作失败。'; - dialog.showMessageBox(null, { - type: 'error', - title: '重置失败', - message: errorMsg, - buttons: ['确定'], - }); - } - } - }, - }, - { type: 'separator' }, { label: '退出', click: () => { @@ -456,12 +531,11 @@ async function killOldBridgeProcesses(): Promise { return []; } -if (!gotTheLock) { - // 检测到另一个实例正在运行 - console.log('[Electron] Another instance is already running'); - - // 弹出对话框提示用户 - dialog.showMessageBox(null, { +/** + * 处理单实例锁失败的情况(需要在 app ready 之后调用) + */ +async function handleSingleInstanceLockFailure() { + const result = await dialog.showMessageBox(null, { type: 'warning', title: '程序已在运行', message: 'OpenCode Bridge 已在运行中,请勿重复启动。', @@ -469,67 +543,132 @@ if (!gotTheLock) { buttons: ['强制终止旧进程并启动', '退出'], defaultId: 1, cancelId: 1, - }).then(async (result) => { - if (result.response === 0) { - // 用户选择强制终止旧进程 - console.log('[Electron] User chose to kill old process and start'); - - // 先释放当前的请求,然后尝试终止旧进程 - // 注意:此时我们无法真正获取锁,因为旧进程还在运行 - // 需要先强制终止旧进程 - await killOldBridgeProcesses(); - - // 等待一秒后重新尝试启动 - setTimeout(() => { - // 重新启动当前实例(退出后再启动) - app.relaunch(); - app.exit(0); - }, 1000); - } else { - // 用户选择退出 - app.exit(0); - } }); -} else { + + if (result.response === 0) { + // 用户选择强制终止旧进程 + console.log('[Electron] User chose to kill old process and start'); + + // 先释放当前的请求,然后尝试终止旧进程 + // 注意:此时我们无法真正获取锁,因为旧进程还在运行 + // 需要先强制终止旧进程 + await killOldBridgeProcesses(); + + // 等待一秒后重新尝试启动 + setTimeout(() => { + // 重新启动当前实例(退出后再启动) + app.relaunch(); + app.exit(0); + }, 1000); + } else { + // 用户选择退出 + app.exit(0); + } +} + +// 应用就绪后处理初始化逻辑 +app.whenReady().then(async () => { + // 先初始化文件日志,这样下面任何输出都会落盘 + initFileLogger(); + + // 检查单实例锁 + if (!gotTheLock) { + // 检测到另一个实例正在运行,在 app ready 后弹窗提示 + console.log('[Electron] Another instance is already running'); + await handleSingleInstanceLockFailure(); + return; + } + app.on('second-instance', () => { // 当运行第二个实例时,打开管理面板 shell.openExternal(`http://localhost:${ADMIN_PORT}`); }); - // 应用就绪 - app.whenReady().then(async () => { - // 启动后端服务 - startBackend(); - - // 等待 Admin Server 就绪 - console.log('[Electron] Waiting for Admin Server...'); - const isReady = await waitForAdminServer(ADMIN_PORT); - - if (!isReady) { - console.error('[Electron] Admin Server failed to start'); - // 服务启动失败时弹窗提示 - dialog.showMessageBox(null, { - type: 'error', - title: '服务启动失败', - message: '管理面板服务未能正常启动,请检查日志。', - buttons: ['确定'], - }); + // 启动后端服务 + startBackend(); + + // 等待 Admin Server 就绪 + console.log('[Electron] Waiting for Admin Server...'); + const isReady = await waitForAdminServer(ADMIN_PORT); + + if (!isReady) { + console.error('[Electron] Admin Server failed to start'); + + const tail = recentBackendOutput.slice(-20).join('\n') || '(子进程没有任何输出,可能 spawn 本身就失败了)'; + + const archInfo = `平台: ${process.platform} / 架构: ${process.arch} / Electron: ${process.versions.electron} / Node: ${process.versions.node}`; + + let diagnosticHints = ''; + if (tail.includes('better-sqlite3') || tail.includes('dlopen') || tail.includes('MODULE_NOT_FOUND')) { + diagnosticHints = '\n\n⚠️ 检测到原生模块加载失败(better-sqlite3),可能原因:\n'; + if (process.platform === 'darwin') { + diagnosticHints += + '• 架构不匹配:确认 DMG 与 CPU 架构匹配(arm64=Apple Silicon, x64=Intel)\n' + + '• macOS 安全隔离:在终端执行 xattr -cr "/Applications/OpenCode Bridge.app"\n' + + '• 配置损坏:删除 ~/Library/Application Support/opencode-bridge/data/config.db'; + } else if (process.platform === 'linux') { + diagnosticHints += + '• 执行 npm run rebuild 重新编译原生模块\n' + + '• 检查 Node.js 版本兼容性'; + } else { + diagnosticHints += + '• 原生模块编译版本不匹配,请尝试重新安装'; + } } - // 创建托盘(始终创建,作为主要交互入口) - createTray(); + const detail = + `端口: ${ADMIN_PORT}\n` + + `${archInfo}\n` + + `日志文件: ${logFilePath || '(未初始化)'}\n` + + `数据目录: ${getUserDataPath()}\n` + + `\n最近的后端输出:\n${tail}${diagnosticHints}`; - // 无论开发模式还是生产模式,启动时都主动打开管理面板 - console.log('[Electron] Opening admin panel on startup'); - shell.openExternal(`http://localhost:${ADMIN_PORT}`); + const result = await dialog.showMessageBox(null, { + type: 'error', + title: '服务启动失败', + message: '管理面板服务未能正常启动。', + detail, + buttons: ['打开日志文件', '打开日志目录', '退出'], + defaultId: 0, + cancelId: 2, + noLink: true, + }); - // 检查更新(非开发模式) - checkForUpdates(); - }); -} + if (result.response === 0 && logFilePath) { + shell.openPath(logFilePath); + } else if (result.response === 1) { + shell.openPath(path.join(getUserDataPath(), 'logs')); + } else { + app.exit(1); + return; + } + } + + // 创建托盘(始终创建,作为主要交互入口) + createTray(); + + // 无论开发模式还是生产模式,启动时都主动打开管理面板 + console.log('[Electron] Opening admin panel on startup'); + shell.openExternal(`http://localhost:${ADMIN_PORT}`); + + // 检查更新(非开发模式) + // 延迟 30s 再请求 GitHub,避免和首次启动的网络/磁盘 IO 抢资源; + // 这一步即使 GitHub 完全不可达也不会拖慢窗口打开。 + // 注:Windows 安装「卡半程」的根因已在 installer.nsh 移除阻塞 MessageBox。 + setTimeout(() => { + checkForUpdates().catch(err => { + console.error('[Electron] checkForUpdates threw:', err); + }); + }, 30_000); +}); // 应用退出前清理 app.on('before-quit', () => { (app as any).isQuitting = true; stopBackend(); + try { + logStream?.end(`===== OpenCode Bridge exited at ${new Date().toISOString()} =====\n`); + } catch { + // ignore + } }); \ No newline at end of file diff --git a/installer.nsh b/installer.nsh index 03e2f9e..243f417 100644 --- a/installer.nsh +++ b/installer.nsh @@ -3,20 +3,13 @@ !macroend !macro customInstall - ; 添加开机自启选项 - MessageBox MB_YESNO "是否设置开机自动启动?" IDYES setAutoStart IDNO skipAutoStart - setAutoStart: - WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "OpenCode Bridge" "$INSTDIR\OpenCode Bridge.exe" - skipAutoStart: + ; 安装阶段不再弹任何选择框: + ; - 开机自启的开关已迁移到 Web → 系统设置 → Bridge 服务,运行时按需切换; + ; - 静默安装能避免「Windows 安装卡半程很久」时多一次用户交互。 !macroend !macro customUnInstall - ; 删除开机自启项 + ; 卸载时仅清理可能残留的开机自启注册表项, + ; 不再询问是否删除 $APPDATA\opencode-bridge:保留配置/会话数据以便重装时恢复。 DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "OpenCode Bridge" - - ; 提示用户是否删除数据目录 - MessageBox MB_YESNO "是否删除应用数据目录?$\n$\n数据目录位置:$APPDATA\opencode-bridge$\n$\n选择「是」将删除所有配置和会话数据,选择「否」将保留数据以便下次安装使用。" IDYES deleteData IDNO keepData - deleteData: - RMDir /r "$APPDATA\opencode-bridge" - keepData: -!macroend \ No newline at end of file +!macroend diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..2420fec --- /dev/null +++ b/opencode.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://opencode.ai/config.json", + "server": { + "host": "localhost", + "port": 4096, + "auth": { + "username": "opencode" + } + } +} diff --git a/package-lock.json b/package-lock.json index 76326bb..d6e01d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,55 +1,62 @@ { "name": "opencode-bridge", - "version": "2.9.59", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-bridge", - "version": "2.9.59", + "version": "3.1.0", "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { + "@hapi/boom": "^10.0.1", + "@inquirer/prompts": "^8.4.2", "@larksuiteoapi/node-sdk": "^1.36.3", "@opencode-ai/sdk": "latest", "@wecom/aibot-node-sdk": "^1.0.4", "@whiskeysockets/baileys": "^7.0.0-rc.9", + "axios": "^1.13.6", "better-sqlite3": "^12.8.0", + "chokidar": "^5.0.0", "dingtalk-stream": "^2.1.4", "discord.js": "^14.25.1", "dotenv": "^16.4.5", - "electron-updater": "^6.3.9", "express": "^5.2.1", "grammy": "^1.41.1", + "gray-matter": "^4.0.3", + "multer": "^2.1.1", "node-cron": "^4.2.1", - "qrcode": "^1.5.4" + "qrcode": "^1.5.4", + "simple-git": "^3.36.0", + "ws": "^8.20.0" }, "bin": { "opencode-bridge": "bin/opencode-bridge.js" }, "devDependencies": { "@grammyjs/types": "^3.25.0", - "@hapi/boom": "^10.0.1", "@types/axios": "^0.14.4", "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", + "@types/multer": "^2.1.0", "@types/node": "^22.10.0", "@types/pino": "^7.0.5", "@types/qrcode": "^1.5.6", + "@types/supertest": "^6.0.3", "@types/ws": "^8.18.1", - "axios": "^1.13.6", "electron": "^34.5.8", "electron-builder": "^25.1.8", "electron-rebuild": "^3.2.9", "pino": "^10.3.1", "puppeteer": "^24.40.0", + "supertest": "^7.2.2", "tsx": "^4.19.0", - "typescript": "^5.6.0", - "vitest": "^4.0.18", - "ws": "^8.20.0" + "typescript": "^5.9.3", + "vitest": "^4.1.5" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@babel/code-frame": { @@ -659,37 +666,34 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.10.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.10.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1153,7 +1157,6 @@ "version": "10.0.1", "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^11.0.2" @@ -1163,7 +1166,6 @@ "version": "11.0.7", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@img/colour": { @@ -1656,6 +1658,362 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@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/checkbox": { + "version": "5.1.4", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@inquirer/checkbox/-/checkbox-5.1.4.tgz", + "integrity": "sha512-w6KF8ZYRvqHhROkOTHXYC3qIV/KYEu5o12oLqQySvch61vrYtRxNSHTONSdJqWiFJPlCUQAHT5OgOIyuTr+MHQ==", + "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/confirm": { + "version": "6.0.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@inquirer/confirm/-/confirm-6.0.12.tgz", + "integrity": "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@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/core": { + "version": "11.1.9", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@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/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/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/@inquirer/editor": { + "version": "5.1.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@inquirer/editor/-/editor-5.1.1.tgz", + "integrity": "sha512-6y11LgmNpmn5D2aB5FgnCfBUBK8ZstwLCalyJmORcJZ/WrhOjm16mu6eSqIx8DnErxDqSLr+Jkp+GP8/Nwd5tA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/external-editor": "^3.0.0", + "@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/expand": { + "version": "5.0.13", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@inquirer/expand/-/expand-5.0.13.tgz", + "integrity": "sha512-dF2zvrFo9LshkcB23/O1il13kBkBltWIXzut1evfbuBLXMiGIuC45c+ZQ0uukjCDsvI8OWqun4FRYMnzFCQa3g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@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/external-editor": { + "version": "3.0.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@inquirer/external-editor/-/external-editor-3.0.0.tgz", + "integrity": "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "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/external-editor/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@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/input": { + "version": "5.0.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@inquirer/input/-/input-5.0.12.tgz", + "integrity": "sha512-uiMFBl4LqFzJClh80Q3f9hbOFJ6kgkDWI4LjAeBuyO6EanVVMF69AgOvpi1qdqjDSjDN6578B6nky9ceEpI+1Q==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@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/number": { + "version": "4.0.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@inquirer/number/-/number-4.0.12.tgz", + "integrity": "sha512-/vrwhEf7Xsuh+YlHF4IjSy3g1cyrQuPaSiHIxCEbLu8qnfvrcvJyCkoktOOF+xV9gSb77/G0n3h04RbMDW2sIg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@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/password": { + "version": "5.0.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@inquirer/password/-/password-5.0.12.tgz", + "integrity": "sha512-CBh7YHju623lxJRcAOo498ZUwIuMy63bqW/vVq0tQAZVv+lkWlHkP9ealYE1utWSisEShY5VMdzIXRmyEODzcQ==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.9", + "@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/prompts": { + "version": "8.4.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@inquirer/prompts/-/prompts-8.4.2.tgz", + "integrity": "sha512-XJmn/wY4AX56l1BRU+ZjDrFtg9+2uBEi4JvJQj82kwJDQKiPgSn4CEsbfGGygS4Gw6rkL4W18oATjfVfaqub2Q==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.1.4", + "@inquirer/confirm": "^6.0.12", + "@inquirer/editor": "^5.1.1", + "@inquirer/expand": "^5.0.13", + "@inquirer/input": "^5.0.12", + "@inquirer/number": "^4.0.12", + "@inquirer/password": "^5.0.12", + "@inquirer/rawlist": "^5.2.8", + "@inquirer/search": "^4.1.8", + "@inquirer/select": "^5.1.4" + }, + "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/rawlist": { + "version": "5.2.8", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@inquirer/rawlist/-/rawlist-5.2.8.tgz", + "integrity": "sha512-Su7FQvp5buZmCymN3PPoYv31ZQQX4ve2j02k7piGgKAWgE+AQRB5YoYVveGXcl3TZ9ldgRMSxj56YfDFmmaqLg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@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/search": { + "version": "4.1.8", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@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://mirrors.huaweicloud.com/repository/npm/@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://mirrors.huaweicloud.com/repository/npm/@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/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1761,7 +2119,7 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" @@ -1788,6 +2146,21 @@ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", "license": "MIT" }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, "node_modules/@larksuiteoapi/node-sdk": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.60.0.tgz", @@ -1899,9 +2272,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "version": "1.1.4", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, @@ -1917,6 +2290,19 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@npmcli/fs": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", @@ -1966,15 +2352,25 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.127.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -2120,9 +2516,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", "cpu": [ "arm64" ], @@ -2137,9 +2533,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", "cpu": [ "arm64" ], @@ -2154,9 +2550,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", "cpu": [ "x64" ], @@ -2171,9 +2567,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", "cpu": [ "x64" ], @@ -2188,9 +2584,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", "cpu": [ "arm" ], @@ -2205,13 +2601,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2222,13 +2621,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2239,13 +2641,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2256,13 +2661,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2273,13 +2681,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2290,13 +2701,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2307,9 +2721,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", "cpu": [ "arm64" ], @@ -2324,9 +2738,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", "cpu": [ "wasm32" ], @@ -2334,16 +2748,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", "cpu": [ "arm64" ], @@ -2358,9 +2774,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", "cpu": [ "x64" ], @@ -2375,9 +2791,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", "dev": true, "license": "MIT" }, @@ -2414,6 +2830,21 @@ "npm": ">=7.0.0" } }, + "node_modules/@simple-git/args-pathspec": { + "version": "1.0.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz", + "integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==", + "license": "MIT" + }, + "node_modules/@simple-git/argv-parser": { + "version": "1.1.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz", + "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==", + "license": "MIT", + "dependencies": { + "@simple-git/args-pathspec": "^1.0.3" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -2429,7 +2860,7 @@ }, "node_modules/@standard-schema/spec": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" @@ -2489,7 +2920,7 @@ }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", @@ -2545,7 +2976,7 @@ }, "node_modules/@types/chai": { "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", @@ -2564,6 +2995,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2576,14 +3014,14 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" @@ -2653,6 +3091,13 @@ "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -2660,6 +3105,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", @@ -2747,6 +3202,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -2776,16 +3255,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", - "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "version": "4.1.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2794,13 +3273,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", - "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "version": "4.1.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.2", + "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2821,9 +3300,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "version": "4.1.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", "dev": true, "license": "MIT", "dependencies": { @@ -2834,13 +3313,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", - "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "version": "4.1.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.5", "pathe": "^2.0.3" }, "funding": { @@ -2848,14 +3327,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", - "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "version": "4.1.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2864,9 +3343,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "version": "4.1.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "dev": true, "license": "MIT", "funding": { @@ -2874,13 +3353,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "version": "4.1.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", + "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3246,6 +3725,12 @@ "node": ">= 10.0.0" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/aproba": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", @@ -3351,8 +3836,16 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -3366,7 +3859,7 @@ }, "node_modules/assertion-error": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", @@ -3763,7 +4256,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, "node_modules/builder-util": { @@ -3843,6 +4335,17 @@ "node": ">= 10.0.0" } }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -4045,7 +4548,7 @@ }, "node_modules/chai": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/chai/-/chai-6.2.2.tgz", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", @@ -4070,6 +4573,27 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -4171,6 +4695,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -4269,6 +4802,16 @@ "node": ">=0.10.0" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compress-commons": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", @@ -4293,6 +4836,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/config-file-ts": { "version": "0.2.8-rc1", "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz", @@ -4400,7 +4958,7 @@ }, "node_modules/convert-source-map": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" @@ -4423,6 +4981,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -4715,6 +5280,17 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", @@ -5247,82 +5823,6 @@ "node": ">= 10.0.0" } }, - "node_modules/electron-updater": { - "version": "6.8.3", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz", - "integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==", - "license": "MIT", - "dependencies": { - "builder-util-runtime": "9.5.1", - "fs-extra": "^10.1.0", - "js-yaml": "^4.1.0", - "lazy-val": "^1.0.5", - "lodash.escaperegexp": "^4.1.2", - "lodash.isequal": "^4.5.0", - "semver": "~7.7.3", - "tiny-typed-emitter": "^2.1.0" - } - }, - "node_modules/electron-updater/node_modules/builder-util-runtime": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", - "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "sax": "^1.2.4" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/electron-updater/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-updater/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-updater/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/electron-updater/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/electron/node_modules/@types/node": { "version": "20.19.37", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", @@ -5552,7 +6052,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -5574,7 +6073,7 @@ }, "node_modules/estree-walker": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", @@ -5695,6 +6194,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -5747,6 +6258,37 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/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://mirrors.huaweicloud.com/repository/npm/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://mirrors.huaweicloud.com/repository/npm/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -5759,7 +6301,7 @@ }, "node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", @@ -5960,6 +6502,24 @@ "node": ">= 0.6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6306,6 +6866,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/grammy": { @@ -6323,6 +6884,49 @@ "node": "^12.20.0 || >=14.13.1" } }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/gray-matter/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6674,6 +7278,15 @@ "is-ci": "bin.js" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmmirror.com/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -6792,6 +7405,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6861,10 +7475,20 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, "license": "MIT" }, "node_modules/lazystream": { @@ -6967,7 +7591,7 @@ }, "node_modules/lightningcss": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", @@ -6997,7 +7621,7 @@ }, "node_modules/lightningcss-android-arm64": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" @@ -7018,7 +7642,7 @@ }, "node_modules/lightningcss-darwin-arm64": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" @@ -7039,7 +7663,7 @@ }, "node_modules/lightningcss-darwin-x64": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" @@ -7060,7 +7684,7 @@ }, "node_modules/lightningcss-freebsd-x64": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" @@ -7081,7 +7705,7 @@ }, "node_modules/lightningcss-linux-arm-gnueabihf": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" @@ -7102,12 +7726,15 @@ }, "node_modules/lightningcss-linux-arm64-gnu": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7123,12 +7750,15 @@ }, "node_modules/lightningcss-linux-arm64-musl": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7144,12 +7774,15 @@ }, "node_modules/lightningcss-linux-x64-gnu": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7165,12 +7798,15 @@ }, "node_modules/lightningcss-linux-x64-musl": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7186,7 +7822,7 @@ }, "node_modules/lightningcss-win32-arm64-msvc": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" @@ -7207,7 +7843,7 @@ }, "node_modules/lightningcss-win32-x64-msvc": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" @@ -7267,12 +7903,6 @@ "license": "MIT", "peer": true }, - "node_modules/lodash.escaperegexp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", - "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", - "license": "MIT" - }, "node_modules/lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", @@ -7287,13 +7917,6 @@ "integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==", "license": "MIT" }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "license": "MIT" - }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -7404,7 +8027,7 @@ }, "node_modules/magic-string": { "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", @@ -7546,6 +8169,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -7758,6 +8391,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/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/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/music-metadata": { "version": "11.12.3", "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.3.tgz", @@ -7789,10 +8484,19 @@ "node": ">=18" } }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -8407,7 +9111,7 @@ }, "node_modules/pathe": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" @@ -8443,7 +9147,7 @@ }, "node_modules/picomatch": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", @@ -8518,9 +9222,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.13", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", "dev": true, "funding": [ { @@ -9048,6 +9752,19 @@ "node": ">=10" } }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -9191,14 +9908,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "version": "1.0.0-rc.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" @@ -9207,21 +9924,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" } }, "node_modules/router": { @@ -9289,11 +10006,25 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" } }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -9598,6 +10329,23 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-git": { + "version": "3.36.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/simple-git/-/simple-git-3.36.0.tgz", + "integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "@simple-git/args-pathspec": "^1.0.3", + "@simple-git/argv-parser": "^1.1.0", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -9715,7 +10463,7 @@ }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", @@ -9797,6 +10545,14 @@ "dev": true, "license": "MIT" }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/streamx": { "version": "2.25.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", @@ -9874,6 +10630,15 @@ "node": ">=8" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -9912,6 +10677,42 @@ "node": ">= 8.0" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -10070,12 +10871,6 @@ "node": ">=20" } }, - "node_modules/tiny-typed-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", - "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", - "license": "MIT" - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -10094,14 +10889,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -10112,7 +10907,7 @@ }, "node_modules/tinyrainbow": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/tinyrainbow/-/tinyrainbow-3.1.0.tgz", "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", @@ -10262,9 +11057,15 @@ "dev": true, "license": "MIT" }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", @@ -10397,17 +11198,17 @@ } }, "node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "version": "8.0.10", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", - "tinyglobby": "^0.2.15" + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -10424,7 +11225,7 @@ "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -10475,19 +11276,19 @@ } }, "node_modules/vitest": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", - "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "version": "4.1.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.2", - "@vitest/mocker": "4.1.2", - "@vitest/pretty-format": "4.1.2", - "@vitest/runner": "4.1.2", - "@vitest/snapshot": "4.1.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -10515,10 +11316,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.2", - "@vitest/browser-preview": "4.1.2", - "@vitest/browser-webdriverio": "4.1.2", - "@vitest/ui": "4.1.2", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -10542,6 +11345,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, diff --git a/package.json b/package.json index 0315de8..3d43e36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-bridge", - "version": "2.9.59", + "version": "3.1.0", "type": "module", "description": "Feishu / Discord × OpenCode bridge service with runtime cron and reliability tooling", "main": "dist-electron/main.js", @@ -33,7 +33,7 @@ "build:web": "cd web && pnpm install && pnpm run build", "build:electron": "tsc -p tsconfig.electron.json", "build:all": "npm run build:web && npm run build && npm run build:electron", - "rebuild": "electron-rebuild -f -w better-sqlite3 -v", + "rebuild": "electron-rebuild -f -w better-sqlite3", "pack": "electron-builder --dir", "dist": "electron-builder --publish never", "dist:win": "electron-builder --win --publish never", @@ -66,39 +66,47 @@ "url": "https://github.com/HNGM-HP/opencode-bridge/issues" }, "dependencies": { + "@hapi/boom": "^10.0.1", + "@inquirer/prompts": "^8.4.2", "@larksuiteoapi/node-sdk": "^1.36.3", "@opencode-ai/sdk": "latest", "@wecom/aibot-node-sdk": "^1.0.4", "@whiskeysockets/baileys": "^7.0.0-rc.9", + "axios": "^1.13.6", "better-sqlite3": "^12.8.0", + "chokidar": "^5.0.0", "dingtalk-stream": "^2.1.4", "discord.js": "^14.25.1", "dotenv": "^16.4.5", - "express": "^5.2.1", + "express": "^5.2.1", "grammy": "^1.41.1", + "gray-matter": "^4.0.3", + "multer": "^2.1.1", "node-cron": "^4.2.1", - "qrcode": "^1.5.4" + "qrcode": "^1.5.4", + "simple-git": "^3.36.0", + "ws": "^8.20.0" }, "devDependencies": { "@grammyjs/types": "^3.25.0", - "@hapi/boom": "^10.0.1", "@types/axios": "^0.14.4", "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", + "@types/multer": "^2.1.0", "@types/node": "^22.10.0", "@types/pino": "^7.0.5", "@types/qrcode": "^1.5.6", + "@types/supertest": "^6.0.3", "@types/ws": "^8.18.1", - "axios": "^1.13.6", "electron": "^34.5.8", "electron-builder": "^25.1.8", "electron-rebuild": "^3.2.9", "pino": "^10.3.1", "puppeteer": "^24.40.0", + "supertest": "^7.2.2", "tsx": "^4.19.0", - "typescript": "^5.6.0", - "vitest": "^4.0.18", - "ws": "^8.20.0" + "typescript": "^5.9.3", + "vitest": "^4.1.5" }, "engines": { "node": ">=20.0.0" @@ -115,10 +123,7 @@ "dist-electron/**/*", "assets/**/*", "package.json", - "node_modules/**/*", "!**/*.ts", - "!**/node_modules/*/{CHANGELOG.md,README.md,readme.md,LICENSE,license}", - "!**/node_modules/.bin", "!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}", "!.editorconfig", "!**/._*", @@ -127,9 +132,6 @@ "!web/src" ], "asar": true, - "asarUnpack": [ - "**/node_modules/better-sqlite3/**/*" - ], "extraResources": [ { "from": "dist", @@ -151,6 +153,33 @@ "filter": [ "**/*" ] + }, + { + "from": "node_modules", + "to": "app/node_modules", + "filter": [ + "**/*", + "!*/{CHANGELOG.md,README.md,readme.md,LICENSE,license}", + "!.bin/**", + "!@esbuild{,/**}", + "!@img{,/**}", + "!@rolldown{,/**}", + "!@types{,/**}", + "!7zip-bin{,/**}", + "!app-builder-bin{,/**}", + "!chromium-bidi{,/**}", + "!devtools-protocol{,/**}", + "!electron{,/**}", + "!electron-builder{,/**}", + "!electron-rebuild{,/**}", + "!esbuild{,/**}", + "!lightningcss-*{,/**}", + "!puppeteer{,/**}", + "!puppeteer-core{,/**}", + "!tsx{,/**}", + "!typescript{,/**}", + "!vitest{,/**}" + ] } ], "win": { @@ -187,11 +216,12 @@ ], "icon": "assets/icon-1024.png", "category": "public.app-category.productivity", - "hardenedRuntime": true, - "gatekeeperAssess": false, "extendInfo": { "NSHighResolutionCapable": true, - "CFBundleShortVersionString": "${version}" + "CFBundleShortVersionString": "${version}", + "NSAppleEventsUsageDescription": "需要与系统交互以提供服务。", + "NSCameraUsageDescription": "需要访问摄像头以提供相关功能。", + "NSMicrophoneUsageDescription": "需要访问麦克风以提供相关功能。" } }, "dmg": { diff --git a/scripts/deploy.mjs b/scripts/deploy.mjs index bdac2a4..761c69d 100644 --- a/scripts/deploy.mjs +++ b/scripts/deploy.mjs @@ -1,7 +1,6 @@ #!/usr/bin/env node import fs from 'node:fs'; -import crypto from 'node:crypto'; import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; @@ -557,7 +556,7 @@ function ensureEnvFile() { const pureEnvContent = `ADMIN_PORT=4098\n`; fs.writeFileSync(envPath, pureEnvContent, 'utf-8'); - console.log('[deploy] 📄 已生成极简版 .env 文件。首次访问 Web 管理面板时需设置管理员密码。'); + console.log('[deploy] 📄 已生成极简版 .env 文件,可直接通过 Web 管理面板进行配置(无需登录)。'); } function ensureLogDir() { @@ -695,7 +694,6 @@ async function runBeginnerGuide() { // 读取生成的 .env 文件获取面板信息 const envConfig = parseDotEnvFile(); const adminPort = envConfig.ADMIN_PORT || '4098'; - const adminPassword = envConfig.ADMIN_PASSWORD || '未设置'; // 获取本机局域网 IP const interfaces = os.networkInterfaces(); @@ -713,9 +711,8 @@ async function runBeginnerGuide() { console.log('\n📋 后续步骤:\n'); console.log('1️⃣ 启动服务:在菜单中选择「2) 启动后台进程」'); - console.log('2️⃣ 访问配置面板:使用浏览器访问下方地址'); + console.log('2️⃣ 访问配置面板:使用浏览器访问下方地址(无需登录)'); console.log(` 🔗 http://${lanIp}:${adminPort}`); - console.log(` 🔑 管理员密码:${adminPassword}`); console.log('\n3️⃣ 在 Web 面板中配置飞书 App ID / App Secret 等平台凭据'); console.log('4️⃣ 保存配置后服务会自动提示是否需要重启\n'); console.log('💡 提示:所有配置项(飞书、Discord、高可用、Cron 任务等)'); @@ -1241,59 +1238,6 @@ function printLinuxStatus() { console.log(`[deploy] 提示: 若服务正在运行,请使用浏览器访问 http://<机器IP>:${port}`); } -// ────────────────────────────────────────────── -// 重置管理员密码 -// ────────────────────────────────────────────── - -function resolveDataDir() { - const explicit = process.env.OPENCODE_BRIDGE_CONFIG_DIR?.trim(); - if (explicit) { - return path.resolve(explicit); - } - return path.join(rootDir, 'data'); -} - -async function resetAdminPassword() { - const dataDir = resolveDataDir(); - const dbPath = path.join(dataDir, 'config.db'); - - if (!fs.existsSync(dbPath)) { - console.log('[deploy] 数据库文件不存在,密码将在首次启动时从 .env 初始化'); - return; - } - - // 生成新密码 - const newPassword = crypto.randomBytes(8).toString('hex'); - - // 使用 better-sqlite3 直接操作数据库 - try { - // 动态导入 better-sqlite3 - const Database = (await import('better-sqlite3')).default; - const db = new Database(dbPath); - - // 更新密码并清除 password_changed_at(强制用户再次修改) - db.prepare(` - INSERT INTO admin_meta (key, value) VALUES ('admin_password', ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value - `).run(newPassword); - - db.prepare(` - INSERT INTO admin_meta (key, value) VALUES ('password_changed_at', ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value - `).run(new Date().toISOString()); - - db.close(); - - console.log(`[deploy] ✅ 管理员密码已重置为: ${newPassword}`); - console.log('[deploy] 请使用新密码登录 Web 管理面板'); - console.log('[deploy] 登录后系统会要求您修改密码'); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`[deploy] 重置密码失败: ${message}`); - console.error('[deploy] 请确保服务已停止后重试'); - } -} - async function showMenu() { const rl = readline.createInterface({ input: process.stdin, @@ -1314,13 +1258,11 @@ async function showMenu() { console.log('5) 停止 systemd 服务'); console.log('6) 卸载 systemd 服务'); console.log('7) 查看运行状态'); - console.log('8) 重置管理员密码'); console.log('0) 退出'); } else { console.log('1) 一键部署'); console.log('2) 启动后台进程'); console.log('3) 停止后台进程'); - console.log('4) 重置管理员密码'); console.log('0) 退出'); } @@ -1350,9 +1292,6 @@ async function showMenu() { case '7': printLinuxStatus(); break; - case '8': - await resetAdminPassword(); - break; case '0': return; default: @@ -1369,9 +1308,6 @@ async function showMenu() { case '3': await stopBackgroundProcess(rl); break; - case '4': - await resetAdminPassword(); - break; case '0': return; default: @@ -1395,7 +1331,6 @@ function printUsage() { console.log(' deploy 一键部署'); console.log(' start 启动后台进程'); console.log(' stop 停止后台进程'); - console.log(' reset-password 重置管理员密码'); console.log(' menu 打开交互菜单(默认)'); if (isLinux()) { console.log(' service-install 安装并启动 systemd 服务'); @@ -1440,9 +1375,6 @@ async function main() { case 'status': printLinuxStatus(); break; - case 'reset-password': - await resetAdminPassword(); - break; case 'help': case '--help': case '-h': diff --git a/scripts/process-manager.mjs b/scripts/process-manager.mjs index b9cbd5d..e9fe3d8 100644 --- a/scripts/process-manager.mjs +++ b/scripts/process-manager.mjs @@ -4,15 +4,30 @@ * 跨平台进程管理工具 * * 用法: - * node process-manager.mjs kill-bridge # 终止所有 Bridge 进程 - * node process-manager.mjs kill-opencode # 终止所有 OpenCode 进程 - * node process-manager.mjs list-bridge # 列出所有 Bridge 进程 - * node process-manager.mjs list-opencode # 列出所有 OpenCode 进程 + * node process-manager.mjs kill-bridge # 终止所有 Bridge 进程 + * node process-manager.mjs kill-opencode # 终止所有 OpenCode 进程 + * node process-manager.mjs list-bridge # 列出所有 Bridge 进程 + * node process-manager.mjs list-opencode # 列出所有 OpenCode 进程 + * node process-manager.mjs start-opencode # 后台启动 opencode serve(幂等) + * node process-manager.mjs status-opencode # 检查 opencode serve 运行状态 */ -import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import process from 'node:process'; +// ==================== 路径常量 ==================== + +const scriptFile = fileURLToPath(import.meta.url); +const scriptDir = path.dirname(scriptFile); +const rootDir = path.resolve(scriptDir, '..'); +const logsDir = path.join(rootDir, 'logs'); +const opencodePidFile = path.join(logsDir, 'opencode.pid'); +const opencodeLogFile = path.join(logsDir, 'opencode.log'); +const opencodeErrFile = path.join(logsDir, 'opencode.err'); + // ==================== 平台检测 ==================== function isWindows() { @@ -351,11 +366,13 @@ function printUsage() { 跨平台进程管理工具 用法: - node process-manager.mjs kill-bridge # 终止所有 Bridge 进程 - node process-manager.mjs kill-opencode # 终止所有 OpenCode 进程 - node process-manager.mjs list-bridge # 列出所有 Bridge 进程 - node process-manager.mjs list-opencode # 列出所有 OpenCode 进程 - node process-manager.mjs help # 显示此帮助信息 + node process-manager.mjs kill-bridge # 终止所有 Bridge 进程 + node process-manager.mjs kill-opencode # 终止所有 OpenCode 进程 + node process-manager.mjs list-bridge # 列出所有 Bridge 进程 + node process-manager.mjs list-opencode # 列出所有 OpenCode 进程 + node process-manager.mjs start-opencode # 后台启动 opencode serve(幂等) + node process-manager.mjs status-opencode # 检查 opencode serve 运行状态 + node process-manager.mjs help # 显示此帮助信息 选项: --exclude-pid 排除指定 PID(用于防止自杀) @@ -462,6 +479,36 @@ function main() { break; } + case 'start-opencode': { + console.log('[process-manager] 正在启动 opencode serve...'); + const result = startOpenCodeServe(); + if (result.skipped) { + console.log(`[process-manager] opencode serve 已在运行 (PID: ${result.pid})`); + } else if (result.started) { + console.log(`[process-manager] opencode serve 已启动 (PID: ${result.pid})`); + console.log(`[process-manager] 日志文件:${opencodeLogFile}`); + } else { + console.error(`[process-manager] opencode serve 启动失败:${result.reason}`); + process.exit(1); + } + break; + } + + case 'status-opencode': { + const alivePid = readAlivePid(opencodePidFile); + if (alivePid !== null) { + console.log(`[process-manager] opencode serve 运行中 (PID: ${alivePid})`); + } else { + const scanPids = findOpenCodeProcesses(); + if (scanPids.length > 0) { + console.log(`[process-manager] opencode serve 运行中(扫描到 PID: ${scanPids.join(', ')},但 PID 文件缺失)`); + } else { + console.log('[process-manager] opencode serve 未运行'); + } + } + break; + } + case 'help': case '--help': case '-h': @@ -471,6 +518,285 @@ function main() { } } +// ==================== OpenCode 启动 ==================== + +/** + * 在 Windows 下定位 opencode 可执行方式 + * 返回 { type: 'node-script', nodeExe, script } 或 { type: 'shell', cmd: 'opencode' } + */ +function resolveOpenCodeExecutable() { + if (!isWindows()) { + return { type: 'shell', cmd: 'opencode' }; + } + + // 1. 优先通过 npm root -g 找到真正的 JS 入口,用 node.exe 直接启动 + // 避免通过 .cmd 包装层(windowsHide 对 cmd.exe 子进程不稳定) + try { + const npmRootResult = spawnSync('npm', ['root', '-g'], { + encoding: 'utf-8', + windowsHide: true, + shell: true, // npm 在 Windows 是 npm.cmd,需要 shell + timeout: 8000, + // 防止 shell 命令弹窗,重定向输出 + stdio: 'pipe', + }); + if (!npmRootResult.error && npmRootResult.status === 0) { + const globalRoot = npmRootResult.stdout.trim(); + const candidates = [ + path.join(globalRoot, 'opencode-ai', 'bin', 'opencode'), + path.join(globalRoot, '@opencode-ai', 'opencode', 'bin', 'opencode'), + path.join(globalRoot, 'opencode', 'bin', 'opencode'), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return { type: 'node-script', nodeExe: process.execPath, script: candidate }; + } + } + } + } catch { + // ignore, fall through + } + + // 2. 尝试 where opencode 找到 .cmd 或 .exe 路径 + try { + const whereResult = spawnSync('where', ['opencode'], { + encoding: 'utf-8', + windowsHide: true, + shell: true, // where 是内置命令,需要 shell + timeout: 5000, + stdio: 'pipe', + }); + if (!whereResult.error && whereResult.status === 0) { + const lines = whereResult.stdout.split(/\r?\n/).map(l => l.trim()).filter(Boolean); + // 优先选 .exe 或 .cmd,找到第一条即用 + const found = lines[0]; + if (found) { + // 如果是 .cmd 包装脚本,尝试从同目录的 node_modules 找 JS 入口 + if (found.toLowerCase().endsWith('.cmd')) { + // npm bin 目录通常是 node_modules\.bin 的上一级 + const binDir = path.dirname(found); + const globalRoot = path.resolve(binDir, '..', 'node_modules'); + const candidates = [ + path.join(globalRoot, 'opencode-ai', 'bin', 'opencode'), + path.join(globalRoot, '@opencode-ai', 'opencode', 'bin', 'opencode'), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return { type: 'node-script', nodeExe: process.execPath, script: candidate }; + } + } + } + return { type: 'direct', exe: found }; + } + } + } catch { + // ignore + } + + // 3. 最终回退:让 shell 自行解析(可能弹窗,但保证能跑) + return { type: 'shell', cmd: 'opencode' }; +} + +/** + * 检查指定 PID 对应的进程是否仍在运行 + */ +function isPidRunning(pid) { + try { + if (isWindows()) { + const result = spawnSync('tasklist', ['/FO', 'CSV', '/NH', '/FI', `PID eq ${pid}`], { + encoding: 'utf-8', + windowsHide: true, + timeout: 5000, + }); + return !result.error && result.stdout.includes(`"${pid}"`); + } else { + process.kill(pid, 0); // signal 0 = 仅检查进程是否存在 + return true; + } + } catch { + return false; + } +} + +/** + * 读取 PID 文件,若进程仍在运行则返回 PID,否则返回 null + */ +function readAlivePid(pidFilePath) { + try { + const content = fs.readFileSync(pidFilePath, 'utf-8').trim(); + const pid = parseInt(content, 10); + if (!isNaN(pid) && pid > 0 && isPidRunning(pid)) { + return pid; + } + } catch { + // 文件不存在或读取失败 + } + return null; +} + +/** + * 后台启动 opencode serve(幂等 - 如已运行则跳过) + * @param {object} options + * @param {string} [options.pidFilePath] + * @param {string} [options.logFile] + * @param {string} [options.errFile] + * @returns {{ started: boolean, pid: number | null, skipped: boolean, reason: string }} + */ +function startOpenCodeServe(options = {}) { + const pidFilePath = options.pidFilePath ?? opencodePidFile; + const logFile = options.logFile ?? opencodeLogFile; + const errFile = options.errFile ?? opencodeErrFile; + + // 幂等检查:PID 文件存在且进程健在 + const alivePid = readAlivePid(pidFilePath); + if (alivePid !== null) { + return { started: false, pid: alivePid, skipped: true, reason: `already_running` }; + } + + // 也通过进程扫描检查(防止 PID 文件丢失但进程还在的情况) + const scanPids = findOpenCodeProcesses(); + if (scanPids.length > 0) { + // 补写 PID 文件 + try { + fs.mkdirSync(path.dirname(pidFilePath), { recursive: true }); + fs.writeFileSync(pidFilePath, String(scanPids[0]), 'utf-8'); + } catch { /* ignore */ } + return { started: false, pid: scanPids[0], skipped: true, reason: `already_running_no_pidfile` }; + } + + // 确保日志目录存在 + fs.mkdirSync(path.dirname(pidFilePath), { recursive: true }); + + const exe = resolveOpenCodeExecutable(); + let child; + let windowsHiddenPid = null; + + try { + const stdoutFd = fs.openSync(logFile, 'a'); + const stderrFd = fs.openSync(errFile, 'a'); + + if (isWindows() && (exe.type === 'node-script' || exe.type === 'direct')) { + // Windows 关键修复:不能用 Node 的 windowsHide:true(CREATE_NO_WINDOW), + // 因为 opencode JS 脚本内部会再 spawn 平台二进制 opencode-windows-x64\bin\opencode.exe, + // 父进程没 console → Windows 会为这个孙进程重新分配一个可见的黑窗。 + // + // 改用 PowerShell 的 Start-Process -WindowStyle Hidden(STARTF_USESHOWWINDOW + SW_HIDE), + // 它会分配一个隐藏的 console,孙进程继承这个隐藏 console,不会弹窗。 + // 通过 -PassThru 拿到真实 node.exe / opencode.exe 的 PID。 + // + // ⚠️ 绝对不能传 -RedirectStandardOutput/-RedirectStandardError 给 Start-Process! + // 那会让 PS 内部切到 UseShellExecute=false,子进程的 stdio 变成管道, + // 继而导致孙 opencode.exe 没有可继承的 console → 再次弹出黑窗。 + // 因此这里不再落盘 opencode 的 stdout/stderr(无日志文件也比黑窗强; + // 排查时用 opencode attach 前台窗口即可看实时输出)。 + fs.closeSync(stdoutFd); + fs.closeSync(stderrFd); + + const filePath = exe.type === 'node-script' ? exe.nodeExe : exe.exe; + const argList = exe.type === 'node-script' + ? [exe.script, 'serve'] + : ['serve']; + + windowsHiddenPid = startHiddenOnWindows({ filePath, argList }); + } else if (exe.type === 'node-script') { + // 非 Windows 不会走到这里,但保留以防万一 + child = spawn(exe.nodeExe, [exe.script, 'serve'], { + detached: true, + stdio: ['ignore', stdoutFd, stderrFd], + windowsHide: true, + }); + child.unref(); + fs.closeSync(stdoutFd); + fs.closeSync(stderrFd); + } else if (exe.type === 'direct') { + child = spawn(exe.exe, ['serve'], { + detached: true, + stdio: ['ignore', stdoutFd, stderrFd], + windowsHide: true, + }); + child.unref(); + fs.closeSync(stdoutFd); + fs.closeSync(stderrFd); + } else { + // Unix / 回退: opencode serve + const args = ['serve']; + child = spawn(exe.cmd, args, { + detached: true, + stdio: ['ignore', stdoutFd, stderrFd], + shell: true, // ← 使用 shell 以解析 PATH + windowsHide: isWindows(), + }); + child.unref(); + fs.closeSync(stdoutFd); + fs.closeSync(stderrFd); + } + } catch (e) { + return { started: false, pid: null, skipped: false, reason: `spawn_error: ${e.message}` }; + } + + // 保存 PID + const pid = windowsHiddenPid ?? child?.pid ?? null; + if (pid) { + fs.writeFileSync(pidFilePath, String(pid), 'utf-8'); + } + + return { started: true, pid, skipped: false, reason: 'launched' }; +} + +/** + * Windows 下用 PowerShell Start-Process -WindowStyle Hidden 启动进程, + * 既能保证真正隐藏(父+孙 console 程序都不弹窗),又能通过 -PassThru 拿到 PID。 + * + * ⚠️ 此函数严格复刻用户已验证可工作的 PS 脚本模板: + * Start-Process -WindowStyle Hidden -FilePath $nodeExe -ArgumentList ... -PassThru + * 不要在这里加 -RedirectStandardOutput / -RedirectStandardError —— 一旦加上, + * PS 会切到 UseShellExecute=false,SW_HIDE 对孙进程失效,opencode.exe 会弹出黑窗。 + * + * @param {{ filePath: string, argList: string[] }} opts + * @returns {number} + */ +function startHiddenOnWindows({ filePath, argList }) { + // PowerShell 单引号字符串只需把 ' 转义成 '' + const psEscape = (s) => String(s).replace(/'/g, "''"); + // ArgumentList 每个元素单独作为 PS 字符串 + const argListLiteral = argList.length === 0 + ? "@()" + : argList.map((a) => `'${psEscape(a)}'`).join(','); + + const psCommand = [ + `$ErrorActionPreference='Stop'`, + `$p = Start-Process -WindowStyle Hidden -FilePath '${psEscape(filePath)}' ` + + `-ArgumentList ${argListLiteral} ` + + `-PassThru`, + `Write-Output $p.Id`, + ].join('; '); + + const result = spawnSync('powershell.exe', [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', 'Bypass', + '-WindowStyle', 'Hidden', + '-Command', psCommand, + ], { + encoding: 'utf-8', + windowsHide: true, + timeout: 15000, + }); + + if (result.error) { + throw new Error(`powershell launch failed: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error(`powershell exit ${result.status}: ${result.stderr || result.stdout}`); + } + const pidStr = String(result.stdout || '').trim().split(/\s+/).pop(); + const pid = Number.parseInt(pidStr, 10); + if (!pid || Number.isNaN(pid)) { + throw new Error(`could not parse PID from powershell output: ${result.stdout}`); + } + return pid; +} + // 导出供其他模块使用 export { isWindows, @@ -479,6 +805,8 @@ export { findOpenCodeProcesses, stopProcesses, waitForExit, + startOpenCodeServe, + readAlivePid, }; // 作为 CLI 直接执行 diff --git a/scripts/setup-mirror.mjs b/scripts/setup-mirror.mjs index f67f917..7700c37 100644 --- a/scripts/setup-mirror.mjs +++ b/scripts/setup-mirror.mjs @@ -7,7 +7,7 @@ * 用法: node scripts/setup-mirror.mjs */ -import { writeFileSync, existsSync, appendFileSync } from 'fs'; +import { writeFileSync, existsSync, appendFileSync, readFileSync } from 'fs'; import { execSync } from 'child_process'; // 镜像源配置 (主1备2兜底1) @@ -147,7 +147,7 @@ async function main() { const npmrcPath = '.npmrc'; if (existsSync(npmrcPath)) { // 读取现有配置,移除自动生成的部分 - const existing = require('fs').readFileSync(npmrcPath, 'utf-8'); + const existing = readFileSync(npmrcPath, 'utf-8'); const lines = existing.split('\n').filter(line => !line.includes('# 自动生成的镜像配置') && !line.includes('# 生成时间:') && diff --git a/scripts/start.mjs b/scripts/start.mjs index a602f2b..9ba98a9 100644 --- a/scripts/start.mjs +++ b/scripts/start.mjs @@ -25,6 +25,9 @@ const errLog = path.join(logsDir, 'service.err'); const adminEntryFile = path.join(rootDir, 'dist', 'admin', 'index.js'); const processManagerPath = path.join(rootDir, 'scripts', 'process-manager.mjs'); +// 是否跳过 opencode serve 启动(用于已由外部管理 opencode 的场景) +const skipOpencodeStart = process.argv.includes('--no-opencode'); + function isWindows() { return process.platform === 'win32'; } @@ -144,24 +147,36 @@ function sleep(ms) { } } -function main() { - ensureLogDir(); - - // 1. 调用进程管理工具清理旧进程(不传递 --exclude-self,因为这是独立调用) - console.log('[start] 清理旧进程...'); - const cleanupResult = spawnSync(process.execPath, [processManagerPath, 'kill-bridge'], { +function runProcessManager(args, options = {}) { + const result = spawnSync(process.execPath, [processManagerPath, ...args], { stdio: 'pipe', encoding: 'utf-8', windowsHide: isWindows(), + ...options, }); + if (result.stdout?.trim()) console.log(result.stdout.trim()); + if (result.stderr?.trim()) console.error(result.stderr.trim()); + return result; +} - if (cleanupResult.stdout) { - console.log(cleanupResult.stdout.trim()); - } - if (cleanupResult.stderr) { - console.error(cleanupResult.stderr.trim()); +function main() { + ensureLogDir(); + + // 0. 启动 opencode serve(幂等 - 如果已在运行则跳过) + if (skipOpencodeStart) { + console.log('[start] 跳过 opencode serve 启动(--no-opencode)'); + } else { + console.log('[start] 启动 opencode serve...'); + const opencodeResult = runProcessManager(['start-opencode']); + if (opencodeResult.status !== 0) { + console.warn('[start] 警告:opencode serve 启动失败,继续启动 Bridge(可稍后手动启动 opencode)'); + } } + // 1. 调用进程管理工具清理旧 Bridge 进程(不传递 --exclude-self,因为这是独立调用) + console.log('[start] 清理旧 Bridge 进程...'); + runProcessManager(['kill-bridge']); + // 2. 等待 3 秒,确保旧进程完全退出 console.log('[start] 等待进程退出...'); sleep(3000); diff --git a/scripts/stop.mjs b/scripts/stop.mjs index a36a587..3e83177 100644 --- a/scripts/stop.mjs +++ b/scripts/stop.mjs @@ -20,6 +20,7 @@ const scriptDir = path.dirname(scriptFile); const rootDir = path.resolve(scriptDir, '..'); const logsDir = path.join(rootDir, 'logs'); const pidFile = path.join(logsDir, 'bridge.pid'); +const opencodePidFile = path.join(logsDir, 'opencode.pid'); const processManagerPath = path.join(rootDir, 'scripts', 'process-manager.mjs'); function isWindows() { @@ -45,6 +46,8 @@ function main() { } catch (e) { console.error('[stop] 终止 OpenCode 进程异常:', e.message); } + // 清理 opencode PID 文件 + fs.rmSync(opencodePidFile, { force: true }); console.log('[stop] OpenCode 进程清理完成'); } diff --git a/src/admin/admin-server.ts b/src/admin/admin-server.ts index 3984602..d397b0b 100644 --- a/src/admin/admin-server.ts +++ b/src/admin/admin-server.ts @@ -18,7 +18,6 @@ */ import express from 'express'; -import crypto from 'node:crypto'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; @@ -28,13 +27,175 @@ import { configStore, type BridgeSettings } from '../store/config-store.js'; import { logStore } from '../store/log-store.js'; import type { RuntimeCronManager } from '../reliability/runtime-cron.js'; import type { BridgeManager } from './bridge-manager.js'; +import { opencodeConfig } from '../config.js'; import { createSessionRoutes } from './routes/session.js'; +import { registerWorkspaceGitRoutes } from './routes/workspace-git.js'; +import { registerWorkspaceFilesRoutes } from './routes/workspace-files.js'; +import { registerWorkspaceTerminalRoutes } from './routes/workspace-terminal.js'; +import { registerResourcesTerminalRoutes, setupResourcesTerminalWebSocket } from './routes/resources-terminal.js'; +import { registerChatRoutes } from './routes/chat.js'; +import { registerChatUploadRoutes } from './routes/chat-upload.js'; +import { createResourcesRoutes } from './routes/resources.js'; +import { getAutoStart, setAutoStart } from './autostart.js'; +import { initResourceSystem } from '../services/resources/index.js'; +import { opencodeClient } from '../opencode/client.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +function toRecord(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +function parseOptionalBoolean(value: unknown): boolean | undefined { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true' || normalized === '1') return true; + if (normalized === 'false' || normalized === '0') return false; + } + return undefined; +} + +function extractEnabledModelsFromOpencodeConfig(config: unknown): string[] { + const root = toRecord(config); + if (!root) return []; + + const providersRecord = toRecord(root.provider) || toRecord(root.providers); + if (!providersRecord) return []; + + const selected = new Set(); + for (const [providerId, rawProvider] of Object.entries(providersRecord)) { + const providerRecord = toRecord(rawProvider); + if (!providerRecord) continue; + + const rawModels = providerRecord.models; + if (Array.isArray(rawModels)) { + for (const item of rawModels) { + if (typeof item === 'string' && item.trim()) { + selected.add(`${providerId}/${item.trim()}`); + continue; + } + const modelRecord = toRecord(item); + const modelId = typeof modelRecord?.id === 'string' && modelRecord.id.trim() + ? modelRecord.id.trim() + : ''; + if (modelId) { + selected.add(`${providerId}/${modelId}`); + } + } + continue; + } + + const modelMap = toRecord(rawModels); + if (!modelMap) continue; + + for (const [modelKey, rawModel] of Object.entries(modelMap)) { + if (rawModel === false || rawModel === null) { + continue; + } + + const modelRecord = toRecord(rawModel); + const disabled = parseOptionalBoolean(modelRecord?.disabled); + if (disabled === true) { + continue; + } + + const configId = typeof modelRecord?.id === 'string' && modelRecord.id.trim() + ? modelRecord.id.trim() + : modelKey.trim(); + if (configId) { + selected.add(`${providerId}/${configId}`); + } + } + } + + return Array.from(selected).sort((left, right) => left.localeCompare(right, 'en')); +} + +function extractProviderId(provider: unknown): string | undefined { + const record = toRecord(provider); + const rawId = typeof record?.id === 'string' ? record.id.trim() : ''; + return rawId || undefined; +} + +function extractProviderModels(provider: unknown): Array<{ id: string; name: string }> { + const record = toRecord(provider); + const rawModels = record?.models; + const models: Array<{ id: string; name: string }> = []; + const dedupe = new Set(); + + const pushModel = (rawModel: unknown, fallbackId?: string): void => { + const fallbackNormalized = typeof fallbackId === 'string' ? fallbackId.trim() : ''; + if (!rawModel || typeof rawModel !== 'object') { + if (!fallbackNormalized) return; + const key = fallbackNormalized.toLowerCase(); + if (dedupe.has(key)) return; + dedupe.add(key); + models.push({ id: fallbackNormalized, name: fallbackNormalized }); + return; + } + + const modelRecord = rawModel as Record; + const modelId = typeof modelRecord.id === 'string' && modelRecord.id.trim() + ? modelRecord.id.trim() + : fallbackNormalized; + if (!modelId) return; + + const modelName = typeof modelRecord.name === 'string' && modelRecord.name.trim() + ? modelRecord.name.trim() + : modelId; + const key = modelId.toLowerCase(); + if (dedupe.has(key)) return; + dedupe.add(key); + models.push({ id: modelId, name: modelName }); + }; + + if (Array.isArray(rawModels)) { + for (const rawModel of rawModels) { + pushModel(rawModel); + } + } else { + const modelMap = toRecord(rawModels); + if (modelMap) { + for (const [modelKey, rawModel] of Object.entries(modelMap)) { + pushModel(rawModel, modelKey); + } + } + } + + return models.sort((left, right) => left.name.localeCompare(right.name, 'zh-Hans-CN')); +} + +function buildOpencodeAuthHeaders(): Record { + if (!opencodeConfig.serverPassword) { + return {}; + } + + const username = opencodeConfig.serverUsername || 'opencode'; + const authorization = Buffer.from(`${username}:${opencodeConfig.serverPassword}`).toString('base64'); + return { Authorization: `Basic ${authorization}` }; +} + // 开发模式检测(process.resourcesPath 是 Electron 特有属性) const isDev = process.env.NODE_ENV === 'development' || !(process as any).resourcesPath; +/** + * 获取 process-manager.mjs 的绝对路径 + * 兼容:开发环境 / 源码部署 / Electron 打包后 + */ +function resolveProcessManagerPath(): string { + if ((process as any).resourcesPath && !isDev) { + // Electron 打包:scripts 在 resources/app/scripts/ + return path.join((process as any).resourcesPath, 'app', 'scripts', 'process-manager.mjs'); + } + // 开发 / 源码部署:从 dist/admin/ 向上两级到项目根 + return path.resolve(__dirname, '../../scripts/process-manager.mjs'); +} + // ────────────────────────────────────────────── // 需要重启才能生效的敏感配置项 // ────────────────────────────────────────────── @@ -71,7 +232,7 @@ const RESTART_REQUIRED_KEYS: (keyof BridgeSettings)[] = [ 'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD', 'OPENCODE_AUTO_START', - 'OPENCODE_AUTO_START_CMD', + 'OPENCODE_AUTO_START_FOREGROUND', 'RELIABILITY_CRON_ENABLED', 'RELIABILITY_CRON_API_ENABLED', 'RELIABILITY_CRON_API_HOST', @@ -106,80 +267,30 @@ async function probeTcpPort(host: string, port: number, timeoutMs = 2000): Promi export interface AdminServerOptions { port: number; - password: string; // 仅用于首次初始化 cronManager?: RuntimeCronManager; startedAt?: Date; version?: string; bridgeManager?: BridgeManager; } -export function createAdminServer(options: AdminServerOptions): { start: () => void; stop: () => void } { +export function createAdminServer(options: AdminServerOptions): { start: () => Promise; stop: () => void } { const app = express(); const { port, cronManager, bridgeManager } = options; const startedAt = options.startedAt ?? new Date(); const version = options.version ?? 'unknown'; - // ── 密码初始化:首次启动从 env 读取,后续使用数据库密码 - const envPassword = options.password; - let dbPassword = configStore.getAdminPassword(); - if (!dbPassword && envPassword) { - configStore.setAdminPassword(envPassword); - dbPassword = envPassword; - console.log('[Admin] 首次启动,已从环境变量初始化管理员密码'); - } - app.use(express.json()); - // ── POST /api/admin/reset-password(无需认证,用于密码恢复) - app.post('/api/admin/reset-password', (_req, res) => { - configStore.setAdminPassword(''); - configStore.setPasswordChangedAt(''); - res.json({ ok: true, message: '密码已重置,请重新设置密码' }); - }); - // ── 静态前端文件(dist/public) const publicDir = path.resolve(__dirname, '../../dist/public'); app.use(express.static(publicDir)); - // ── 基础 Token 鉴权中间件(Bearer password) - // 每次请求从数据库读取密码,确保修改密码后立即生效 - function authMiddleware( - req: express.Request, - res: express.Response, - next: express.NextFunction - ): void { - const currentPassword = configStore.getAdminPassword() || ''; - const authHeader = req.headers.authorization ?? ''; - const hasToken = authHeader.startsWith('Bearer '); - const token = hasToken ? authHeader.slice(7) : ''; - - // 密码为空 → 允许通过(首次设置场景) - // 前端会通过 /api/admin/password-status 检测密码状态并跳转到设置页面 - if (!currentPassword) { - next(); - return; - } - - // 使用时序安全的密码比较,避免长度泄露 - // 将两个 buffer padding 到相同长度后再比较 - const tokenBuf = Buffer.from(token, 'utf-8'); - const passBuf = Buffer.from(currentPassword, 'utf-8'); - const maxLen = Math.max(tokenBuf.length, passBuf.length, 64); - - const paddedToken = Buffer.alloc(maxLen); - const paddedPass = Buffer.alloc(maxLen); - tokenBuf.copy(paddedToken); - passBuf.copy(paddedPass); - - if (!crypto.timingSafeEqual(paddedToken, paddedPass)) { - res.status(401).json({ error: 'Unauthorized' }); - return; - } - next(); - } - + // ── 管理后台不再启用账号 / 密码鉴权,所有请求直接放行 const api = express.Router(); - api.use(authMiddleware); + + // ── Register Chat Routes (Phase A: Native Chat UI) + registerChatRoutes(app); + registerChatUploadRoutes(app); // ── GET /api/config api.get('/config', (_req, res) => { @@ -337,51 +448,9 @@ export function createAdminServer(options: AdminServerOptions): { start: () => v startedAt: startedAt.toISOString(), dbPath: configStore.getDbPath(), cronJobCount: cronManager?.listJobs().length ?? 0, - needsPasswordChange: configStore.needsPasswordChange(), - }); - }); - - // ── GET /api/admin/password-status - api.get('/admin/password-status', (_req, res) => { - res.json({ - needsPasswordChange: configStore.needsPasswordChange(), - hasPassword: !!configStore.getAdminPassword(), }); }); - // ── PUT /api/admin/password - api.put('/admin/password', (req, res) => { - const { oldPassword, newPassword } = req.body; - - if (!newPassword || newPassword.length < 8) { - res.status(400).json({ error: '新密码长度至少 8 位' }); - return; - } - - const currentPassword = configStore.getAdminPassword(); - const isFirstSetup = !currentPassword; - - // 首次设置密码:无需验证旧密码 - if (isFirstSetup) { - configStore.setAdminPassword(newPassword); - configStore.setPasswordChangedAt(new Date().toISOString()); - res.json({ ok: true, message: '密码设置成功', isFirstSetup: true }); - return; - } - - // 修改密码:需要验证旧密码 - if (oldPassword !== currentPassword) { - res.status(401).json({ error: '原密码错误' }); - return; - } - - // 更新密码 - configStore.setAdminPassword(newPassword); - configStore.setPasswordChangedAt(new Date().toISOString()); - - res.json({ ok: true, message: '密码修改成功,请使用新密码重新登录' }); - }); - // ── POST /api/admin/restart api.post('/admin/restart', async (_req, res) => { if (!bridgeManager) { @@ -389,16 +458,16 @@ export function createAdminServer(options: AdminServerOptions): { start: () => v return; } - res.json({ ok: true, message: '正在重启 Bridge 服务...' }); + const result = await bridgeManager.restart(); - // 异步重启,不阻塞响应 - bridgeManager.restart().then(result => { - if (result.success) { - console.log(`[Admin] Bridge 重启成功,PID=${result.pid}`); - } else { - console.error(`[Admin] Bridge 重启失败: ${result.error}`); - } - }); + if (result.success) { + console.log(`[Admin] Bridge 重启成功,PID=${result.pid}`); + res.json({ ok: true, pid: result.pid, message: 'Bridge 重启成功' }); + return; + } + + console.error(`[Admin] Bridge 重启失败: ${result.error}`); + res.status(500).json({ error: result.error || '重启失败' }); }); // ── POST /api/admin/stop-bridge(仅停止 Bridge 进程) @@ -635,69 +704,83 @@ export function createAdminServer(options: AdminServerOptions): { start: () => v }); // ── POST /api/opencode/start - api.post('/opencode/start', async (req, res) => { + // 始终使用后台无窗口模式(通过 process-manager start-opencode,幂等) + api.post('/opencode/start', async (_req, res) => { try { - const { visual = false } = req.body || {}; + const { spawnSync: spawnSyncLocal } = await import('node:child_process'); + const scriptPath = resolveProcessManagerPath(); + const isWindows = process.platform === 'win32'; - // 写入 server 配置 - const opencodeConfigDir = path.join(os.homedir(), '.config', 'opencode'); - fs.mkdirSync(opencodeConfigDir, { recursive: true }); + const result = spawnSyncLocal(process.execPath, [scriptPath, 'start-opencode'], { + encoding: 'utf-8', + timeout: 20000, + windowsHide: isWindows, + }); - const configPath = path.join(opencodeConfigDir, 'opencode.json'); - let config: any = {}; - if (fs.existsSync(configPath)) { - try { - config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - } catch { - // 忽略解析错误 - } + const stdout = (result.stdout || '').trim(); + const stderr = (result.stderr || '').trim(); + if (stdout) console.log('[Admin] opencode start:', stdout); + if (stderr) console.warn('[Admin] opencode start stderr:', stderr); + + if (result.status !== 0 || result.error) { + const msg = result.error?.message || stderr || '启动失败'; + res.status(500).json({ error: msg }); + return; } - config.server = { - port: 4096, - hostname: '0.0.0.0', - cors: ['*'], - }; - fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + // 判断是"已运行"还是"新启动" + const skipped = stdout.includes('已在运行'); + res.json({ + ok: true, + message: skipped ? 'OpenCode 已在后台运行(无需重复启动)' : 'OpenCode 已后台启动', + }); + } catch (error: any) { + res.status(500).json({ error: '启动失败:' + error.message }); + } + }); - // 启动 OpenCode - // visual: true -> opencode (前台模式,显示 CLI 窗口 + web) - // visual: false -> opencode serve (后台模式,headless) - const isWindows = process.platform === 'win32'; + // ── POST /api/opencode/attach + // 在前台弹出 CMD 窗口执行 opencode attach (Windows 专用) + api.post('/opencode/attach', (req, res) => { + const isWindows = process.platform === 'win32'; + if (!isWindows) { + res.status(400).json({ error: '前台 attach 窗口仅支持 Windows 平台' }); + return; + } - if (isWindows) { - if (visual) { - // 前台模式:直接启动 opencode,显示 CLI 窗口 - spawn('opencode', [], { - detached: true, - stdio: 'ignore', - shell: true, - }); - } else { - // 后台模式:使用 VBS 静默启动,不显示窗口 - const vbsContent = ` -Set objShell = CreateObject("WScript.Shell") -objShell.Run "cmd /c opencode serve", 0, False -`.trim(); - const vbsPath = path.join(os.tmpdir(), 'opencode-start.vbs'); - fs.writeFileSync(vbsPath, vbsContent, 'utf-8'); - spawn('wscript', [vbsPath], { - detached: true, - stdio: 'ignore', - windowsHide: true, - }); - } - } else { - const args = visual ? [] : ['serve']; - spawn('opencode', args, { + try { + const { port = 4096, host = 'localhost' } = req.body || {}; + const attachUrl = `http://${host}:${port}`; + + // ⚠️ Windows 弹窗坑:旧实现 spawn('cmd', ['/c', 'start ... cmd /k ...'], { windowsHide: true }) + // 在打包后(Electron 主进程无控制台)等价于 CREATE_NO_WINDOW,会被传给后续的 cmd 子树, + // 导致 start 命令也无法分配可见控制台 → 用户点了按钮但什么都看不到。 + // + // 这里改用 PowerShell 的 Start-Process: + // - 外层 powershell.exe 仍 windowsHide:true,不污染界面,与 + // scripts/process-manager.mjs 隐藏 opencode serve 的策略一致; + // - 由 Start-Process 显式 CreateProcess 一个新的可见 cmd 控制台来跑 attach, + // 不依赖父进程是否有 console,从而保留"自启动 opencode 后台无窗"能力的同时 + // 恢复"前台 attach 窗口可见"行为。 + spawn( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `Start-Process cmd -ArgumentList '/k opencode attach ${attachUrl}'`, + ], + { detached: true, stdio: 'ignore', - }); - } + windowsHide: true, + } + ).unref(); - res.json({ ok: true, message: visual ? 'OpenCode 已启动(可视化模式)' : 'OpenCode 已启动(后台模式)' }); + console.log(`[Admin] OpenCode attach 窗口已拉起(${attachUrl})`); + res.json({ ok: true, message: `OpenCode 前台窗口已打开(${attachUrl})` }); } catch (error: any) { - res.status(500).json({ error: '启动失败:' + error.message }); + res.status(500).json({ error: '打开前台窗口失败:' + error.message }); } }); @@ -708,15 +791,7 @@ objShell.Run "cmd /c opencode serve", 0, False // 异步终止 OpenCode 进程 setTimeout(() => { try { - // 获取脚本路径(兼容开发环境和 Electron 打包环境) - let scriptPath: string; - if ((process as any).resourcesPath && !isDev) { - // Electron 打包后:scripts 在 resources/app/scripts/ - scriptPath = path.join((process as any).resourcesPath, 'app', 'scripts', 'process-manager.mjs'); - } else { - // 开发环境或非 Electron 环境 - scriptPath = path.resolve(__dirname, '../../scripts/process-manager.mjs'); - } + const scriptPath = resolveProcessManagerPath(); console.log('[Admin] 终止脚本路径:', scriptPath); console.log('[Admin] Node 路径:', process.execPath); @@ -983,6 +1058,66 @@ objShell.Run "cmd /c opencode serve", 0, False } }); + // ── GET /api/opencode/model-catalog + api.get('/opencode/model-catalog', async (_req, res) => { + try { + const providersResult = await opencodeClient.getProviders(); + const providers = Array.isArray(providersResult.providers) ? providersResult.providers : []; + + const items = providers + .map(provider => { + const record = provider as Record; + const id = extractProviderId(provider); + if (!id) { + return null; + } + + const name = typeof record.name === 'string' && record.name.trim() + ? record.name.trim() + : id; + + return { + id, + name, + models: extractProviderModels(provider), + }; + }) + .filter((item): item is NonNullable => Boolean(item)) + .sort((left, right) => left.name.localeCompare(right.name, 'zh-Hans-CN')); + + res.json({ providers: items }); + } catch (error: any) { + console.error('[Admin] 获取完整模型目录失败:', error.message); + res.status(502).json({ error: '获取完整模型目录失败:' + error.message }); + } + }); + + // ── GET /api/opencode/enabled-models-sync + api.get('/opencode/enabled-models-sync', async (_req, res) => { + try { + let config: unknown; + try { + config = await opencodeClient.getConfig(); + } catch { + const connected = await opencodeClient.connect(); + if (!connected) { + throw new Error('OpenCode 当前不可连接,无法读取运行时配置'); + } + config = await opencodeClient.getConfig(); + } + + const models = extractEnabledModelsFromOpencodeConfig(config); + res.json({ + source: 'opencode_runtime_config', + models, + count: models.length, + }); + } catch (error: any) { + console.error('[Admin] 同步 OpenCode 已启用模型失败:', error.message); + res.status(502).json({ error: '同步 OpenCode 已启用模型失败:' + error.message }); + } + }); + // ── GET /api/logs(查询日志) api.get('/logs', (req, res) => { const { level, search, start, end, page = '1', limit = '100' } = req.query; @@ -1013,6 +1148,15 @@ objShell.Run "cmd /c opencode serve", 0, False // ── Session 管理路由 api.use('/sessions', createSessionRoutes()); + registerWorkspaceGitRoutes(api); + registerWorkspaceFilesRoutes(api); + registerWorkspaceTerminalRoutes(api); + + // ── Resources 管理路由(Skills, MCP, Agents, Providers) + api.use('/resources', createResourcesRoutes()); + + // ── Resources 终端路由(WebSocket终端用于OAuth登录) + registerResourcesTerminalRoutes(api); // ── POST /api/admin/shutdown(终止服务) api.post('/admin/shutdown', async (_req, res) => { @@ -1030,8 +1174,7 @@ objShell.Run "cmd /c opencode serve", 0, False // 2. 终止 OpenCode 进程 try { const { spawnSync } = await import('node:child_process'); - const processManagerPath = path.resolve(__dirname, '../../scripts/process-manager.mjs'); - spawnSync(process.execPath, [processManagerPath, 'kill-opencode'], { + spawnSync(process.execPath, [resolveProcessManagerPath(), 'kill-opencode'], { stdio: 'inherit', windowsHide: true, }); @@ -1050,23 +1193,28 @@ objShell.Run "cmd /c opencode serve", 0, False }, 500); }); - // ── GET /api/admin/login-timeout(获取登录超时配置) - api.get('/admin/login-timeout', (_req, res) => { - const timeoutMinutes = configStore.getLoginTimeout(); - res.json({ timeoutMinutes }); + // ── GET /api/admin/autostart(查询开机自启状态) + api.get('/admin/autostart', (_req, res) => { + try { + res.json(getAutoStart()); + } catch (e: any) { + res.status(500).json({ error: e?.message || '查询自启状态失败' }); + } }); - // ── PUT /api/admin/login-timeout(设置登录超时配置) - api.put('/admin/login-timeout', (req, res) => { - const { timeoutMinutes } = req.body; - - if (typeof timeoutMinutes !== 'number' || timeoutMinutes < 0) { - res.status(400).json({ error: '超时时间必须为非负整数' }); + // ── PUT /api/admin/autostart(启用/关闭开机自启) + api.put('/admin/autostart', (req, res) => { + const { enabled } = req.body || {}; + if (typeof enabled !== 'boolean') { + res.status(400).json({ error: 'enabled 必须为布尔值' }); return; } - - configStore.setLoginTimeout(timeoutMinutes); - res.json({ ok: true, timeoutMinutes, message: '登录超时设置已保存' }); + try { + setAutoStart(enabled); + res.json({ ok: true, ...getAutoStart() }); + } catch (e: any) { + res.status(500).json({ error: e?.message || '设置自启失败' }); + } }); // ────────────────────────────────────────────── @@ -1365,7 +1513,10 @@ objShell.Run "cmd /c opencode serve", 0, False let server: ReturnType | null = null; return { - start() { + async start() { + // ── 初始化资源系统(skills/mcp/agents/providers 目录与事件总线) + await initResourceSystem(); + server = app.listen(port, '0.0.0.0', () => { const interfaces = os.networkInterfaces(); let lanIp = 'localhost'; @@ -1380,6 +1531,9 @@ objShell.Run "cmd /c opencode serve", 0, False } console.log(`[Admin] 可视化配置面板已启动: http://${lanIp}:${port}`); }); + + // ── 设置 WebSocket 终端服务器(用于 OAuth 登录) + setupResourcesTerminalWebSocket(server); }, stop() { server?.close(); diff --git a/src/admin/autostart.ts b/src/admin/autostart.ts new file mode 100644 index 0000000..f0aab16 --- /dev/null +++ b/src/admin/autostart.ts @@ -0,0 +1,199 @@ +/** + * 跨平台开机自启管理 + * + * - Windows:HKCU\Software\Microsoft\Windows\CurrentVersion\Run\OpenCode Bridge + * - macOS: ~/Library/LaunchAgents/com.opencode.bridge.plist + * - Linux: ~/.config/autostart/opencode-bridge.desktop + * + * 设计原则: + * 1. 只写当前用户作用域,不需要管理员/root; + * 2. 自启目标 = 当前可执行(Electron 打包后是 OpenCode Bridge.exe; + * 源码部署时是 node + dist/index.js,这种场景不在 UI 自启范围内); + * 3. 失败立即抛错给上层 API 转 500,不静默吞错。 + */ +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; + +const APP_NAME = 'OpenCode Bridge'; +const REG_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; +const REG_VALUE = APP_NAME; +const MAC_PLIST_LABEL = 'com.opencode.bridge'; +const LINUX_DESKTOP_FILENAME = 'opencode-bridge.desktop'; + +/** + * 当前进程是否运行在 Electron 打包后的可执行下。 + * 源码 / 后台部署没有可靠的「自启目标」,UI 应明确禁用。 + */ +function detectExecutablePath(): string | null { + const exec = process.execPath; + if (!exec) return null; + const lower = exec.toLowerCase(); + + // 源码 / 全局 node:execPath 通常是 node 或 node.exe 本身,没有意义 + if (lower.endsWith(`${path.sep}node`) || lower.endsWith(`${path.sep}node.exe`)) { + return null; + } + if (lower === 'node' || lower === 'node.exe') return null; + + return exec; +} + +export function isAutoStartSupported(): boolean { + return detectExecutablePath() !== null && ['win32', 'darwin', 'linux'].includes(process.platform); +} + +// ─────────────────────────── Windows ─────────────────────────── +function winQuery(): boolean { + // 用 reg.exe 查询;非零退出码视为不存在 + const r = spawnSync('reg', ['query', REG_KEY, '/v', REG_VALUE], { + windowsHide: true, + encoding: 'utf-8', + }); + return r.status === 0; +} + +function winEnable(execPath: string): void { + // 注意:注册表值需要带引号包裹路径 + const value = `"${execPath}"`; + const r = spawnSync( + 'reg', + ['add', REG_KEY, '/v', REG_VALUE, '/t', 'REG_SZ', '/d', value, '/f'], + { windowsHide: true, encoding: 'utf-8' } + ); + if (r.status !== 0) { + throw new Error(`reg add 失败: ${r.stderr || r.stdout || `exit ${r.status}`}`); + } +} + +function winDisable(): void { + const r = spawnSync('reg', ['delete', REG_KEY, '/v', REG_VALUE, '/f'], { + windowsHide: true, + encoding: 'utf-8', + }); + // value 不存在也不算错 + if (r.status !== 0 && !/find|找不到|cannot find/i.test(r.stderr || '')) { + throw new Error(`reg delete 失败: ${r.stderr || r.stdout || `exit ${r.status}`}`); + } +} + +// ──────────────────────────── macOS ───────────────────────────── +function macPlistPath(): string { + return path.join(os.homedir(), 'Library', 'LaunchAgents', `${MAC_PLIST_LABEL}.plist`); +} + +function macQuery(): boolean { + return fs.existsSync(macPlistPath()); +} + +function macEnable(execPath: string): void { + const plist = ` + + + + Label${MAC_PLIST_LABEL} + ProgramArguments + + ${execPath} + + RunAtLoad + KeepAlive + ProcessTypeInteractive + + +`; + const dir = path.dirname(macPlistPath()); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(macPlistPath(), plist, 'utf-8'); + // 尝试 launchctl load;失败不抛(用户下次登录时仍会生效) + try { + execFileSync('launchctl', ['load', '-w', macPlistPath()], { stdio: 'ignore' }); + } catch { + /* ignore */ + } +} + +function macDisable(): void { + const file = macPlistPath(); + if (fs.existsSync(file)) { + try { + execFileSync('launchctl', ['unload', '-w', file], { stdio: 'ignore' }); + } catch { + /* ignore */ + } + fs.rmSync(file, { force: true }); + } +} + +// ──────────────────────────── Linux ───────────────────────────── +function linuxDesktopPath(): string { + const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + return path.join(xdg, 'autostart', LINUX_DESKTOP_FILENAME); +} + +function linuxQuery(): boolean { + return fs.existsSync(linuxDesktopPath()); +} + +function linuxEnable(execPath: string): void { + const desktop = `[Desktop Entry] +Type=Application +Name=${APP_NAME} +Exec=${execPath} +X-GNOME-Autostart-enabled=true +NoDisplay=false +Terminal=false +`; + const dir = path.dirname(linuxDesktopPath()); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(linuxDesktopPath(), desktop, 'utf-8'); +} + +function linuxDisable(): void { + const file = linuxDesktopPath(); + if (fs.existsSync(file)) { + fs.rmSync(file, { force: true }); + } +} + +// ──────────────────────────── Public ──────────────────────────── +export function getAutoStart(): { enabled: boolean; supported: boolean; platform: NodeJS.Platform } { + const supported = isAutoStartSupported(); + if (!supported) { + return { enabled: false, supported: false, platform: process.platform }; + } + let enabled = false; + switch (process.platform) { + case 'win32': + enabled = winQuery(); + break; + case 'darwin': + enabled = macQuery(); + break; + case 'linux': + enabled = linuxQuery(); + break; + } + return { enabled, supported: true, platform: process.platform }; +} + +export function setAutoStart(enabled: boolean): void { + const execPath = detectExecutablePath(); + if (!execPath) { + throw new Error('当前运行模式(非打包二进制)不支持开机自启,请在 Electron 打包版本中配置。'); + } + if (!['win32', 'darwin', 'linux'].includes(process.platform)) { + throw new Error(`不支持的平台:${process.platform}`); + } + + if (enabled) { + if (process.platform === 'win32') winEnable(execPath); + else if (process.platform === 'darwin') macEnable(execPath); + else linuxEnable(execPath); + } else { + if (process.platform === 'win32') winDisable(); + else if (process.platform === 'darwin') macDisable(); + else linuxDisable(); + } +} diff --git a/src/admin/bridge-manager.ts b/src/admin/bridge-manager.ts index 8beb10d..9f7278f 100644 --- a/src/admin/bridge-manager.ts +++ b/src/admin/bridge-manager.ts @@ -28,6 +28,7 @@ export class BridgeManager { private statusCallbacks: Set = new Set(); private autoRestart: boolean = true; private restarting: boolean = false; + private restartPromise: Promise<{ success: boolean; pid?: number; error?: string }> | null = null; // 内嵌模式状态 private embeddedMode: boolean = true; @@ -238,21 +239,34 @@ export class BridgeManager { * 重启 Bridge */ async restart(): Promise<{ success: boolean; pid?: number; error?: string }> { - console.log('[BridgeManager] 开始重启 Bridge...'); - this.restarting = true; - this.autoRestart = false; // 重启过程中禁用自动重启 + if (this.restartPromise) { + console.log('[BridgeManager] 重启已在进行中,复用当前重启流程'); + return this.restartPromise; + } - await this.stop(); + this.restartPromise = (async () => { + console.log('[BridgeManager] 开始重启 Bridge...'); + this.restarting = true; + this.autoRestart = false; // 重启过程中禁用自动重启 - // 等待 1 秒 - await new Promise(resolve => setTimeout(resolve, 1000)); + try { + const stopResult = await this.stop(); + if (!stopResult.success) { + return { success: false, error: stopResult.error ?? '停止 Bridge 失败' }; + } - const result = await this.start(); + // 等待 1 秒,确保旧实例释放资源 + await new Promise(resolve => setTimeout(resolve, 1000)); - this.restarting = false; - this.autoRestart = true; + return await this.start(); + } finally { + this.restarting = false; + this.autoRestart = true; + this.restartPromise = null; + } + })(); - return result; + return this.restartPromise; } /** @@ -324,4 +338,4 @@ export class BridgeManager { } // 默认使用内嵌模式 -export const bridgeManager = new BridgeManager(true); \ No newline at end of file +export const bridgeManager = new BridgeManager(true); diff --git a/src/admin/chat/event-bus.ts b/src/admin/chat/event-bus.ts new file mode 100644 index 0000000..70a78a2 --- /dev/null +++ b/src/admin/chat/event-bus.ts @@ -0,0 +1,119 @@ +/** + * ChatEventBus — 按 sessionId 分发归一化后的 ChatEvent + * + * 职责: + * - 维护每个 sessionId 的订阅者列表 + * - 维护每个 sessionId 的最近 N 条事件快照,供新客户端 replay + * - 简单单进程内存实现,足够覆盖 Bridge 单机场景 + * + * 见 plan-v2.md 第四节 `chat/event-bus.ts`。 + */ + +import { EventEmitter } from 'node:events'; +import type { AddressedChatEvent, ChatEvent } from './types.js'; + +const DEFAULT_BUFFER_PER_SESSION = 500; + +export type ChatEventSubscriber = (evt: AddressedChatEvent) => void; + +export class ChatEventBus extends EventEmitter { + private readonly subscribers = new Map>(); + private readonly buffers = new Map(); + private readonly bufferSize: number; + private seqCounter = 0; + + constructor(options: { bufferPerSession?: number } = {}) { + super(); + this.setMaxListeners(0); + this.bufferSize = Math.max(1, options.bufferPerSession ?? DEFAULT_BUFFER_PER_SESSION); + } + + /** 发布一个事件到 sessionId 对应的订阅者 */ + publish(sessionId: string, event: ChatEvent): AddressedChatEvent { + const addressed: AddressedChatEvent = { + sessionId, + event, + seq: ++this.seqCounter, + timestamp: Date.now(), + }; + + // 写缓冲 + let buffer = this.buffers.get(sessionId); + if (!buffer) { + buffer = []; + this.buffers.set(sessionId, buffer); + } + buffer.push(addressed); + if (buffer.length > this.bufferSize) { + buffer.splice(0, buffer.length - this.bufferSize); + } + + // 分发 + const subs = this.subscribers.get(sessionId); + if (subs && subs.size > 0) { + for (const sub of subs) { + try { + sub(addressed); + } catch (err) { + console.error('[ChatEventBus] 订阅者回调异常:', err); + } + } + } + + // 全局事件(供诊断 / 日志) + this.emit('publish', addressed); + + return addressed; + } + + /** 订阅指定 sessionId 的事件 */ + subscribe(sessionId: string, subscriber: ChatEventSubscriber): () => void { + let set = this.subscribers.get(sessionId); + if (!set) { + set = new Set(); + this.subscribers.set(sessionId, set); + } + set.add(subscriber); + + return () => { + const current = this.subscribers.get(sessionId); + if (!current) return; + current.delete(subscriber); + if (current.size === 0) { + this.subscribers.delete(sessionId); + } + }; + } + + /** 获取最近的事件快照(默认返回全部缓冲) */ + snapshot(sessionId: string, sinceSeq?: number): AddressedChatEvent[] { + const buffer = this.buffers.get(sessionId); + if (!buffer || buffer.length === 0) return []; + if (typeof sinceSeq === 'number') { + return buffer.filter(e => e.seq > sinceSeq); + } + return buffer.slice(); + } + + /** 清空指定 session 缓冲(会话被删除时调用) */ + clearSession(sessionId: string): void { + this.buffers.delete(sessionId); + this.subscribers.delete(sessionId); + } + + /** 统计信息(调试用) */ + stats(): { sessions: number; totalSubscribers: number; totalBuffered: number } { + let totalSubs = 0; + for (const set of this.subscribers.values()) totalSubs += set.size; + let totalBuf = 0; + for (const buf of this.buffers.values()) totalBuf += buf.length; + return { + sessions: this.subscribers.size, + totalSubscribers: totalSubs, + totalBuffered: totalBuf, + }; + } +} + +/** 单例 bus */ +export const chatEventBus = new ChatEventBus(); diff --git a/src/admin/chat/event-normalizer.ts b/src/admin/chat/event-normalizer.ts new file mode 100644 index 0000000..7242f80 --- /dev/null +++ b/src/admin/chat/event-normalizer.ts @@ -0,0 +1,491 @@ +/** + * event-normalizer.ts — 把 opencodeClient 的事件映射成干净的 ChatEvent 流 + * + * 输入:opencodeClient EventEmitter 发出的事件 + * - permissionRequest + * - messageUpdated (Message info) + * - messagePartUpdated (Part, optional delta) + * - sessionIdle / sessionError / sessionStatus + * - questionAsked + * + * 输出:按 sessionId 发布到 ChatEventBus 的 ChatEvent + * + * 见 plan-v2.md 第三节事件协议、第四节架构说明。 + */ + +import type { Message, Part } from '@opencode-ai/sdk'; +import { opencodeClient } from '../../opencode/client.js'; +import { chatEventBus, type ChatEventBus } from './event-bus.js'; +import type { ChatEvent, ChatTodoItem, ChatTokenUsage } from './types.js'; + +// ── OpenCode 内部事件 payload 形状(与 client.ts handleEvent 对齐) + +interface MessagePartUpdatedPayload { + part: Part; + delta?: string; +} + +interface PermissionRequestPayload { + sessionId: string; + permissionId: string; + tool: string; + description: string; + risk?: string; + messageId?: string; + callId?: string; +} + +interface SessionIdlePayload { + sessionID?: string; +} + +interface SessionErrorPayload { + sessionID?: string; + error?: { name?: string; data?: { message?: string } }; +} + +interface SessionStatusPayload { + sessionID?: string; + status?: { type?: string }; +} + +// ── 辅助工具 + +function clampString(v: unknown): string { + return typeof v === 'string' ? v : ''; +} + +function toTokenUsage(info: Message): ChatTokenUsage | undefined { + if (info.role !== 'assistant') return undefined; + const t = info.tokens; + if (!t) return undefined; + return { + input: t.input ?? 0, + output: t.output ?? 0, + reasoning: t.reasoning ?? 0, + cacheRead: t.cache?.read ?? 0, + cacheWrite: t.cache?.write ?? 0, + cost: info.cost, + }; +} + +function toMessageMeta(info: Message): Extract['msg'] { + return { + id: info.id, + role: info.role, + createdAt: (info.time?.created ?? Math.floor(Date.now() / 1000)) * 1000, + ...(info.role === 'assistant' && info.parentID ? { parentId: info.parentID } : {}), + ...(info.role === 'user' && info.model?.providerID && info.model?.modelID + ? { + model: { + providerId: info.model.providerID, + modelId: info.model.modelID, + }, + } + : {}), + ...(info.role === 'assistant' && info.providerID && info.modelID + ? { + model: { + providerId: info.providerID, + modelId: info.modelID, + }, + } + : {}), + ...(info.role === 'assistant' && info.mode ? { agent: info.mode } : {}), + ...(info.role === 'user' && info.agent ? { agent: info.agent } : {}), + }; +} + +function toTodos(raw: unknown): ChatTodoItem[] | undefined { + if (!Array.isArray(raw)) return undefined; + const out: ChatTodoItem[] = []; + for (const item of raw) { + if (!item || typeof item !== 'object') continue; + const r = item as Record; + const id = clampString(r.id); + const content = clampString(r.content ?? r.title ?? r.text); + const status = clampString(r.status ?? 'pending'); + if (!id || !content) continue; + out.push({ + id, + content, + status, + priority: typeof r.priority === 'string' ? r.priority : undefined, + }); + } + return out.length > 0 ? out : undefined; +} + +function diffText(prev: string, next: string): string { + if (!prev) return next; + if (next.startsWith(prev)) return next.slice(prev.length); + // 内容被重写,回退为全量 + return next; +} + +// ── 单个 session 的归一化状态 + +interface MessageState { + msgId: string; + role?: 'user' | 'assistant'; + started: boolean; + finishedEmitted: boolean; + textByPartId: Map; + reasoningByPartId: Map; + toolStarted: Set; // callID 集合 + pendingParts: Map; +} + +interface SessionState { + messages: Map; + lastActivity: number; +} + +// ── 归一化器 + +/** 会话状态过期时间(5 分钟无新事件则清理) */ +const SESSION_STATE_TTL_MS = 5 * 60 * 1000; +/** 清理检查间隔(2 分钟) */ +const CLEANUP_INTERVAL_MS = 2 * 60 * 1000; + +export class ChatEventNormalizer { + private readonly bus: ChatEventBus; + private readonly sessionStates = new Map(); + private installed = false; + private readonly handlers: Array<{ event: string; fn: (payload: any) => void }> = []; + private cleanupTimer: ReturnType | null = null; + + constructor(bus: ChatEventBus = chatEventBus) { + this.bus = bus; + } + + /** 挂接到 opencodeClient 事件源 */ + install(): void { + if (this.installed) return; + this.installed = true; + + const bind = (event: string, fn: (payload: T) => void): void => { + const wrapped = (payload: T) => { + try { + fn(payload); + } catch (err) { + console.error(`[ChatEventNormalizer] 处理 ${event} 异常:`, err); + } + }; + opencodeClient.on(event, wrapped); + this.handlers.push({ event, fn: wrapped }); + }; + + bind<{ info: Message }>('messageUpdated', p => this.onMessageUpdated(p?.info)); + bind('messagePartUpdated', p => this.onPartUpdated(p)); + bind('sessionIdle', p => this.onSessionIdle(p)); + bind('sessionError', p => this.onSessionError(p)); + bind('sessionStatus', p => this.onSessionStatus(p)); + bind('permissionRequest', p => this.onPermissionRequest(p)); + + // Periodic cleanup of stale session states + this.cleanupTimer = setInterval(() => this.cleanupStaleSessions(), CLEANUP_INTERVAL_MS); + } + + /** 从事件源解绑(测试或热重载时用) */ + uninstall(): void { + if (!this.installed) return; + for (const h of this.handlers) { + opencodeClient.off(h.event, h.fn as (...args: unknown[]) => void); + } + this.handlers.length = 0; + this.installed = false; + this.sessionStates.clear(); + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + } + + // ── 内部:状态管理 + + private getSessionState(sessionId: string): SessionState { + let s = this.sessionStates.get(sessionId); + if (!s) { + s = { messages: new Map(), lastActivity: Date.now() }; + this.sessionStates.set(sessionId, s); + } + s.lastActivity = Date.now(); + return s; + } + + private getMessageState(sessionId: string, msgId: string): MessageState { + const s = this.getSessionState(sessionId); + let m = s.messages.get(msgId); + if (!m) { + m = { + msgId, + role: undefined, + started: false, + finishedEmitted: false, + textByPartId: new Map(), + reasoningByPartId: new Map(), + toolStarted: new Set(), + pendingParts: new Map(), + }; + s.messages.set(msgId, m); + } + return m; + } + + private publish(sessionId: string, evt: ChatEvent): void { + this.bus.publish(sessionId, evt); + } + + // ── 事件处理 + + private onMessageUpdated(info: Message | undefined): void { + if (!info) return; + const sessionId = info.sessionID; + const msgId = info.id; + if (!sessionId || !msgId) return; + + const state = this.getMessageState(sessionId, msgId); + state.role = info.role; + + // 首次见到消息 → message_start + if (!state.started) { + state.started = true; + this.publish(sessionId, { + type: 'message_start', + msg: toMessageMeta(info), + }); + } + + if (info.role === 'assistant' && state.pendingParts.size > 0) { + const pendingParts = Array.from(state.pendingParts.values()); + state.pendingParts.clear(); + for (const pending of pendingParts) { + this.processAssistantPartUpdate(sessionId, state, pending); + } + } else if (info.role === 'user' && state.pendingParts.size > 0) { + state.pendingParts.clear(); + } + + // 完成 → message_end + if (info.role === 'assistant' && !state.finishedEmitted && info.time?.completed) { + state.finishedEmitted = true; + const errMsg = info.error?.data && typeof (info.error.data as { message?: string }).message === 'string' + ? (info.error.data as { message?: string }).message + : undefined; + this.publish(sessionId, { + type: 'message_end', + msgId, + usage: toTokenUsage(info), + finish: info.finish, + error: errMsg, + }); + } + } + + private onPartUpdated(payload: MessagePartUpdatedPayload | undefined): void { + if (!payload?.part) return; + const part = payload.part; + const sessionId = part.sessionID; + const msgId = part.messageID; + if (!sessionId || !msgId) return; + + const state = this.getMessageState(sessionId, msgId); + if (state.role === 'user') { + return; + } + + if (state.role !== 'assistant') { + state.pendingParts.set(part.id, payload); + return; + } + + this.processAssistantPartUpdate(sessionId, state, payload); + } + + private processAssistantPartUpdate( + sessionId: string, + state: MessageState, + payload: MessagePartUpdatedPayload + ): void { + const part = payload.part; + const msgId = part.messageID; + if (!msgId) return; + switch (part.type) { + case 'text': { + // 优先用 OpenCode 提供的 delta;缺失时自行 diff + const prev = state.textByPartId.get(part.id) ?? ''; + const deltaText = typeof payload.delta === 'string' && payload.delta.length > 0 + ? payload.delta + : diffText(prev, part.text ?? ''); + state.textByPartId.set(part.id, part.text ?? ''); + if (deltaText.length > 0) { + this.publish(sessionId, { type: 'text_delta', msgId, text: deltaText }); + } + break; + } + + case 'reasoning': { + const prev = state.reasoningByPartId.get(part.id) ?? ''; + const deltaText = typeof payload.delta === 'string' && payload.delta.length > 0 + ? payload.delta + : diffText(prev, part.text ?? ''); + state.reasoningByPartId.set(part.id, part.text ?? ''); + if (deltaText.length > 0) { + this.publish(sessionId, { type: 'reasoning_delta', msgId, text: deltaText }); + } + break; + } + + case 'tool': { + const callId = part.callID; + const toolName = part.tool; + const st = part.state; + + // 首次 → tool_start + if (!state.toolStarted.has(callId)) { + state.toolStarted.add(callId); + const input = st && (st.status === 'pending' || st.status === 'running' || st.status === 'completed' || st.status === 'error') + ? st.input + : undefined; + const title = st && st.status === 'running' ? st.title : undefined; + this.publish(sessionId, { + type: 'tool_start', + msgId, + tool: { id: part.id, callId, name: toolName, input, title }, + }); + } + + // TodoWrite 的 todos 在 running 阶段就已经写在 state.input.todos 里了。 + // 只在 completed 时再 emit task_update 会导致看板"等执行完才出现", + // 体感上像没显示任务。这里只要任意状态拿得到 todos 数组就立刻广播, + // 同状态下重复的快照在前端会被覆盖式替换,无副作用。 + if (isTodoWriteTool(toolName) && st && st.status !== 'pending') { + const stateRecord = st as unknown as Record; + const liveInput = stateRecord.input; + const liveMetadata = stateRecord.metadata && typeof stateRecord.metadata === 'object' + ? stateRecord.metadata as Record + : undefined; + const todos = extractTodosFromTodoWrite(liveInput, liveMetadata); + if (todos) this.publish(sessionId, { type: 'task_update', todos }); + } + + // 完成 / 错误 → tool_end + if (st?.status === 'completed') { + const title = st.title; + const output = typeof st.output === 'string' ? st.output : ''; + this.publish(sessionId, { + type: 'tool_end', + msgId, + toolId: part.id, + callId, + name: toolName, + result: output, + isError: false, + title, + durationMs: st.time?.end && st.time?.start ? st.time.end - st.time.start : undefined, + }); + } else if (st?.status === 'error') { + this.publish(sessionId, { + type: 'tool_end', + msgId, + toolId: part.id, + callId, + name: toolName, + result: st.error ?? 'tool error', + isError: true, + durationMs: st.time?.end && st.time?.start ? st.time.end - st.time.start : undefined, + }); + } + break; + } + + case 'step-finish': { + // step-finish 不直接映射,message 完成会有独立 message_end + break; + } + + default: + // 其它 Part 类型(file / step-start / snapshot / patch / agent / retry / compaction) + // 目前前端渲染器不需要,忽略。后续需要时再扩展。 + break; + } + } + + private onSessionIdle(payload: SessionIdlePayload | undefined): void { + const sessionId = payload?.sessionID; + if (!sessionId) return; + this.publish(sessionId, { type: 'session_idle', sessionId }); + // Session is idle — clear its message state (will be rebuilt if it resumes) + this.sessionStates.delete(sessionId); + } + + /** Remove session states that haven't received events for SESSION_STATE_TTL_MS */ + private cleanupStaleSessions(): void { + const now = Date.now(); + let cleaned = 0; + for (const [sessionId, state] of this.sessionStates) { + if (now - state.lastActivity > SESSION_STATE_TTL_MS) { + this.sessionStates.delete(sessionId); + cleaned++; + } + } + if (cleaned > 0) { + console.log(`[ChatEventNormalizer] 清理了 ${cleaned} 个过期会话状态,剩余 ${this.sessionStates.size}`); + } + } + + private onSessionError(payload: SessionErrorPayload | undefined): void { + const sessionId = payload?.sessionID; + if (!sessionId) return; + const msg = payload?.error?.data?.message || payload?.error?.name || '会话错误'; + this.publish(sessionId, { type: 'error', message: msg }); + } + + private onSessionStatus(payload: SessionStatusPayload | undefined): void { + const sessionId = payload?.sessionID; + if (!sessionId) return; + const status = payload?.status?.type; + if (!status) return; + this.publish(sessionId, { type: 'session_status', sessionId, status }); + } + + private onPermissionRequest(payload: PermissionRequestPayload | undefined): void { + if (!payload?.sessionId || !payload.permissionId) return; + this.publish(payload.sessionId, { + type: 'permission_ask', + req: { + id: payload.permissionId, + sessionId: payload.sessionId, + tool: payload.tool || 'unknown', + description: payload.description || '', + risk: payload.risk, + messageId: payload.messageId, + callId: payload.callId, + }, + }); + } +} + +// ── TodoWrite 解析 + +function isTodoWriteTool(name: string): boolean { + const n = name.toLowerCase(); + return n === 'todowrite' || n === 'todo_write' || n === 'todo-write'; +} + +function extractTodosFromTodoWrite( + input: unknown, + metadata: Record | undefined +): ChatTodoItem[] | undefined { + const fromMeta = metadata && toTodos((metadata as Record).todos); + if (fromMeta) return fromMeta; + if (input && typeof input === 'object') { + const rec = input as Record; + return toTodos(rec.todos); + } + return undefined; +} + +/** 单例(在 admin-server 启动时 install) */ +export const chatEventNormalizer = new ChatEventNormalizer(); diff --git a/src/admin/chat/types.ts b/src/admin/chat/types.ts new file mode 100644 index 0000000..05c8fdf --- /dev/null +++ b/src/admin/chat/types.ts @@ -0,0 +1,86 @@ +/** + * ChatEvent 协议(前后端契约) + * + * 后端把 OpenCode 的复杂 Part / Event 协议归一化成简单线性事件流, + * 前端只消费这一种协议,不用管 OpenCode 的内部结构。 + * + * 见 plan-v2.md 第三节。 + */ + +export interface ChatTokenUsage { + input: number; + output: number; + reasoning: number; + cacheRead: number; + cacheWrite: number; + cost?: number; +} + +export interface ChatTodoItem { + id: string; + content: string; + status: string; + priority?: string; +} + +export interface ChatMessageMeta { + id: string; + role: 'user' | 'assistant'; + createdAt: number; + parentId?: string; + model?: { providerId: string; modelId: string }; + agent?: string; +} + +export interface ChatPermissionRequest { + id: string; + sessionId: string; + tool: string; + description: string; + risk?: string; + messageId?: string; + callId?: string; + metadata?: Record; +} + +export type ChatEvent = + | { type: 'message_start'; msg: ChatMessageMeta } + | { type: 'text_delta'; msgId: string; text: string } + | { type: 'reasoning_delta'; msgId: string; text: string } + | { + type: 'tool_start'; + msgId: string; + tool: { id: string; callId: string; name: string; input: unknown; title?: string }; + } + | { type: 'tool_delta'; msgId: string; toolId: string; output: string } + | { + type: 'tool_end'; + msgId: string; + toolId: string; + callId: string; + name: string; + result: string; + isError: boolean; + title?: string; + durationMs?: number; + } + | { type: 'message_end'; msgId: string; usage?: ChatTokenUsage; finish?: string; error?: string } + | { type: 'permission_ask'; req: ChatPermissionRequest } + | { type: 'permission_resolved'; reqId: string; decision: 'allow' | 'reject' | 'always' } + | { type: 'task_update'; todos: ChatTodoItem[] } + | { type: 'session_idle'; sessionId: string } + | { type: 'session_status'; sessionId: string; status: string } + | { type: 'error'; message: string } + | { type: 'keepalive' }; + +export type ChatEventType = ChatEvent['type']; + +/** 带 sessionId 定位的事件(bus 内部使用) */ +export type AddressedChatEvent = { + sessionId: string; + event: ChatEvent; + /** 单调递增序号,用于客户端断线重连时做 replay */ + seq: number; + /** 事件归一化时间戳(毫秒) */ + timestamp: number; +}; diff --git a/src/admin/index.ts b/src/admin/index.ts index f6bcd39..676a9a9 100644 --- a/src/admin/index.ts +++ b/src/admin/index.ts @@ -15,19 +15,17 @@ import '../config.js'; // 设置内嵌模式环境变量,防止 Bridge 自动启动 process.env.BRIDGE_EMBEDDED_MODE = '1'; -import pkg from '../../package.json' with { type: 'json' }; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { createAdminServer } from './admin-server.js'; import { bridgeManager, type BridgeStatus } from './bridge-manager.js'; -import { configStore } from '../store/config-store.js'; import { initLogger } from '../utils/logger.js'; import { logStore } from '../store/log-store.js'; +import { VERSION } from '../utils/version.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ADMIN_PORT = parseInt(process.env.ADMIN_PORT ?? '4098', 10); -const VERSION = pkg.version; // 开发模式检测(process.resourcesPath 是 Electron 特有属性) const isDev = process.env.NODE_ENV === 'development' || !(process as any).resourcesPath; @@ -79,10 +77,9 @@ async function main() { console.log('║ OpenCode Bridge Admin v' + VERSION + ' ║'); console.log('╚════════════════════════════════════════════════╝'); - // 启动 Admin Server + // 启动 Admin Server(已移除账号 / 密码鉴权) const adminServer = createAdminServer({ port: ADMIN_PORT, - password: process.env.ADMIN_PASSWORD ?? '', startedAt: new Date(), version: VERSION, bridgeManager, @@ -127,5 +124,12 @@ async function main() { main().catch((err) => { console.error('[Admin] 启动失败:', err); + if (err?.code === 'MODULE_NOT_FOUND' || String(err?.message || '').includes('better-sqlite3')) { + console.error('[Admin] 原生模块加载失败,诊断信息:'); + console.error(`[Admin] platform=${process.platform} arch=${process.arch} node=${process.versions.node}`); + if (process.platform === 'darwin') { + console.error('[Admin] macOS: 请确认安装包与 CPU 架构匹配,或执行 xattr -cr 移除安全隔离'); + } + } process.exit(1); }); \ No newline at end of file diff --git a/src/admin/routes/admin.ts b/src/admin/routes/admin.ts index 7487f29..aaac4db 100644 --- a/src/admin/routes/admin.ts +++ b/src/admin/routes/admin.ts @@ -34,63 +34,9 @@ export function createAdminRoutes(options: AdminRoutesOptions): express.Router { startedAt: startedAt.toISOString(), dbPath: configStore.getDbPath(), cronJobCount, - needsPasswordChange: configStore.needsPasswordChange(), }); }); - // ── GET /api/admin/password-status - router.get('/password-status', (_req, res) => { - res.json({ - needsPasswordChange: configStore.needsPasswordChange(), - hasPassword: !!configStore.getAdminPassword(), - }); - }); - - // ── POST /api/admin/reset-password(重置密码,用于密码恢复) - router.post('/reset-password', (_req, res) => { - // 清除密码和密码修改时间 - configStore.setAdminPassword(''); - configStore.setPasswordChangedAt(''); - - res.json({ - ok: true, - message: '密码已重置,请重新设置密码', - }); - }); - - // ── PUT /api/admin/password(修改密码或首次设置密码) - router.put('/password', (req, res) => { - const { oldPassword, newPassword } = req.body; - - if (!newPassword || newPassword.length < 8) { - res.status(400).json({ error: '新密码长度至少 8 位' }); - return; - } - - const currentPassword = configStore.getAdminPassword(); - const isFirstSetup = !currentPassword || currentPassword === ''; - - // 首次设置密码:无需验证旧密码 - if (isFirstSetup) { - configStore.setAdminPassword(newPassword); - configStore.setPasswordChangedAt(new Date().toISOString()); - res.json({ ok: true, message: '密码设置成功', isFirstSetup: true }); - return; - } - - // 修改密码:需要验证旧密码 - if (oldPassword !== currentPassword) { - res.status(401).json({ error: '原密码错误' }); - return; - } - - // 更新密码 - configStore.setAdminPassword(newPassword); - configStore.setPasswordChangedAt(new Date().toISOString()); - - res.json({ ok: true, message: '密码修改成功,请使用新密码重新登录' }); - }); - // ── POST /api/admin/restart router.post('/restart', async (_req, res) => { if (!bridgeManager) { @@ -257,23 +203,16 @@ export function createAdminRoutes(options: AdminRoutesOptions): express.Router { res.json({ ok: true, results }); }); - // ── GET /api/admin/login-timeout(获取登录超时配置) - router.get('/login-timeout', (_req, res) => { - const timeoutMinutes = configStore.getLoginTimeout(); - res.json({ timeoutMinutes }); + // ── GET /api/admin/onboarding-status + router.get('/onboarding-status', (_req, res) => { + res.json({ completed: configStore.isOnboardingCompleted() }); }); - // ── PUT /api/admin/login-timeout(设置登录超时配置) - router.put('/login-timeout', (req, res) => { - const { timeoutMinutes } = req.body; - - if (typeof timeoutMinutes !== 'number' || timeoutMinutes < 0) { - res.status(400).json({ error: '超时时间必须为非负整数' }); - return; - } - - configStore.setLoginTimeout(timeoutMinutes); - res.json({ ok: true, timeoutMinutes, message: '登录超时设置已保存' }); + // ── PUT /api/admin/onboarding-status + router.put('/onboarding-status', (req, res) => { + const completed = Boolean(req.body?.completed); + configStore.setOnboardingCompleted(completed); + res.json({ ok: true, completed }); }); // ── GET /api/admin/check-update diff --git a/src/admin/routes/chat-abort.ts b/src/admin/routes/chat-abort.ts new file mode 100644 index 0000000..c76bf75 --- /dev/null +++ b/src/admin/routes/chat-abort.ts @@ -0,0 +1,35 @@ +import express, { type Request, type Response, type Application } from 'express'; +import { opencodeClient } from '../../opencode/client.js'; +import { chatAuthMiddleware } from './chat-auth.js'; + +function errorMsg(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error'; +} + +export function registerChatAbortRoutes(app: Application): void { + const router = express.Router(); + router.use(chatAuthMiddleware); + + router.post('/sessions/:id/abort', async (req: Request, res: Response) => { + try { + const sessionId = typeof req.params.id === 'string' ? req.params.id.trim() : ''; + if (!sessionId) { + res.status(400).json({ error: '缺少 sessionId' }); + return; + } + + const ok = await opencodeClient.abortSession(sessionId); + if (!ok) { + res.status(502).json({ error: '中断会话失败' }); + return; + } + + res.json({ ok: true }); + } catch (error) { + console.error('[Chat API] 中断会话失败:', error); + res.status(502).json({ error: errorMsg(error) }); + } + }); + + app.use('/api/chat', router); +} diff --git a/src/admin/routes/chat-auth.ts b/src/admin/routes/chat-auth.ts new file mode 100644 index 0000000..f95a612 --- /dev/null +++ b/src/admin/routes/chat-auth.ts @@ -0,0 +1,16 @@ +/** + * Chat Auth 中间件 + * + * 历史上:根据管理员密码(Bearer / ?token=)校验请求。 + * 现状:管理后台已彻底移除账号 / 密码鉴权,所有请求直接放行; + * 本文件仅保留导出符号以避免破坏外部 import 路径。 + */ +import type { Request, Response, NextFunction } from 'express'; + +export function isChatAuthorized(_req: Request): boolean { + return true; +} + +export function chatAuthMiddleware(_req: Request, _res: Response, next: NextFunction): void { + next(); +} diff --git a/src/admin/routes/chat-events.ts b/src/admin/routes/chat-events.ts new file mode 100644 index 0000000..4b45af8 --- /dev/null +++ b/src/admin/routes/chat-events.ts @@ -0,0 +1,96 @@ +import { Request, Response } from 'express'; +import { chatEventBus } from '../chat/event-bus.js'; +import type { AddressedChatEvent } from '../chat/types.js'; +import { chatAuthMiddleware } from './chat-auth.js'; + +interface SSEQuery { + session_id?: string; + since?: string; +} + +function writeEvent(res: Response, addressedEvent: AddressedChatEvent): void { + const data = JSON.stringify(addressedEvent.event); + res.write(`id: ${addressedEvent.seq}\nevent: ${addressedEvent.event.type}\ndata: ${data}\n\n`); +} + +function parseSinceSeq(req: Request): number | undefined { + const query = req.query as SSEQuery; + const candidates = [ + typeof query.since === 'string' ? query.since : undefined, + typeof req.headers['last-event-id'] === 'string' ? req.headers['last-event-id'] : undefined, + ]; + + for (const value of candidates) { + if (!value) continue; + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + + return undefined; +} + +export async function sseHandler(req: Request, res: Response): Promise { + const query = req.query as SSEQuery; + const sessionId = typeof query.session_id === 'string' ? query.session_id : undefined; + const sinceSeq = parseSinceSeq(req); + const clientId = Math.random().toString(36).substring(7); + + // Windows 上 Nagle + receive-buffer 聚合会让小 SSE 包延迟数秒后才送达浏览器, + // 表现为「OpenCode 已经回包,但页面要刷新才看见」。这里强制 NoDelay 并启用 + // TCP keepalive,避免长连接中途被中间层静默关闭却没触发 close 事件。 + req.socket.setNoDelay(true); + req.socket.setKeepAlive(true, 15000); + + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders?.(); + + res.write(`id: 0\nevent: connected\ndata: {"clientId":"${clientId}"}\n\n`); + + const eventHandler = (addressedEvent: AddressedChatEvent) => { + if (sessionId && addressedEvent.sessionId !== sessionId) { + return; + } + + try { + writeEvent(res, addressedEvent); + } catch (err) { + console.error('[Chat Events] Failed to write SSE event:', err, clientId); + } + }; + + if (sessionId) { + for (const snapshotEvent of chatEventBus.snapshot(sessionId, sinceSeq)) { + writeEvent(res, snapshotEvent); + } + } + + const unsubscribe = sessionId + ? chatEventBus.subscribe(sessionId, eventHandler) + : (() => { + chatEventBus.on('publish', eventHandler); + return () => chatEventBus.off('publish', eventHandler); + })(); + + // 5s 心跳:足够穿透 Windows TCP keepalive/中间反代的空闲超时,又不会显著增加流量。 + const keepalive = setInterval(() => { + res.write('event: keepalive\ndata: {"type":"keepalive"}\n\n'); + }, 5000); + + console.log('[Chat Events] SSE client connected:', clientId, sessionId); + + req.on('close', () => { + clearInterval(keepalive); + unsubscribe(); + console.log('[Chat Events] SSE client disconnected:', clientId); + }); +} + +export function registerChatEventsRoutes(app: any): void { + app.get('/api/chat/events', chatAuthMiddleware, sseHandler); + console.log('[Chat Routes] Chat events route registered: GET /api/chat/events'); +} diff --git a/src/admin/routes/chat-meta.ts b/src/admin/routes/chat-meta.ts new file mode 100644 index 0000000..0efad03 --- /dev/null +++ b/src/admin/routes/chat-meta.ts @@ -0,0 +1,490 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import express, { type Application, type Request, type Response } from 'express'; +import { opencodeClient } from '../../opencode/client.js'; +import { configStore } from '../../store/config-store.js'; +import { chatAuthMiddleware } from './chat-auth.js'; +import { KNOWN_EFFORT_LEVELS, normalizeEffortLevel } from '../../commands/effort.js'; +import { skillRegistry } from '../../services/resources/skills/registry.js'; +import { getMCPRegistry } from '../../services/resources/mcp/manager.js'; +import { listSlashCommands as listMCPSlashCommands, toCommandItems as toMCPCommandItems } from '../../services/resources/mcp/slash.js'; +import { listSlashCommands as listAgentSlashCommands, toCommandItems as toAgentCommandItems } from '../../services/resources/agents/slash.js'; +import { modelConfig } from '../../config.js'; + +function errorMsg(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error'; +} + +function splitDirectories(raw: unknown): string[] { + if (typeof raw !== 'string' || !raw.trim()) { + return []; + } + + return raw + .split(/[\r\n,;]+/) + .map(item => item.trim()) + .filter(Boolean); +} + +function buildWorkspaceLabel(directory: string, preferredLabel?: string): string { + const trimmedLabel = typeof preferredLabel === 'string' ? preferredLabel.trim() : ''; + if (trimmedLabel) { + return `${trimmedLabel} · ${directory}`; + } + + const baseName = path.basename(directory) || directory; + return `${baseName} · ${directory}`; +} + +function extractProviderId(provider: unknown): string | undefined { + if (!provider || typeof provider !== 'object') { + return undefined; + } + + const record = provider as Record; + const rawId = typeof record.id === 'string' ? record.id.trim() : ''; + return rawId || undefined; +} + +function extractEffortVariants(modelRecord: Record): string[] { + const rawVariants = modelRecord.variants; + if (!rawVariants) { + return []; + } + + const values = Array.isArray(rawVariants) + ? rawVariants + : Object.keys(rawVariants as Record); + + const variants: string[] = []; + const dedupe = new Set(); + for (const rawValue of values) { + if (typeof rawValue !== 'string') { + continue; + } + + const value = rawValue.trim(); + if (!value) { + continue; + } + + const key = value.toLowerCase(); + if (dedupe.has(key)) { + continue; + } + + dedupe.add(key); + variants.push(value); + } + + const order = new Map(); + KNOWN_EFFORT_LEVELS.forEach((value, index) => { + order.set(value, index); + }); + + return variants.sort((left, right) => { + const leftNormalized = normalizeEffortLevel(left); + const rightNormalized = normalizeEffortLevel(right); + const leftOrder = leftNormalized ? (order.get(leftNormalized) ?? Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; + const rightOrder = rightNormalized ? (order.get(rightNormalized) ?? Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + return left.localeCompare(right); + }); +} + +function extractProviderModels(provider: unknown): Array<{ + id: string; + name: string; + variants: string[]; +}> { + if (!provider || typeof provider !== 'object') { + return []; + } + + const providerId = extractProviderId(provider); + if (!providerId) { + return []; + } + + const record = provider as Record; + const rawModels = record.models; + const models: Array<{ id: string; name: string; variants: string[] }> = []; + const dedupe = new Set(); + + const pushModel = (rawModel: unknown, fallbackId?: string): void => { + const fallbackNormalized = typeof fallbackId === 'string' ? fallbackId.trim() : ''; + if (!rawModel || typeof rawModel !== 'object') { + if (!fallbackNormalized) { + return; + } + + const dedupeKey = `${providerId.toLowerCase()}:${fallbackNormalized.toLowerCase()}`; + if (dedupe.has(dedupeKey)) { + return; + } + + dedupe.add(dedupeKey); + models.push({ + id: fallbackNormalized, + name: fallbackNormalized, + variants: [], + }); + return; + } + + const modelRecord = rawModel as Record; + const modelId = typeof modelRecord.id === 'string' && modelRecord.id.trim() + ? modelRecord.id.trim() + : fallbackNormalized; + if (!modelId) { + return; + } + + const modelName = typeof modelRecord.name === 'string' && modelRecord.name.trim() + ? modelRecord.name.trim() + : modelId; + const dedupeKey = `${providerId.toLowerCase()}:${modelId.toLowerCase()}`; + if (dedupe.has(dedupeKey)) { + return; + } + + dedupe.add(dedupeKey); + models.push({ + id: modelId, + name: modelName, + variants: extractEffortVariants(modelRecord), + }); + }; + + if (Array.isArray(rawModels)) { + for (const rawModel of rawModels) { + pushModel(rawModel); + } + } else if (rawModels && typeof rawModels === 'object') { + for (const [modelKey, rawModel] of Object.entries(rawModels as Record)) { + pushModel(rawModel, modelKey); + } + } + + return models.sort((left, right) => left.name.localeCompare(right.name, 'zh-Hans-CN')); +} + +type CommandItem = { + name: string; + description?: string; + source?: 'command' | 'mcp' | 'skill' | 'agent' | 'bridge-doc'; + template: string; + hints: string[]; + group?: 'command' | 'mcp' | 'skill' | 'agent' | 'other'; +}; + +function toTemplate(commandCell: string): string { + const first = commandCell + .split(/
|\/|、|,|\s+/) + .map(item => item.trim()) + .find(item => item.startsWith('/')); + + return first || commandCell.trim(); +} + +async function readBridgeCommandFallback(): Promise { + const commandDocPath = path.resolve('assets', 'docs', 'commands.md'); + const content = await fs.readFile(commandDocPath, 'utf8'); + const commands = new Map(); + + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line.startsWith('|')) continue; + if (line.includes('| 命令 |') || line.includes('|------|')) continue; + + const cells = line + .split('|') + .slice(1, -1) + .map(item => item.trim()); + + if (cells.length < 2) continue; + + const commandCell = cells[0]; + const description = cells[1] || undefined; + const matches = commandCell.match(/\/{1,2}[A-Za-z0-9:_-]+/g) || []; + for (const match of matches) { + const normalized = match.replace(/^\/+/, ''); + if (!normalized || commands.has(normalized)) { + continue; + } + + commands.set(normalized, { + name: normalized, + description, + source: 'bridge-doc', + template: toTemplate(match), + hints: [], + }); + } + } + + return Array.from(commands.values()).sort((left, right) => left.name.localeCompare(right.name, 'zh-Hans-CN')); +} + +function mergeCommands(primary: CommandItem[], fallback: CommandItem[]): CommandItem[] { + const merged = new Map(); + + const upsert = (item: CommandItem): void => { + const key = item.name.trim().toLowerCase(); + if (!key) return; + + const existing = merged.get(key); + if (!existing) { + merged.set(key, item); + return; + } + + merged.set(key, { + ...existing, + ...item, + description: existing.description || item.description, + template: existing.template || item.template, + hints: existing.hints.length > 0 ? existing.hints : item.hints, + source: existing.source || item.source, + }); + }; + + for (const item of primary) upsert(item); + for (const item of fallback) upsert(item); + + return Array.from(merged.values()).sort((left, right) => left.name.localeCompare(right.name, 'zh-Hans-CN')); +} + +/** + * 根据source确定命令分组 + */ +function getCommandGroup(source?: string): 'command' | 'mcp' | 'skill' | 'agent' | 'other' { + switch (source) { + case 'command': + case 'bridge-doc': + return 'command'; + case 'mcp': + return 'mcp'; + case 'skill': + return 'skill'; + case 'agent': + return 'agent'; + default: + return 'other'; + } +} + +/** + * 将命令按分组分类 + */ +function groupCommands(commands: CommandItem[]): Map { + const groups = new Map(); + const groupOrder: Record = { + command: 1, + mcp: 2, + skill: 3, + agent: 4, + other: 5, + }; + + for (const cmd of commands) { + const group = getCommandGroup(cmd.source); + if (!groups.has(group)) { + groups.set(group, []); + } + groups.get(group)!.push({ ...cmd, group }); + } + + // 按分组顺序排序 + const sortedGroups = new Map(); + const sortedKeys = Array.from(groups.keys()).sort( + (a, b) => (groupOrder[a] || 99) - (groupOrder[b] || 99) + ); + + for (const key of sortedKeys) { + const items = groups.get(key)!; + // 每组内按名称排序 + sortedGroups.set(key, items.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'))); + } + + return sortedGroups; +} + +export function registerChatMetaRoutes(app: Application): void { + const router = express.Router(); + router.use(chatAuthMiddleware); + + router.get('/workspaces', async (_req: Request, res: Response) => { + try { + const settings = configStore.get(); + const seenDirectories = new Set(); + const workspaces: Array<{ + id: string; + label: string; + directory: string; + source: 'project' | 'default' | 'allowlist'; + }> = []; + + const pushWorkspace = ( + directory: string | undefined, + source: 'project' | 'default' | 'allowlist', + preferredLabel?: string + ): void => { + const normalized = typeof directory === 'string' ? directory.trim() : ''; + if (!normalized || seenDirectories.has(normalized)) { + return; + } + + seenDirectories.add(normalized); + workspaces.push({ + id: normalized, + label: buildWorkspaceLabel(normalized, preferredLabel), + directory: normalized, + source, + }); + }; + + const projects = await opencodeClient.listProjects().catch(() => []); + for (const project of projects) { + if (!project || typeof project !== 'object') continue; + const record = project as Record; + const worktree = typeof record.worktree === 'string' ? record.worktree.trim() : ''; + const name = typeof record.name === 'string' ? record.name.trim() : ''; + pushWorkspace(worktree, 'project', name); + } + + pushWorkspace(settings.DEFAULT_WORK_DIRECTORY, 'default', '默认工作区'); + + for (const directory of splitDirectories(settings.ALLOWED_DIRECTORIES)) { + pushWorkspace(directory, 'allowlist'); + } + + workspaces.sort((left, right) => left.directory.localeCompare(right.directory, 'zh-Hans-CN')); + res.json({ workspaces }); + } catch (error) { + console.error('[Chat API] 获取工作区列表失败:', error); + res.status(502).json({ error: errorMsg(error) }); + } + }); + + router.get('/agents', async (_req: Request, res: Response) => { + try { + const agents = (await opencodeClient.getAgents()) + .filter(agent => agent.hidden !== true) + .sort((left, right) => left.name.localeCompare(right.name, 'zh-Hans-CN')); + + res.json({ agents }); + } catch (error) { + console.error('[Chat API] 获取 Agent 列表失败:', error); + res.status(502).json({ error: errorMsg(error) }); + } + }); + + router.get('/models', async (_req: Request, res: Response) => { + try { + const providersResult = await opencodeClient.getProviders(); + const providers = Array.isArray(providersResult.providers) ? providersResult.providers : []; + const whitelist = new Set(modelConfig.chatModelWhitelist.map(item => item.toLowerCase())); + const useWhitelist = whitelist.size > 0; + + const items = providers + .map(provider => { + const record = provider as Record; + const id = extractProviderId(provider); + if (!id) { + return null; + } + + const name = typeof record.name === 'string' && record.name.trim() + ? record.name.trim() + : id; + + return { + id, + name, + models: extractProviderModels(provider).filter(model => { + if (!useWhitelist) { + return true; + } + return whitelist.has(`${id}/${model.id}`.toLowerCase()); + }), + }; + }) + .filter((item): item is NonNullable => Boolean(item && item.models.length > 0)) + .sort((left, right) => left.name.localeCompare(right.name, 'zh-Hans-CN')); + + res.json({ providers: items }); + } catch (error) { + console.error('[Chat API] 获取模型列表失败:', error); + res.status(502).json({ error: errorMsg(error) }); + } + }); + + // 列出所有支持 image 输入的 model(供 VISION_OCR_MODEL 下拉选择器使用) + router.get('/vision-models', async (_req: Request, res: Response) => { + try { + const models = await opencodeClient.listVisionModels(); + res.json({ models }); + } catch (error) { + console.error('[Chat API] 获取多模态模型列表失败:', error); + res.status(502).json({ error: errorMsg(error) }); + } + }); + + router.get('/commands', async (_req: Request, res: Response) => { + try { + const commands = (await opencodeClient.getCommands()) + .slice() + .sort((left, right) => left.name.localeCompare(right.name, 'zh-Hans-CN')); + const fallbackCommands = await readBridgeCommandFallback().catch(() => []); + + // 1. 获取技能命令 + const skillCommands = skillRegistry.listSlashCommands(); + const skillCommandItems: CommandItem[] = skillCommands.map(skill => ({ + name: skill.command, + description: skill.description, + source: 'skill' as const, + template: skill.command, + hints: [], + })); + + // 2. 获取 MCP 命令(从 slash.ts 获取) + const mcpSlashCommands = await listMCPSlashCommands(); + const mcpCommandItems: CommandItem[] = toMCPCommandItems(mcpSlashCommands); + + // 3. 获取 Agent 命令(从 slash.ts 获取) + const agentSlashCommands = listAgentSlashCommands(); + const agentCommandItems: CommandItem[] = toAgentCommandItems(agentSlashCommands); + + // 4. 合并所有命令 + const allCommands = mergeCommands( + commands, + [...fallbackCommands, ...skillCommandItems, ...mcpCommandItems, ...agentCommandItems] + ); + + // 5. 按组返回(添加group字段) + const groupedCommands = allCommands.map(cmd => ({ + ...cmd, + group: getCommandGroup(cmd.source), + })); + + res.json({ commands: groupedCommands }); + } catch (error) { + console.error('[Chat API] 获取命令列表失败:', error); + try { + const fallbackCommands = await readBridgeCommandFallback(); + const groupedFallback = fallbackCommands.map(cmd => ({ + ...cmd, + group: getCommandGroup(cmd.source), + })); + res.json({ commands: groupedFallback, fallback: true, error: errorMsg(error) }); + } catch (fallbackError) { + res.status(502).json({ error: errorMsg(fallbackError) }); + } + } + }); + + app.use('/api/chat', router); +} diff --git a/src/admin/routes/chat-permission.ts b/src/admin/routes/chat-permission.ts new file mode 100644 index 0000000..d92b304 --- /dev/null +++ b/src/admin/routes/chat-permission.ts @@ -0,0 +1,59 @@ +import express, { type Request, type Response, type Application } from 'express'; +import { opencodeClient } from '../../opencode/client.js'; +import { chatEventBus } from '../chat/event-bus.js'; +import { chatAuthMiddleware } from './chat-auth.js'; + +function errorMsg(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error'; +} + +export function registerChatPermissionRoutes(app: Application): void { + const router = express.Router(); + router.use(chatAuthMiddleware); + + router.post('/permissions/:id', async (req: Request, res: Response) => { + try { + const permissionId = typeof req.params.id === 'string' ? req.params.id.trim() : ''; + const sessionId = typeof req.body?.sessionId === 'string' ? req.body.sessionId.trim() : ''; + const decision = typeof req.body?.decision === 'string' ? req.body.decision.trim() : ''; + + if (!permissionId || !sessionId) { + res.status(400).json({ error: '缺少 permissionId / sessionId' }); + return; + } + + if (!['allow', 'reject', 'always'].includes(decision)) { + res.status(400).json({ error: 'decision 必须为 allow / reject / always' }); + return; + } + + const result = await opencodeClient.respondToPermission( + sessionId, + permissionId, + decision !== 'reject', + decision === 'always', + ); + + if (!result.ok) { + res.status(result.expired ? 410 : 502).json({ + error: result.expired ? '权限请求已过期' : '权限响应失败', + expired: result.expired === true, + }); + return; + } + + chatEventBus.publish(sessionId, { + type: 'permission_resolved', + reqId: permissionId, + decision: decision as 'allow' | 'reject' | 'always', + }); + + res.json({ ok: true }); + } catch (error) { + console.error('[Chat API] 处理权限请求失败:', error); + res.status(502).json({ error: errorMsg(error) }); + } + }); + + app.use('/api/chat', router); +} diff --git a/src/admin/routes/chat-prompt.ts b/src/admin/routes/chat-prompt.ts new file mode 100644 index 0000000..9a46d27 --- /dev/null +++ b/src/admin/routes/chat-prompt.ts @@ -0,0 +1,153 @@ +/** + * chat-prompt.ts — 发送消息(异步,前端通过 SSE 收取增量) + * + * POST /api/chat/prompt + * Body: + * { + * sessionId: string, + * parts: Array<{ type: 'text', text: string } | { type: 'file', mime: string, url: string, filename?: string }>, + * providerId?: string, + * modelId?: string, + * agent?: string, + * variant?: string, + * directory?: string + * } + * + * 成功返回 { ok: true },模型输出由 /api/chat/events SSE 推送。 + * + * 见 plan-v2.md 第四节。 + */ + +import express, { type Request, type Response, type Application } from 'express'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { opencodeClient } from '../../opencode/client.js'; +import { preprocessVisionParts, type VisionPart } from '../../services/vision-ocr.js'; +import { chatAuthMiddleware } from './chat-auth.js'; + +type ChatPart = VisionPart; + +interface PromptRequestBody { + sessionId?: string; + parts?: ChatPart[]; + providerId?: string; + modelId?: string; + agent?: string; + variant?: string; + directory?: string; +} + +const UPLOAD_DIR = path.resolve(process.cwd(), 'data', 'uploads'); + +function errorMsg(e: unknown): string { + return e instanceof Error ? e.message : 'Unknown error'; +} + +function validateParts(parts: unknown): parts is PromptRequestBody['parts'] { + if (!Array.isArray(parts) || parts.length === 0) return false; + for (const p of parts) { + if (!p || typeof p !== 'object') return false; + const rec = p as Record; + if (rec.type === 'text') { + if (typeof rec.text !== 'string') return false; + } else if (rec.type === 'file') { + if (typeof rec.mime !== 'string' || typeof rec.url !== 'string') return false; + } else { + return false; + } + } + return true; +} + +function extractUploadFilename(rawUrl: string): string | null { + const trimmed = rawUrl.trim(); + if (!trimmed) { + return null; + } + + let pathname = trimmed; + if (/^https?:\/\//i.test(trimmed)) { + try { + pathname = new URL(trimmed).pathname; + } catch { + return null; + } + } + + if (!pathname.startsWith('/uploads/')) { + return null; + } + + const filename = path.basename(pathname); + return filename && filename !== '.' && filename !== '..' ? filename : null; +} + +async function inlineUploadParts(parts: ChatPart[]): Promise { + const resolved = await Promise.all(parts.map(async (part): Promise => { + if (part.type !== 'file') { + return part; + } + + const filename = extractUploadFilename(part.url); + if (!filename) { + return part; + } + + const filePath = path.join(UPLOAD_DIR, filename); + const buffer = await fs.readFile(filePath); + const mime = part.mime || 'application/octet-stream'; + + return { + ...part, + url: `data:${mime};base64,${buffer.toString('base64')}`, + }; + })); + + return resolved; +} + +export function registerChatPromptRoutes(app: Application): void { + const router = express.Router(); + router.use(chatAuthMiddleware); + + router.post('/prompt', async (req: Request, res: Response) => { + try { + const body = (req.body ?? {}) as PromptRequestBody; + const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''; + if (!sessionId) { + res.status(400).json({ error: '缺少 sessionId' }); + return; + } + if (!validateParts(body.parts)) { + res.status(400).json({ error: 'parts 必须为非空数组,仅支持 text / file 两种类型' }); + return; + } + + const inlinedParts = await inlineUploadParts(body.parts!); + const resolvedParts = await preprocessVisionParts( + inlinedParts, + { + providerId: body.providerId, + modelId: body.modelId, + directory: body.directory, + }, + 'Chat API', + ); + + await opencodeClient.sendMessagePartsAsync(sessionId, resolvedParts, { + providerId: body.providerId, + modelId: body.modelId, + agent: body.agent, + variant: body.variant, + directory: body.directory, + }); + + res.json({ ok: true }); + } catch (e) { + console.error('[Chat API] 发送消息失败:', e); + res.status(502).json({ error: errorMsg(e) }); + } + }); + + app.use('/api/chat', router); +} diff --git a/src/admin/routes/chat-sessions.ts b/src/admin/routes/chat-sessions.ts new file mode 100644 index 0000000..0a61b41 --- /dev/null +++ b/src/admin/routes/chat-sessions.ts @@ -0,0 +1,365 @@ +/** + * chat-sessions.ts — 会话 CRUD + 消息历史 + * + * REST 路由(挂载于 /api/chat): + * GET /sessions 列表(跨项目聚合) + * POST /sessions 创建 { title?, directory? } + * GET /sessions/:id 详情 + * PATCH /sessions/:id 重命名 { title } + * DELETE /sessions/:id 删除 { directory? as query } + * GET /sessions/:id/messages 历史消息(info + parts) + * POST /sessions/:id/revert 回退到指定消息(删除该消息及之后消息) + * POST /sessions/:id/undo 回退上一轮对话 + * POST /sessions/:id/summarize 触发总结(可选) + * + * 所有路由只是 opencodeClient 的薄封装,不引入本地持久化。 + * 见 plan-v2.md 第四节。 + */ + +import express, { type Request, type Response, type Application } from 'express'; +import { opencodeClient } from '../../opencode/client.js'; +import { chatEventBus } from '../chat/event-bus.js'; +import type { Session } from '@opencode-ai/sdk'; +import { chatAuthMiddleware } from './chat-auth.js'; + +const MESSAGE_PAGE_LIMIT_DEFAULT = 10; +const MESSAGE_PAGE_LIMIT_MAX = 100; + +function errorMsg(e: unknown): string { + return e instanceof Error ? e.message : 'Unknown error'; +} + +function paramStr(v: string | string[] | undefined): string { + return typeof v === 'string' ? v : Array.isArray(v) ? v[0] ?? '' : ''; +} + +function parsePositiveInt(value: unknown, fallback: number): number { + if (typeof value !== 'string' || !value.trim()) { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + + return parsed; +} + +function isTodoWriteTool(name: unknown): boolean { + if (typeof name !== 'string') { + return false; + } + + const normalized = name.trim().toLowerCase(); + return normalized === 'todowrite' || normalized === 'todo_write' || normalized === 'todo-write'; +} + +function toTodoItems(raw: unknown): Array<{ id: string; content: string; status: string; priority?: string }> { + if (!Array.isArray(raw)) { + return []; + } + + const tasks: Array<{ id: string; content: string; status: string; priority?: string }> = []; + for (const item of raw) { + if (!item || typeof item !== 'object') { + continue; + } + + const record = item as Record; + const id = typeof record.id === 'string' ? record.id.trim() : ''; + const contentValue = record.content ?? record.title ?? record.text; + const content = typeof contentValue === 'string' ? contentValue.trim() : ''; + const status = typeof record.status === 'string' && record.status.trim() + ? record.status.trim() + : 'pending'; + + if (!id || !content) { + continue; + } + + tasks.push({ + id, + content, + status, + ...(typeof record.priority === 'string' ? { priority: record.priority } : {}), + }); + } + + return tasks; +} + +function extractLatestTasks(messages: Array<{ parts?: unknown[] }>): Array<{ id: string; content: string; status: string; priority?: string }> { + let latestTasks: Array<{ id: string; content: string; status: string; priority?: string }> = []; + + for (const message of messages) { + const parts = Array.isArray(message.parts) ? message.parts : []; + for (const part of parts) { + if (!part || typeof part !== 'object') { + continue; + } + + const record = part as Record; + if (record.type !== 'tool' || !isTodoWriteTool(record.tool)) { + continue; + } + + const state = record.state && typeof record.state === 'object' + ? record.state as Record + : undefined; + const metadata = state?.metadata && typeof state.metadata === 'object' + ? state.metadata as Record + : undefined; + const inputTodos = state?.input && typeof state.input === 'object' + ? (state.input as Record).todos + : undefined; + const tasks = toTodoItems(metadata?.todos ?? inputTodos); + + if (tasks.length > 0) { + latestTasks = tasks; + } + } + } + + return latestTasks; +} + +function toSessionItem(s: Session): { + id: string; + title: string; + projectId: string; + directory: string; + parentId?: string; + createdAt: number; + updatedAt: number; + version: string; + summary?: Session['summary']; + share?: Session['share']; +} { + return { + id: s.id, + title: s.title, + projectId: s.projectID, + directory: s.directory, + parentId: s.parentID, + createdAt: s.time?.created ?? 0, + updatedAt: s.time?.updated ?? s.time?.created ?? 0, + version: s.version, + summary: s.summary, + share: s.share, + }; +} + +function findUndoTargetMessageId(messages: Array<{ info?: { id?: string; role?: string } }>): string | undefined { + // 从后往前找最后一条 user 消息作为回退目标 + // 不再 fallback 到任意消息,避免误回退 assistant 消息 + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + const messageId = typeof message?.info?.id === 'string' ? message.info.id.trim() : ''; + if (!messageId) { + continue; + } + + if (message.info?.role === 'user') { + return messageId; + } + } + + return undefined; +} + +export function registerChatSessionsRoutes(app: Application): void { + const router = express.Router(); + router.use(chatAuthMiddleware); + + // ── GET /sessions — 列表 + router.get('/sessions', async (req: Request, res: Response) => { + try { + const directory = typeof req.query.directory === 'string' ? req.query.directory : undefined; + const sessions = directory + ? await opencodeClient.listSessions({ directory }) + : await opencodeClient.listSessionsAcrossProjects(); + + const items = sessions + .map(toSessionItem) + .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + + res.json({ sessions: items }); + } catch (e) { + console.error('[Chat API] 获取会话列表失败:', e); + res.status(502).json({ error: errorMsg(e) }); + } + }); + + // ── POST /sessions — 创建 + router.post('/sessions', async (req: Request, res: Response) => { + try { + const { title, directory } = (req.body ?? {}) as { title?: string; directory?: string }; + const session = await opencodeClient.createSession(title, directory); + res.json({ session: toSessionItem(session) }); + } catch (e) { + console.error('[Chat API] 创建会话失败:', e); + res.status(502).json({ error: errorMsg(e) }); + } + }); + + // ── GET /sessions/:id — 详情 + router.get('/sessions/:id', async (req: Request, res: Response) => { + try { + const directory = typeof req.query.directory === 'string' ? req.query.directory : undefined; + const session = directory + ? await opencodeClient.getSessionById(paramStr(req.params.id), { directory }) + : await opencodeClient.findSessionAcrossProjects(paramStr(req.params.id)); + + if (!session) { + res.status(404).json({ error: 'Session not found' }); + return; + } + res.json({ session: toSessionItem(session) }); + } catch (e) { + console.error('[Chat API] 获取会话详情失败:', e); + res.status(502).json({ error: errorMsg(e) }); + } + }); + + // ── PATCH /sessions/:id — 重命名 + router.patch('/sessions/:id', async (req: Request, res: Response) => { + try { + const { title } = (req.body ?? {}) as { title?: string }; + if (!title || !title.trim()) { + res.status(400).json({ error: '缺少 title' }); + return; + } + + const ok = await opencodeClient.updateSession(paramStr(req.params.id), title.trim()); + if (!ok) { + res.status(502).json({ error: '重命名失败' }); + return; + } + res.json({ ok: true }); + } catch (e) { + console.error('[Chat API] 重命名会话失败:', e); + res.status(502).json({ error: errorMsg(e) }); + } + }); + + // ── DELETE /sessions/:id + router.delete('/sessions/:id', async (req: Request, res: Response) => { + try { + const directory = typeof req.query.directory === 'string' ? req.query.directory : undefined; + const ok = await opencodeClient.deleteSession(paramStr(req.params.id), directory ? { directory } : undefined); + if (!ok) { + res.status(502).json({ error: '删除失败' }); + return; + } + chatEventBus.clearSession(paramStr(req.params.id)); + res.json({ ok: true }); + } catch (e) { + console.error('[Chat API] 删除会话失败:', e); + res.status(502).json({ error: errorMsg(e) }); + } + }); + + // ── GET /sessions/:id/messages — 历史消息 + router.get('/sessions/:id/messages', async (req: Request, res: Response) => { + try { + const messages = await opencodeClient.getSessionMessages(paramStr(req.params.id)); + const total = messages.length; + const limit = Math.min( + parsePositiveInt(req.query.limit, MESSAGE_PAGE_LIMIT_DEFAULT), + MESSAGE_PAGE_LIMIT_MAX + ); + const requestedCursor = parsePositiveInt(req.query.cursor, total); + const cursor = Math.min(requestedCursor, total); + const start = Math.max(cursor - limit, 0); + const page = messages.slice(start, cursor); + + res.json({ + messages: page, + tasks: extractLatestTasks(messages), + total, + hasMore: start > 0, + nextCursor: start > 0 ? String(start) : null, + }); + } catch (e) { + console.error('[Chat API] 获取会话消息失败:', e); + res.status(502).json({ error: errorMsg(e) }); + } + }); + + // ── POST /sessions/:id/summarize — 触发总结 + router.post('/sessions/:id/summarize', async (req: Request, res: Response) => { + try { + const { providerId, modelId } = (req.body ?? {}) as { providerId?: string; modelId?: string }; + if (!providerId || !modelId) { + res.status(400).json({ error: '缺少 providerId / modelId' }); + return; + } + const ok = await opencodeClient.summarizeSession(paramStr(req.params.id), providerId, modelId); + res.json({ ok }); + } catch (e) { + console.error('[Chat API] 总结会话失败:', e); + res.status(502).json({ error: errorMsg(e) }); + } + }); + + // ── POST /sessions/:id/revert — 回退到指定消息 + router.post('/sessions/:id/revert', async (req: Request, res: Response) => { + try { + const sessionId = paramStr(req.params.id); + const messageId = typeof req.body?.messageId === 'string' ? req.body.messageId.trim() : ''; + + if (!sessionId) { + res.status(400).json({ error: '缺少 sessionId' }); + return; + } + + if (!messageId) { + res.status(400).json({ error: '缺少 messageId' }); + return; + } + + const ok = await opencodeClient.revertMessage(sessionId, messageId); + if (!ok) { + res.status(502).json({ error: '回退失败' }); + return; + } + + res.json({ ok: true }); + } catch (e) { + console.error('[Chat API] 回退会话失败:', e); + res.status(502).json({ error: errorMsg(e) }); + } + }); + + // ── POST /sessions/:id/undo — 回退上一轮对话 + router.post('/sessions/:id/undo', async (req: Request, res: Response) => { + try { + const sessionId = paramStr(req.params.id); + if (!sessionId) { + res.status(400).json({ error: '缺少 sessionId' }); + return; + } + + const messages = await opencodeClient.getSessionMessages(sessionId); + const targetMessageId = findUndoTargetMessageId(messages); + if (!targetMessageId) { + res.status(409).json({ error: '当前没有可回退的对话' }); + return; + } + + const ok = await opencodeClient.revertMessage(sessionId, targetMessageId); + if (!ok) { + res.status(502).json({ error: '回退失败' }); + return; + } + + res.json({ ok: true, messageId: targetMessageId }); + } catch (e) { + console.error('[Chat API] 回退上一轮失败:', e); + res.status(502).json({ error: errorMsg(e) }); + } + }); + + app.use('/api/chat', router); +} diff --git a/src/admin/routes/chat-upload.ts b/src/admin/routes/chat-upload.ts new file mode 100644 index 0000000..435e3d7 --- /dev/null +++ b/src/admin/routes/chat-upload.ts @@ -0,0 +1,199 @@ +/** + * chat-upload.ts — 文件上传 API + * + * POST /api/chat/upload + * + * 支持的文件类型:对齐 OpenCode 能力(所有 MIME 类型) + * 文件大小限制:使用 ATTACHMENT_MAX_SIZE 配置(默认 50MB) + * + * 成功返回: + * { + * ok: true, + * file: { + * url: string, // 文件访问 URL + * filename: string, // 原始文件名 + * mime: string, // MIME 类型 + * size: number // 文件大小(字节) + * } + * } + */ + +import express, { type Request, type Response, type Application } from 'express'; +import multer from 'multer'; +import crypto from 'node:crypto'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { chatAuthMiddleware } from './chat-auth.js'; + +// 生成文件存储目录 +const UPLOAD_DIR = path.resolve(process.cwd(), 'data', 'uploads'); + +// 确保上传目录存在 +async function ensureUploadDir(): Promise { + try { + await fs.mkdir(UPLOAD_DIR, { recursive: true }); + } catch (error) { + console.error('[Upload] 创建上传目录失败:', error); + throw error; + } +} + +// 生成唯一文件名 +function generateFilename(originalName: string): string { + const ext = path.extname(originalName); + const hash = crypto.randomBytes(16).toString('hex'); + const basename = path.basename(originalName, ext).replace(/[^a-zA-Z0-9]/g, '_').slice(0, 50); + return `${basename}_${hash}${ext}`; +} + +// MIME 类型映射 +const MIME_TYPE_MAP: Record = { + // 图片 + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.bmp': 'image/bmp', + + // 文档 + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.txt': 'text/plain', + '.md': 'text/markdown', + '.csv': 'text/csv', + + // 代码 + '.js': 'text/javascript', + '.ts': 'text/typescript', + '.json': 'application/json', + '.xml': 'application/xml', + '.html': 'text/html', + '.css': 'text/css', + '.py': 'text/x-python', + '.java': 'text/x-java-source', + '.c': 'text/x-c', + '.cpp': 'text/x-c++', + '.h': 'text/x-c', + '.hpp': 'text/x-c++', + '.rs': 'text/x-rust', + '.go': 'text/x-go', + '.php': 'text/x-php', + '.rb': 'text/x-ruby', + '.sh': 'text/x-shellscript', + '.sql': 'application/sql', + + // 压缩文件 + '.zip': 'application/zip', + '.tar': 'application/x-tar', + '.gz': 'application/gzip', + '.rar': 'application/vnd.rar', + '.7z': 'application/x-7z-compressed', + + // 视频 + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.ogv': 'video/ogg', + '.avi': 'video/x-msvideo', + '.mov': 'video/quicktime', + '.wmv': 'video/x-ms-wmv', + + // 音频 + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', + '.flac': 'audio/flac', + '.aac': 'audio/aac', + '.m4a': 'audio/mp4', +}; + +// 获取 MIME 类型 +function getMimeType(filename: string): string { + const ext = path.extname(filename).toLowerCase(); + return MIME_TYPE_MAP[ext] || 'application/octet-stream'; +} + +// 获取文件大小限制(从配置读取,默认 50MB) +function getMaxFileSize(): number { + const maxSize = process.env.ATTACHMENT_MAX_SIZE || '52428800'; + return parseInt(maxSize, 10) || 52428800; +} + +// 配置 multer 存储 +const storage = multer.diskStorage({ + destination: async (_req, _file, cb) => { + try { + await ensureUploadDir(); + cb(null, UPLOAD_DIR); + } catch (error) { + cb(error as Error, UPLOAD_DIR); + } + }, + filename: (_req, file, cb) => { + const safeName = generateFilename(file.originalname); + cb(null, safeName); + }, +}); + +// multer 配置(不限制文件类型,仅限制大小) +const upload = multer({ + storage, + limits: { + fileSize: getMaxFileSize(), + }, +}); + +export function registerChatUploadRoutes(app: Application): void { + const router = express.Router(); + router.use(chatAuthMiddleware); + + // 单文件上传 + router.post('/upload', upload.single('file'), async (req: Request, res: Response) => { + try { + if (!req.file) { + res.status(400).json({ error: '未找到上传的文件' }); + return; + } + + const file = req.file; + const mime = getMimeType(file.originalname); + + // 返回相对路径,避免代理问题 + // 浏览器会自动从当前域名加载,不经过代理 + const fileUrl = `/uploads/${file.filename}`; + + // 返回文件信息 + res.json({ + ok: true, + file: { + url: fileUrl, + filename: file.originalname, + mime, + size: file.size, + }, + }); + } catch (error) { + console.error('[Upload] 文件上传失败:', error); + res.status(500).json({ + error: error instanceof Error ? error.message : '文件上传失败', + }); + } + }); + + // 多文件上传(可选,暂不暴露) + // router.post('/upload/multiple', upload.array('files', 10), async (req: Request, res: Response) => { + // // TODO: 实现多文件上传 + // }); + + app.use('/api/chat', router); + + // 注册静态文件服务(用于访问上传的文件) + app.use('/uploads', express.static(UPLOAD_DIR)); +} diff --git a/src/admin/routes/chat.ts b/src/admin/routes/chat.ts new file mode 100644 index 0000000..a03a305 --- /dev/null +++ b/src/admin/routes/chat.ts @@ -0,0 +1,38 @@ +/** + * Chat Routes Registry + * + * Centralizes all /api/chat/* routes for the new native chat UI. + * + * Routes: + * - GET /api/chat/sessions - List all sessions + * - POST /api/chat/sessions - Create new session + * - GET /api/chat/sessions/:id - Get session details + * - DELETE /api/chat/sessions/:id - Delete session + * - PATCH /api/chat/sessions/:id - Rename session + * - GET /api/chat/sessions/:id/messages - Get session messages + * - POST /api/chat/sessions/:id/revert - Revert session to a message + * - POST /api/chat/sessions/:id/undo - Undo last interaction + * - POST /api/chat/sessions/:id/prompt - Send message + * - POST /api/chat/sessions/:id/abort - Abort session + * - GET /api/chat/events - SSE event stream + * - POST /api/chat/permissions/:id - Respond to permission request + */ + +import { registerChatSessionsRoutes } from './chat-sessions.js'; +import { registerChatPromptRoutes } from './chat-prompt.js'; +import { registerChatEventsRoutes } from './chat-events.js'; +import { registerChatPermissionRoutes } from './chat-permission.js'; +import { registerChatAbortRoutes } from './chat-abort.js'; +import { registerChatMetaRoutes } from './chat-meta.js'; +import { chatEventNormalizer } from '../chat/event-normalizer.js'; + +export function registerChatRoutes(app: import('express').Application): void { + chatEventNormalizer.install(); + // Register all chat-related routes + registerChatSessionsRoutes(app); + registerChatMetaRoutes(app); + registerChatPromptRoutes(app); + registerChatEventsRoutes(app); + registerChatPermissionRoutes(app); + registerChatAbortRoutes(app); +} diff --git a/src/admin/routes/resources-terminal.ts b/src/admin/routes/resources-terminal.ts new file mode 100644 index 0000000..f718e32 --- /dev/null +++ b/src/admin/routes/resources-terminal.ts @@ -0,0 +1,265 @@ +/** + * WebSocket Terminal for OAuth Provider Login + * + * 提供WebSocket端点用于执行 opencode providers login 命令 + * 仅允许白名单命令,单连接超时10分钟 + */ + +import express from 'express'; +import { createServer } from 'http'; +import { WebSocketServer, WebSocket } from 'ws'; +import { spawn, type ChildProcess } from 'node:child_process'; + +const CONNECTION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes +const COMMAND_WHITELIST = new Set([ + 'opencode', +]); + +// 允许的命令模式 +const ALLOWED_PATTERNS = [ + /^opencode\s+providers\s+login(\s.*)?$/, + /^opencode\s+providers\s+logout(\s.*)?$/, + /^opencode\s+providers\s+list$/, +]; + +type TerminalSession = { + ws: WebSocket; + process: ChildProcess | null; + timeout: NodeJS.Timeout; + command: string | null; +}; + +const activeSessions = new Map(); + +/** + * 检查命令是否在白名单中 + */ +function isCommandAllowed(command: string): boolean { + const trimmed = command.trim(); + for (const pattern of ALLOWED_PATTERNS) { + if (pattern.test(trimmed)) { + return true; + } + } + return false; +} + +/** + * 清理会话 + */ +function cleanupSession(ws: WebSocket): void { + const session = activeSessions.get(ws); + if (!session) { + return; + } + + clearTimeout(session.timeout); + + if (session.process) { + try { + session.process.kill(); + } catch { + // Ignore + } + } + + activeSessions.delete(ws); +} + +/** + * 设置会话超时 + */ +function setupSessionTimeout(ws: WebSocket): void { + const session = activeSessions.get(ws); + if (!session) { + return; + } + + clearTimeout(session.timeout); + session.timeout = setTimeout(() => { + ws.send('\r\n\n⚠️ 会话超时(10分钟),连接已关闭\r\n'); + ws.close(); + }, CONNECTION_TIMEOUT_MS); +} + +/** + * 执行命令 + */ +function executeCommand(ws: WebSocket, command: string): void { + const session = activeSessions.get(ws); + if (!session) { + ws.send('\r\n❌ 会话不存在\r\n'); + return; + } + + if (!isCommandAllowed(command)) { + ws.send(`\r\n❌ 命令不在白名单中: ${command}\r\n`); + ws.send('允许的命令:\r\n'); + ws.send(' - opencode providers login [provider]\r\n'); + ws.send(' - opencode providers logout [provider]\r\n'); + ws.send(' - opencode providers list\r\n'); + ws.send('$ '); + return; + } + + // 停止之前的进程 + if (session.process) { + try { + session.process.kill(); + } catch { + // Ignore + } + } + + const args = command.trim().split(/\s+/); + const cmd = args[0]; + const cmdArgs = args.slice(1); + + ws.send(`\r\n$ ${command}\r\n`); + + try { + const child = spawn(cmd, cmdArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + TERM: 'xterm-256color', + }, + }); + + session.process = child; + + // 处理stdout + child.stdout?.on('data', (data) => { + const output = data.toString(); + ws.send(output); + }); + + // 处理stderr + child.stderr?.on('data', (data) => { + const output = data.toString(); + ws.send(output); + }); + + // 处理退出 + child.on('exit', (code) => { + session.process = null; + const exitMsg = code === 0 + ? '\r\n✓ 命令执行完成\r\n' + : `\r\n❌ 命令退出,代码: ${code}\r\n`; + ws.send(exitMsg); + ws.send('$ '); + setupSessionTimeout(ws); + }); + + child.on('error', (err) => { + session.process = null; + ws.send(`\r\n❌ 执行错误: ${err.message}\r\n`); + ws.send('$ '); + setupSessionTimeout(ws); + }); + + } catch (err) { + ws.send(`\r\n❌ 启动进程失败: ${err}\r\n`); + ws.send('$ '); + setupSessionTimeout(ws); + } +} + +/** + * 注册终端路由和WebSocket服务器 + */ +export function registerResourcesTerminalRoutes(api: express.Router): void { + // POST /api/resources/terminal/create - 创建终端会话 + api.post('/resources/terminal/create', async (_req, res) => { + res.json({ + ok: true, + message: 'WebSocket terminal available at WS endpoint', + wsUrl: '/api/resources/terminal/ws', + }); + }); +} + +/** + * 设置WebSocket终端服务器 + */ +export function setupResourcesTerminalWebSocket(httpServer: ReturnType): void { + const wss = new WebSocketServer({ + noServer: true, + path: '/api/resources/terminal/ws', + }); + + // 升级HTTP请求到WebSocket + httpServer.on('upgrade', (request, socket, head) => { + const pathname = new URL(request.url || '', `http://${request.headers.host}`).pathname; + + if (pathname === '/api/resources/terminal/ws') { + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request); + }); + } + }); + + wss.on('connection', (ws) => { + console.log('[Resources Terminal] 新连接已建立'); + + // 创建会话 + const session: TerminalSession = { + ws, + process: null, + timeout: setTimeout(() => { + ws.send('\r\n\n⚠️ 会话超时(10分钟),连接已关闭\r\n'); + ws.close(); + }, CONNECTION_TIMEOUT_MS), + command: null, + }; + + activeSessions.set(ws, session); + + // 发送欢迎消息 + ws.send( + '\r\n' + + '========================================\r\n' + + ' OpenCode Bridge - OAuth Login Terminal\r\n' + + '========================================\r\n' + + '\r\n' + + '允许的命令:\r\n' + + ' - opencode providers login [provider]\r\n' + + ' - opencode providers logout [provider]\r\n' + + ' - opencode providers list\r\n' + + '\r\n' + + '注意: 会话将在10分钟后自动关闭\r\n' + + '\r\n' + + '$ ' + ); + + // 处理消息 + ws.on('message', (data) => { + const message = data.toString(); + const trimmed = message.trim(); + + if (!trimmed) { + return; + } + + // 重置超时 + setupSessionTimeout(ws); + + // 执行命令 + executeCommand(ws, trimmed); + }); + + // 处理关闭 + ws.on('close', () => { + console.log('[Resources Terminal] 连接已关闭'); + cleanupSession(ws); + }); + + // 处理错误 + ws.on('error', (err) => { + console.error('[Resources Terminal] WebSocket错误:', err); + cleanupSession(ws); + }); + }); + + console.log('[Resources Terminal] WebSocket服务器已就绪'); +} diff --git a/src/admin/routes/resources.ts b/src/admin/routes/resources.ts new file mode 100644 index 0000000..f1c8855 --- /dev/null +++ b/src/admin/routes/resources.ts @@ -0,0 +1,864 @@ +/** + * Resources Management API Routes + * + * REST API endpoints for managing Skills, MCP Servers, Agents, and Providers. + * Provides CRUD operations, enable/disable toggles, and SSE event streaming. + * + * Routes: + * Skills: /api/resources/skills + * MCP: /api/resources/mcp + * Agents: /api/resources/agents + * Providers: /api/resources/providers + * Events: /api/resources/events (SSE) + */ + +import express from 'express'; +import { skillRegistry } from '../../services/resources/skills/registry.js'; +import { getMCPRegistry } from '../../services/resources/mcp/manager.js'; +import { getAgentRegistry } from '../../services/resources/agents/manager.js'; +import { getProviderRegistry } from '../../services/resources/providers/manager.js'; +import { onResourceChange } from '../../services/resources/events.js'; +import type { ResourceScope } from '../../services/resources/types.js'; +import type { SkillSlashCommand } from '../../services/resources/skills/registry.js'; +import type { MCPServerConfig, MCPInput } from '../../services/resources/mcp/types.js'; +import type { AgentConfig, AgentInput } from '../../services/resources/agents/types.js'; +import type { ProviderConfig } from '../../services/resources/providers/types.js'; + +export function createResourcesRoutes(): express.Router { + const router = express.Router(); + + // ============================================================================ + // STATS ROUTE + // ============================================================================ + + // GET /api/resources/stats - Get resource statistics + router.get('/stats', (_req, res) => { + try { + const skills = skillRegistry.list(); + const mcpRegistry = getMCPRegistry(); + const agentRegistry = getAgentRegistry(); + const providerRegistry = getProviderRegistry(); + + const servers = mcpRegistry.list(); + const agents = agentRegistry.list(); + const providers = providerRegistry.list(); + + res.json({ + skills: skills.length, + mcp: servers.length, + agents: agents.length, + providers: providers.length, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to get stats:', message); + res.status(500).json({ error: `Failed to retrieve resource statistics: ${message}` }); + } + }); + + // ============================================================================ + // SKILLS ROUTES + // ============================================================================ + + // GET /api/resources/skills - List all skills + router.get('/skills', (req, res) => { + try { + const scope = req.query.scope as ResourceScope | undefined; + let skills = skillRegistry.list(); + + // Filter by scope if provided + if (scope === 'project' || scope === 'user') { + skills = skills.filter((s) => s.scope === scope); + } + + res.json({ resources: skills }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to list skills:', message); + res.status(500).json({ error: message }); + } + }); + + // GET /api/resources/skills/:name - Get skill details + router.get('/skills/:name', (req, res) => { + try { + const { name } = req.params; + const scope = req.query.scope as ResourceScope | undefined; + const skill = skillRegistry.get(name, scope); + + if (!skill) { + res.status(404).json({ error: `Skill not found: ${name}` }); + return; + } + + res.json({ skill }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to get skill:', message); + res.status(500).json({ error: message }); + } + }); + + // POST /api/resources/skills - Create new skill + router.post('/skills', (req, res) => { + try { + const { name, markdown, scope } = req.body; + + if (!name || typeof name !== 'string') { + res.status(400).json({ error: 'Missing or invalid field: name (string required)' }); + return; + } + + if (!markdown || typeof markdown !== 'string') { + res.status(400).json({ error: 'Missing or invalid field: markdown (string required)' }); + return; + } + + const resourceScope: ResourceScope = scope === 'user' ? 'user' : 'project'; + + // Parse markdown to extract frontmatter + const frontmatterMatch = markdown.match(/^---\n([\s\S]+?)\n---\n([\s\S]*)$/); + let frontmatter: Record = {}; + let body = markdown; + + if (frontmatterMatch) { + try { + // Parse YAML frontmatter (simple key-value pairs) + const fmLines = frontmatterMatch[1].split('\n'); + for (const line of fmLines) { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim(); + let value: string = line.slice(colonIndex + 1).trim(); + + // Parse boolean values + if (value === 'true') { + frontmatter[key] = true; + continue; + } else if (value === 'false') { + frontmatter[key] = false; + continue; + } + // Parse array values + if (value.startsWith('[') && value.endsWith(']')) { + const arrValue = value.slice(1, -1).split(',').map((v: string) => v.trim()); + frontmatter[key] = arrValue; + continue; + } + + frontmatter[key] = value; + } + } + } catch (e) { + console.warn('[Resources API] Failed to parse frontmatter, using defaults'); + } + body = frontmatterMatch[2]; + } + + const skill = skillRegistry.create({ + name, + scope: resourceScope, + frontmatter: { + description: typeof frontmatter.description === 'string' ? frontmatter.description : '', + version: typeof frontmatter.version === 'string' ? frontmatter.version : undefined, + allowedTools: Array.isArray(frontmatter.allowedTools) ? frontmatter.allowedTools : undefined, + enabled: typeof frontmatter.enabled === 'boolean' ? frontmatter.enabled : true, + extra: frontmatter, + }, + body, + }); + + res.status(201).json({ skill }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to create skill:', message); + const skillName = req.body.name as string | undefined; + if (message.includes('already exists') || message.includes('duplicate')) { + res.status(409).json({ error: `Skill "${skillName || 'unknown'}" already exists` }); + } else { + res.status(500).json({ error: `Failed to create skill: ${message}` }); + } + } + }); + + // PUT /api/resources/skills/:name - Update skill + router.put('/skills/:name', (req, res) => { + try { + const { name } = req.params; + const { markdown, scope } = req.body; + + if (!markdown || typeof markdown !== 'string') { + res.status(400).json({ error: 'Missing or invalid field: markdown (string required)' }); + return; + } + + const resourceScope: ResourceScope | undefined = scope === 'user' || scope === 'project' ? scope : undefined; + + // Parse markdown to extract frontmatter + const frontmatterMatch = markdown.match(/^---\n([\s\S]+?)\n---\n([\s\S]*)$/); + let frontmatter: Record = {}; + let body = markdown; + + if (frontmatterMatch) { + try { + const fmLines = frontmatterMatch[1].split('\n'); + for (const line of fmLines) { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim(); + let value: string = line.slice(colonIndex + 1).trim(); + + if (value === 'true') { + frontmatter[key] = true; + continue; + } else if (value === 'false') { + frontmatter[key] = false; + continue; + } + if (value.startsWith('[') && value.endsWith(']')) { + const arrValue = value.slice(1, -1).split(',').map((v: string) => v.trim()); + frontmatter[key] = arrValue; + continue; + } + + frontmatter[key] = value; + } + } + } catch (e) { + console.warn('[Resources API] Failed to parse frontmatter'); + } + body = frontmatterMatch[2]; + } + + const skill = skillRegistry.update({ + name, + scope: resourceScope, + frontmatter: { + description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined, + version: typeof frontmatter.version === 'string' ? frontmatter.version : undefined, + allowedTools: Array.isArray(frontmatter.allowedTools) ? frontmatter.allowedTools : undefined, + enabled: typeof frontmatter.enabled === 'boolean' ? frontmatter.enabled : undefined, + extra: frontmatter, + }, + body, + }); + + res.json({ skill }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to update skill:', message); + const skillName = req.params.name; + if (message.includes('not found')) { + res.status(404).json({ error: `Skill "${skillName}" not found` }); + } else { + res.status(500).json({ error: `Failed to update skill: ${message}` }); + } + } + }); + + // DELETE /api/resources/skills/:name - Delete skill + router.delete('/skills/:name', (req, res) => { + try { + const { name } = req.params; + const scope = req.query.scope as ResourceScope | undefined; + + skillRegistry.delete(name, scope); + + res.json({ ok: true, message: `Skill "${name}" deleted` }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to delete skill:', message); + const skillName = req.params.name; + if (message.includes('not found')) { + res.status(404).json({ error: `Skill "${skillName}" not found` }); + } else if (message.includes('built-in') || message.includes('system')) { + res.status(403).json({ error: `Cannot delete built-in skill "${skillName}"` }); + } else { + res.status(500).json({ error: `Failed to delete skill: ${message}` }); + } + } + }); + + // POST /api/resources/skills/:name/toggle - Enable/disable skill + router.post('/skills/:name/toggle', (req, res) => { + try { + const { name } = req.params; + const { enabled } = req.body; + const scope = req.query.scope as ResourceScope | undefined; + + if (typeof enabled !== 'boolean') { + res.status(400).json({ error: 'Missing or invalid field: enabled (boolean required)' }); + return; + } + + const skill = skillRegistry.toggle(name, enabled, scope); + + res.json({ skill, message: `Skill "${name}" ${enabled ? 'enabled' : 'disabled'}` }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to toggle skill:', message); + res.status(500).json({ error: message }); + } + }); + + // GET /api/resources/skills/slash - List all slash commands + router.get('/skills/slash', (_req, res) => { + try { + const commands: SkillSlashCommand[] = skillRegistry.listSlashCommands(); + res.json({ commands }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to list slash commands:', message); + res.status(500).json({ error: message }); + } + }); + + // ============================================================================ + // MCP ROUTES + // ============================================================================ + + // GET /api/resources/mcp - List all MCP servers + router.get('/mcp', (req, res) => { + try { + const scope = req.query.scope as ResourceScope | undefined; + const mcpRegistry = getMCPRegistry(); + let servers = mcpRegistry.list(); + + // Filter by scope if provided + if (scope === 'project' || scope === 'user') { + servers = servers.filter((s) => s.scope === scope); + } + + res.json({ resources: servers }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to list MCP servers:', message); + res.status(500).json({ error: message }); + } + }); + + // GET /api/resources/mcp/:name - Get MCP server details + router.get('/mcp/:name', (req, res) => { + try { + const { name } = req.params; + const scope = req.query.scope as ResourceScope | undefined; + + const mcpRegistry = getMCPRegistry(); + const server = mcpRegistry.get(name, scope); + + if (!server) { + res.status(404).json({ error: `MCP server not found: ${name}` }); + return; + } + + res.json({ server }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to get MCP server:', message); + res.status(500).json({ error: message }); + } + }); + + // POST /api/resources/mcp - Create MCP server + router.post('/mcp', async (req, res) => { + try { + const { name, transport, description, enabled, scope } = req.body; + + if (!name || typeof name !== 'string') { + res.status(400).json({ error: 'Missing or invalid field: name (string required)' }); + return; + } + + if (!transport || !['stdio', 'sse', 'http'].includes(transport)) { + res.status(400).json({ error: 'Missing or invalid field: transport (must be stdio, sse, or http)' }); + return; + } + + const resourceScope: ResourceScope = scope === 'user' ? 'user' : 'project'; + + const mcpRegistry = getMCPRegistry(); + + // Add transport-specific fields + let input: MCPInput; + + if (transport === 'stdio') { + if (!req.body.command || typeof req.body.command !== 'string') { + res.status(400).json({ error: 'Missing or invalid field: command (required for stdio transport)' }); + return; + } + input = { + transport, + command: req.body.command, + args: req.body.args, + cwd: req.body.cwd, + env: req.body.env, + description, + enabled: enabled !== undefined ? enabled : true, + } as MCPInput; + } else if (transport === 'sse') { + if (!req.body.url || typeof req.body.url !== 'string') { + res.status(400).json({ error: 'Missing or invalid field: url (required for sse transport)' }); + return; + } + input = { + transport, + url: req.body.url, + headers: req.body.headers, + description, + enabled: enabled !== undefined ? enabled : true, + } as MCPInput; + } else { + if (!req.body.url || typeof req.body.url !== 'string') { + res.status(400).json({ error: 'Missing or invalid field: url (required for http transport)' }); + return; + } + input = { + transport, + url: req.body.url, + headers: req.body.headers, + description, + enabled: enabled !== undefined ? enabled : true, + } as MCPInput; + } + + const server = await mcpRegistry.create(name, input, resourceScope); + + res.status(201).json({ server }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to create MCP server:', message); + res.status(500).json({ error: message }); + } + }); + + // PUT /api/resources/mcp/:name - Update MCP server + router.put('/mcp/:name', async (req, res) => { + try { + const { name } = req.params; + const { transport, description, enabled, scope } = req.body; + const resourceScope: ResourceScope = scope === 'user' ? 'user' : 'project'; + + const mcpRegistry = getMCPRegistry(); + + // Build input based on transport type + let input: Partial; + + if (transport === 'stdio') { + input = { + transport, + command: req.body.command, + args: req.body.args, + cwd: req.body.cwd, + env: req.body.env, + description, + enabled, + } as Partial; + } else if (transport === 'sse') { + input = { + transport, + url: req.body.url, + headers: req.body.headers, + description, + enabled, + } as Partial; + } else if (transport === 'http') { + input = { + transport, + url: req.body.url, + headers: req.body.headers, + description, + enabled, + } as Partial; + } else { + input = { + description, + enabled, + }; + } + + const server = await mcpRegistry.update(name, input, resourceScope); + + res.json({ server }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to update MCP server:', message); + res.status(500).json({ error: message }); + } + }); + + // DELETE /api/resources/mcp/:name - Delete MCP server + router.delete('/mcp/:name', async (req, res) => { + try { + const { name } = req.params; + const scope = req.query.scope as ResourceScope | undefined; + const resourceScope: ResourceScope = scope === 'user' ? 'user' : 'project'; + + const mcpRegistry = getMCPRegistry(); + await mcpRegistry.delete(name, resourceScope); + + res.json({ ok: true, message: `MCP server "${name}" deleted` }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to delete MCP server:', message); + res.status(500).json({ error: message }); + } + }); + + // POST /api/resources/mcp/:name/toggle - Enable/disable MCP server + router.post('/mcp/:name/toggle', async (req, res) => { + try { + const { name } = req.params; + const { enabled } = req.body; + const scope = req.query.scope as ResourceScope | undefined; + + if (typeof enabled !== 'boolean') { + res.status(400).json({ error: 'Missing or invalid field: enabled (boolean required)' }); + return; + } + + const mcpRegistry = getMCPRegistry(); + const server = await mcpRegistry.toggle(name, enabled, scope); + + res.json({ server, message: `MCP server "${name}" ${enabled ? 'enabled' : 'disabled'}` }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to toggle MCP server:', message); + res.status(500).json({ error: message }); + } + }); + + // ============================================================================ + // AGENTS ROUTES + // ============================================================================ + + // GET /api/resources/agents - List all agents + router.get('/agents', (req, res) => { + try { + const scope = req.query.scope as ResourceScope | undefined; + const agentRegistry = getAgentRegistry(); + let agents = agentRegistry.list(); + + // Filter by scope if provided + if (scope === 'project' || scope === 'user') { + agents = agents.filter((a) => a.scope === scope); + } + + res.json({ resources: agents }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to list agents:', message); + res.status(500).json({ error: message }); + } + }); + + // GET /api/resources/agents/:name - Get agent details + router.get('/agents/:name', (req, res) => { + try { + const { name } = req.params; + const scope = req.query.scope as ResourceScope | undefined; + + const agentRegistry = getAgentRegistry(); + const agent = agentRegistry.get(name, scope); + + if (!agent) { + res.status(404).json({ error: `Agent not found: ${name}` }); + return; + } + + res.json({ agent }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to get agent:', message); + res.status(500).json({ error: message }); + } + }); + + // POST /api/resources/agents - Create agent + router.post('/agents', async (req, res) => { + try { + const { name, description, mode, prompt, tools, model, enabled, scope } = req.body; + + if (!name || typeof name !== 'string') { + res.status(400).json({ error: 'Missing or invalid field: name (string required)' }); + return; + } + + const resourceScope: ResourceScope = scope === 'user' ? 'user' : 'project'; + + const agentRegistry = getAgentRegistry(); + const input: AgentInput = { + description, + mode, + prompt, + tools, + model, + enabled: enabled !== undefined ? enabled : true, + }; + + const agent = await agentRegistry.create(name, input, resourceScope); + + res.status(201).json({ agent }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to create agent:', message); + res.status(500).json({ error: message }); + } + }); + + // PUT /api/resources/agents/:name - Update agent + router.put('/agents/:name', async (req, res) => { + try { + const { name } = req.params; + const { description, mode, prompt, tools, model, enabled, scope } = req.body; + const resourceScope: ResourceScope = scope === 'user' ? 'user' : 'project'; + + const agentRegistry = getAgentRegistry(); + const input: Partial = { + description, + mode, + prompt, + tools, + model, + enabled, + }; + + const agent = await agentRegistry.update(name, input, resourceScope); + + res.json({ agent }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to update agent:', message); + res.status(500).json({ error: message }); + } + }); + + // DELETE /api/resources/agents/:name - Delete agent + router.delete('/agents/:name', async (req, res) => { + try { + const { name } = req.params; + const scope = req.query.scope as ResourceScope | undefined; + const resourceScope: ResourceScope = scope === 'user' ? 'user' : 'project'; + + const agentRegistry = getAgentRegistry(); + await agentRegistry.delete(name, resourceScope); + + res.json({ ok: true, message: `Agent "${name}" deleted` }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to delete agent:', message); + res.status(500).json({ error: message }); + } + }); + + // POST /api/resources/agents/:name/toggle - Enable/disable agent + router.post('/agents/:name/toggle', async (req, res) => { + try { + const { name } = req.params; + const { enabled } = req.body; + const scope = req.query.scope as ResourceScope | undefined; + + if (typeof enabled !== 'boolean') { + res.status(400).json({ error: 'Missing or invalid field: enabled (boolean required)' }); + return; + } + + const agentRegistry = getAgentRegistry(); + const agent = await agentRegistry.toggle(name, enabled, scope); + + res.json({ agent, message: `Agent "${name}" ${enabled ? 'enabled' : 'disabled'}` }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to toggle agent:', message); + res.status(500).json({ error: message }); + } + }); + + // ============================================================================ + // PROVIDERS ROUTES + // ============================================================================ + + // GET /api/resources/providers - List all providers + router.get('/providers', (_req, res) => { + try { + const providerRegistry = getProviderRegistry(); + const providers = providerRegistry.list(); + res.json({ resources: providers }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to list providers:', message); + res.status(500).json({ error: message }); + } + }); + + // GET /api/resources/providers/:id - Get provider config + router.get('/providers/:id', (req, res) => { + try { + const { id } = req.params; + + const providerRegistry = getProviderRegistry(); + const provider = providerRegistry.get(id); + + if (!provider) { + res.status(404).json({ error: `Provider not found: ${id}` }); + return; + } + + // Don't expose the actual API key + const sanitized = provider.type === 'api' + ? { type: 'api', key: provider.key ? '••••••••' : '' } + : provider; + + res.json({ provider: sanitized }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to get provider:', message); + res.status(500).json({ error: message }); + } + }); + + // PUT /api/resources/providers/:id - Set API key + router.put('/providers/:id', async (req, res) => { + try { + const { id } = req.params; + const { key } = req.body; + + if (!key || typeof key !== 'string') { + res.status(400).json({ error: 'Missing or invalid field: key (string required)' }); + return; + } + + const providerRegistry = getProviderRegistry(); + await providerRegistry.setKey(id, key); + + res.json({ ok: true, message: `Provider "${id}" API key updated` }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to set provider key:', message); + res.status(500).json({ error: message }); + } + }); + + // DELETE /api/resources/providers/:id - Remove provider + router.delete('/providers/:id', async (req, res) => { + try { + const { id } = req.params; + + const providerRegistry = getProviderRegistry(); + await providerRegistry.removeKey(id); + + res.json({ ok: true, message: `Provider "${id}" removed` }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to remove provider:', message); + res.status(500).json({ error: message }); + } + }); + + // POST /api/resources/providers/refresh - Refresh models cache + router.post('/providers/refresh', async (req, res) => { + try { + const providerRegistry = getProviderRegistry(); + await providerRegistry.refreshModels(); + + res.json({ ok: true, message: 'Provider models cache refreshed' }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to refresh provider models:', message); + res.status(500).json({ error: message }); + } + }); + + // GET /api/resources/providers/:id/models - Get models for provider + router.get('/providers/:id/models', (req, res) => { + try { + const { id } = req.params; + + const providerRegistry = getProviderRegistry(); + const models = providerRegistry.getModels(id); + + res.json({ providerId: id, models }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to get provider models:', message); + res.status(500).json({ error: message }); + } + }); + + // GET /api/resources/providers/models - Get all models + router.get('/providers/models', (_req, res) => { + try { + const providerRegistry = getProviderRegistry(); + const models = providerRegistry.getAllModels(); + + res.json({ models }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to get all models:', message); + res.status(500).json({ error: message }); + } + }); + + // ============================================================================ + // EVENTS ROUTE (SSE) + // ============================================================================ + + // GET /api/resources/events - SSE endpoint for resource change notifications + router.get('/events', (req, res) => { + try { + // Set SSE headers + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering + + // Send initial connection event + res.write(`data: ${JSON.stringify({ type: 'connected', at: Date.now() })}\n\n`); + + let keepalive: ReturnType | null = null; + + // Unified cleanup function + const cleanup = () => { + if (keepalive) { + clearInterval(keepalive); + keepalive = null; + } + res.end(); + }; + + // Subscribe to resource changes + const unsubscribe = onResourceChange((event) => { + try { + res.write(`data: ${JSON.stringify(event)}\n\n`); + } catch (err) { + // Client disconnected + console.error('[Resources API] SSE write error:', err); + unsubscribe(); + cleanup(); + } + }); + + // Handle client disconnect (single event handler) + req.on('close', () => { + unsubscribe(); + cleanup(); + }); + + // Send keepalive comments every 30 seconds + keepalive = setInterval(() => { + try { + res.write(': keepalive\n\n'); + } catch (err) { + console.error('[Resources API] SSE keepalive error:', err); + unsubscribe(); + cleanup(); + } + }, 30000); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Resources API] Failed to setup SSE:', message); + res.status(500).json({ error: message }); + } + }); + + return router; +} diff --git a/src/admin/routes/session.ts b/src/admin/routes/session.ts index 0519ffc..69a0db3 100644 --- a/src/admin/routes/session.ts +++ b/src/admin/routes/session.ts @@ -41,6 +41,7 @@ export interface OpenCodeSessionItem { id: string; title?: string; createdAt?: string; + updatedAt?: number; projectPath?: string; directory?: string; isBound: boolean; @@ -388,7 +389,14 @@ export function createSessionRoutes(): express.Router { } // 尝试获取 OpenCode sessions - let openCodeSessions: Array<{ id: string; title?: string; createdAt?: string; projectPath?: string; directory?: string }> = []; + let openCodeSessions: Array<{ + id: string; + title?: string; + createdAt?: string; + updatedAt?: number; + projectPath?: string; + directory?: string; + }> = []; let openCodeAvailable = false; try { @@ -396,7 +404,12 @@ export function createSessionRoutes(): express.Router { openCodeSessions = sessions.map((s: Session) => ({ id: s.id, title: s.title, - createdAt: s.time?.created ? new Date(s.time.created * 1000).toISOString() : undefined, + createdAt: s.time?.created ? new Date(s.time.created).toISOString() : undefined, + updatedAt: s.time?.updated + ? s.time.updated + : s.time?.created + ? s.time.created + : 0, projectPath: s.directory, directory: s.directory, })); @@ -415,6 +428,7 @@ export function createSessionRoutes(): express.Router { id: s.id, title: s.title, createdAt: s.createdAt, + updatedAt: s.updatedAt, projectPath: s.projectPath, directory: s.directory, isBound: boundTo.length > 0, @@ -434,6 +448,7 @@ export function createSessionRoutes(): express.Router { id: sessionId, title: boundTo[0]?.session.title, createdAt: undefined, + updatedAt: boundTo.reduce((latest, item) => Math.max(latest, item.session.createdAt || 0), 0), projectPath: undefined, directory: boundTo[0]?.session.sessionDirectory || boundTo[0]?.session.resolvedDirectory, isBound: true, @@ -448,6 +463,14 @@ export function createSessionRoutes(): express.Router { } } + sessions.sort((left, right) => { + const updatedDiff = (right.updatedAt || 0) - (left.updatedAt || 0); + if (updatedDiff !== 0) { + return updatedDiff; + } + return left.id.localeCompare(right.id, 'en'); + }); + res.json({ sessions, openCodeAvailable }); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -496,6 +519,7 @@ export function createSessionRoutes(): express.Router { { id: 'qq', name: 'QQ', icon: 'qq' }, { id: 'whatsapp', name: 'WhatsApp', icon: 'whatsapp' }, { id: 'weixin', name: '个人微信', icon: 'weixin' }, + { id: 'dingtalk', name: '钉钉', icon: 'dingtalk' }, ]; res.json({ platforms }); }); @@ -552,6 +576,8 @@ export function createSessionRoutes(): express.Router { // ── 平台聊天获取函数 async function fetchFeishuChats(chats: PlatformChat[], bindingMap: Map): Promise { + const appended = new Set(); + try { const chatIds = await feishuClient.getUserChats(); console.log(`[Session API] 获取飞书群列表: ${chatIds.length} 个`); @@ -568,14 +594,25 @@ async function fetchFeishuChats(chats: PlatformChat[], bindingMap: Map): Promise { @@ -688,4 +725,4 @@ async function fetchQQChats(chats: PlatformChat[], bindingMap: Map { + const resolvedDirectory = resolveWorkspaceDirectory(getWorkspaceDirectoryInput(req)); + if (!resolvedDirectory.ok) { + res.status(resolvedDirectory.status).json({ error: resolvedDirectory.error }); + return; + } + + const resolvedPath = resolveWorkspacePath(resolvedDirectory.directory, req.query.path); + if (!resolvedPath.ok) { + res.status(resolvedPath.status).json({ error: resolvedPath.error }); + return; + } + + try { + const stats = await fs.stat(resolvedPath.absolutePath); + if (!stats.isDirectory()) { + res.status(400).json({ error: '目标路径不是目录。' }); + return; + } + + const limit = parseTreeLimit(req.query.limit); + const items = await fs.readdir(resolvedPath.absolutePath, { withFileTypes: true }); + const visibleItems = items + .filter(item => item.name !== '.git') + .sort((left, right) => { + if (left.isDirectory() !== right.isDirectory()) { + return left.isDirectory() ? -1 : 1; + } + return left.name.localeCompare(right.name, 'zh-Hans-CN'); + }); + + const selectedItems = visibleItems.slice(0, limit); + const entries = await Promise.all( + selectedItems.map(async item => { + const absoluteChildPath = path.join(resolvedPath.absolutePath, item.name); + const relativeChildPath = path.relative(resolvedDirectory.directory, absoluteChildPath).split(path.sep).join('/'); + // 直接使用 Dirent 判定目录/文件: + // - 避免 Windows 上 junction/reparse point 的 stat 解析问题; + // - stat 仅用于取 size / mtimeMs,失败时降级,不影响整体列表。 + const isDirectory = item.isDirectory(); + + let size = 0; + let mtimeMs = 0; + let inaccessible = false; + try { + const childStats = await fs.stat(absoluteChildPath); + size = childStats.isDirectory() ? 0 : childStats.size; + mtimeMs = childStats.mtimeMs; + } catch (statError) { + // Windows 下 C:\PerfLogs、C:\System Volume Information、C:\$Recycle.Bin 等 + // 系统目录对普通进程会抛 EPERM;Linux 下也可能遇到受限目录。 + // 这里降级处理:保留条目,元数据置 0,打印一条 debug 级日志, + // 防止单项失败让整个目录列表 502。 + inaccessible = true; + const reason = statError instanceof Error ? statError.message : String(statError); + console.debug('[Workspace Files] stat 失败已降级:', absoluteChildPath, reason); + } + + return { + name: item.name, + path: relativeChildPath, + type: isDirectory ? 'directory' : 'file', + size, + mtimeMs, + inaccessible, + }; + }) + ); + + res.json({ + directory: resolvedDirectory.directory, + path: resolvedPath.relativePath, + entries, + truncated: visibleItems.length > selectedItems.length, + }); + } catch (error) { + console.error('[Workspace Files] 获取目录失败:', error); + res.status(502).json({ error: errorMessage(error) }); + } + }); + + api.get('/workspace/files/content', async (req, res) => { + const resolvedDirectory = resolveWorkspaceDirectory(getWorkspaceDirectoryInput(req)); + if (!resolvedDirectory.ok) { + res.status(resolvedDirectory.status).json({ error: resolvedDirectory.error }); + return; + } + + const resolvedPath = resolveWorkspacePath(resolvedDirectory.directory, req.query.path); + if (!resolvedPath.ok) { + res.status(resolvedPath.status).json({ error: resolvedPath.error }); + return; + } + + try { + const stats = await fs.stat(resolvedPath.absolutePath); + if (!stats.isFile()) { + res.status(400).json({ error: '目标路径不是文件。' }); + return; + } + + const handle = await fs.open(resolvedPath.absolutePath, 'r'); + try { + const bytesToRead = Math.min(stats.size, FILE_PREVIEW_BYTES); + const buffer = Buffer.alloc(bytesToRead); + await handle.read(buffer, 0, bytesToRead, 0); + const isBinary = detectBinary(buffer); + + res.json({ + directory: resolvedDirectory.directory, + path: resolvedPath.relativePath, + size: stats.size, + truncated: stats.size > FILE_PREVIEW_BYTES, + isBinary, + content: isBinary ? '' : buffer.toString('utf-8'), + }); + } finally { + await handle.close(); + } + } catch (error) { + console.error('[Workspace Files] 读取文件失败:', error); + res.status(502).json({ error: errorMessage(error) }); + } + }); +} diff --git a/src/admin/routes/workspace-git.ts b/src/admin/routes/workspace-git.ts new file mode 100644 index 0000000..81d38aa --- /dev/null +++ b/src/admin/routes/workspace-git.ts @@ -0,0 +1,492 @@ +import express from 'express'; +import { simpleGit } from 'simple-git'; +import type { Response } from 'express'; +import { getWorkspaceDirectoryInput, resolveWorkspaceDirectory } from './workspace-utils.js'; + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim()) { + return error.message; + } + return 'Unknown error'; +} + +function isUntracked(index: string, workingTree: string): boolean { + return index === '?' || workingTree === '?'; +} + +function isConflicted(index: string, workingTree: string): boolean { + return index === 'U' || workingTree === 'U' || (index === 'A' && workingTree === 'A'); +} + +function sendGitError(res: Response, error: unknown, fallbackMessage: string): void { + const message = errorMessage(error); + console.error(`[Workspace Git] ${fallbackMessage}:`, error); + res.status(502).json({ error: message || fallbackMessage }); +} + +function isNoCommitsError(error: unknown): boolean { + const message = errorMessage(error).toLowerCase(); + return message.includes('does not have any commits yet') + || (message.includes('unknown revision') && message.includes('head')) + || (message.includes('bad revision') && message.includes('head')) + || (message.includes('ambiguous argument') && message.includes('head')); +} + +async function ensureGitRepo(git: ReturnType, res: Response): Promise { + const isRepo = await git.checkIsRepo(); + if (!isRepo) { + res.status(409).json({ error: '当前目录不是 Git 仓库。' }); + return false; + } + return true; +} + +async function validateBranchName(git: ReturnType, branch: string): Promise { + await git.raw(['check-ref-format', '--branch', branch]); +} + +async function ensureCleanWorktree(git: ReturnType): Promise { + const status = await git.status(); + if (status.files.length > 0) { + throw new Error('当前工作区有未提交变更,请先提交、暂存或清理后再切换。'); + } +} + +export function registerWorkspaceGitRoutes(api: express.Router): void { + api.post('/workspace/git/init', async (req, res) => { + const resolvedDirectory = resolveWorkspaceDirectory(getWorkspaceDirectoryInput(req)); + if (!resolvedDirectory.ok) { + res.status(resolvedDirectory.status).json({ error: resolvedDirectory.error }); + return; + } + + try { + const git = simpleGit(resolvedDirectory.directory); + await git.init(); + res.json({ ok: true }); + } catch (error) { + sendGitError(res, error, '初始化 Git 仓库失败'); + } + }); + + api.get('/workspace/git/status', async (req, res) => { + const resolvedDirectory = resolveWorkspaceDirectory(getWorkspaceDirectoryInput(req)); + if (!resolvedDirectory.ok) { + res.status(resolvedDirectory.status).json({ error: resolvedDirectory.error }); + return; + } + + try { + const git = simpleGit(resolvedDirectory.directory); + if (!(await ensureGitRepo(git, res))) { + return; + } + + const [status, branches, repositoryRoot, latestCommit] = await Promise.all([ + git.status(), + git.branchLocal(), + git.revparse(['--show-toplevel']), + git.log({ maxCount: 1 }) + .then(log => { + if (!log.latest) { + return undefined; + } + + return { + hash: log.latest.hash, + message: log.latest.message, + authorName: log.latest.author_name, + date: log.latest.date, + }; + }) + .catch(error => { + if (isNoCommitsError(error)) { + return undefined; + } + throw error; + }), + ]); + + const files = status.files.map(file => { + const index = file.index ?? ' '; + const workingTree = file.working_dir ?? ' '; + const untracked = isUntracked(index, workingTree); + const conflicted = isConflicted(index, workingTree); + const staged = !untracked && index.trim().length > 0 && !conflicted; + const modified = !untracked && workingTree.trim().length > 0 && !conflicted; + + return { + path: file.path, + index, + workingTree, + staged, + modified, + untracked, + conflicted, + }; + }); + + const currentBranch = status.current || branches.current || 'HEAD'; + const branchNames = branches.all.includes(currentBranch) || currentBranch === 'HEAD' + ? branches.all + : [currentBranch, ...branches.all]; + + res.json({ + directory: resolvedDirectory.directory, + repositoryRoot: repositoryRoot.trim() || resolvedDirectory.directory, + branch: currentBranch, + tracking: status.tracking || undefined, + ahead: status.ahead ?? 0, + behind: status.behind ?? 0, + clean: files.length === 0, + detached: Boolean(status.detached), + branches: branchNames, + counts: { + staged: files.filter(file => file.staged).length, + modified: files.filter(file => file.modified).length, + untracked: files.filter(file => file.untracked).length, + conflicted: files.filter(file => file.conflicted).length, + }, + files, + lastCommit: latestCommit, + }); + } catch (error) { + sendGitError(res, error, '获取 Git 状态失败'); + } + }); + + api.get('/workspace/git/diff', async (req, res) => { + const resolvedDirectory = resolveWorkspaceDirectory(getWorkspaceDirectoryInput(req)); + if (!resolvedDirectory.ok) { + res.status(resolvedDirectory.status).json({ error: resolvedDirectory.error }); + return; + } + + const filePath = typeof req.query.filePath === 'string' && req.query.filePath.trim() + ? req.query.filePath.trim() + : undefined; + const staged = req.query.staged === 'true'; + + try { + const git = simpleGit(resolvedDirectory.directory); + if (!(await ensureGitRepo(git, res))) { + return; + } + + const args: string[] = []; + if (staged) { + args.push('--cached'); + } + if (filePath) { + args.push('--', filePath); + } + + const diff = await git.diff(args); + res.json({ + directory: resolvedDirectory.directory, + filePath, + staged, + diff, + }); + } catch (error) { + sendGitError(res, error, '获取 Git diff 失败'); + } + }); + + api.post('/workspace/git/commit', async (req, res) => { + const resolvedDirectory = resolveWorkspaceDirectory(getWorkspaceDirectoryInput(req)); + if (!resolvedDirectory.ok) { + res.status(resolvedDirectory.status).json({ error: resolvedDirectory.error }); + return; + } + + const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''; + if (!message) { + res.status(400).json({ error: '缺少 commit message。' }); + return; + } + + // files: optional array of file paths to stage; if omitted, stages all changes + const rawFiles = req.body?.files; + const files: string[] | null = Array.isArray(rawFiles) && rawFiles.length > 0 + ? rawFiles.filter((f: unknown): f is string => typeof f === 'string' && f.trim().length > 0).map((f: string) => f.trim()) + : null; + + try { + const git = simpleGit(resolvedDirectory.directory); + if (!(await ensureGitRepo(git, res))) { + return; + } + + if (files && files.length > 0) { + await git.add(files); + } else { + await git.add(['-A']); + } + const result = await git.commit(message); + + res.json({ + ok: true, + commit: { + hash: result.commit, + branch: result.branch, + summary: result.summary, + }, + }); + } catch (error) { + sendGitError(res, error, '提交变更失败'); + } + }); + + api.post('/workspace/git/pull', async (req, res) => { + const resolvedDirectory = resolveWorkspaceDirectory(getWorkspaceDirectoryInput(req)); + if (!resolvedDirectory.ok) { + res.status(resolvedDirectory.status).json({ error: resolvedDirectory.error }); + return; + } + + try { + const git = simpleGit(resolvedDirectory.directory); + if (!(await ensureGitRepo(git, res))) { + return; + } + + const result = await git.pull(); + res.json({ ok: true, result }); + } catch (error) { + sendGitError(res, error, '拉取远端变更失败'); + } + }); + + api.post('/workspace/git/push', async (req, res) => { + const resolvedDirectory = resolveWorkspaceDirectory(getWorkspaceDirectoryInput(req)); + if (!resolvedDirectory.ok) { + res.status(resolvedDirectory.status).json({ error: resolvedDirectory.error }); + return; + } + + try { + const git = simpleGit(resolvedDirectory.directory); + if (!(await ensureGitRepo(git, res))) { + return; + } + + const result = await git.push(); + res.json({ ok: true, result }); + } catch (error) { + sendGitError(res, error, '推送远端失败'); + } + }); + + api.post('/workspace/git/checkout', async (req, res) => { + const resolvedDirectory = resolveWorkspaceDirectory(getWorkspaceDirectoryInput(req)); + if (!resolvedDirectory.ok) { + res.status(resolvedDirectory.status).json({ error: resolvedDirectory.error }); + return; + } + + const branch = typeof req.body?.branch === 'string' ? req.body.branch.trim() : ''; + const ref = typeof req.body?.ref === 'string' ? req.body.ref.trim() : ''; + const target = branch || ref; + const detach = req.body?.detach === true; + + if (!target) { + res.status(400).json({ error: '缺少 branch/ref。' }); + return; + } + + try { + const git = simpleGit(resolvedDirectory.directory); + if (!(await ensureGitRepo(git, res))) { + return; + } + + await ensureCleanWorktree(git); + + if (detach) { + await git.checkout(['--detach', target]); + res.json({ ok: true, ref: target, detached: true }); + return; + } + + const branches = await git.branchLocal(); + if (!branches.all.includes(target)) { + res.status(404).json({ error: `分支不存在: ${target}` }); + return; + } + + await git.checkout(target); + res.json({ ok: true, branch: target, detached: false }); + } catch (error) { + sendGitError(res, error, '切换分支失败'); + } + }); + + api.post('/workspace/git/branch/create', async (req, res) => { + const resolvedDirectory = resolveWorkspaceDirectory(getWorkspaceDirectoryInput(req)); + if (!resolvedDirectory.ok) { + res.status(resolvedDirectory.status).json({ error: resolvedDirectory.error }); + return; + } + + const branch = typeof req.body?.branch === 'string' ? req.body.branch.trim() : ''; + const switchAfterCreate = req.body?.switchAfterCreate !== false; + if (!branch) { + res.status(400).json({ error: '缺少 branch。' }); + return; + } + + try { + const git = simpleGit(resolvedDirectory.directory); + if (!(await ensureGitRepo(git, res))) { + return; + } + + await validateBranchName(git, branch); + + const branches = await git.branchLocal(); + if (branches.all.includes(branch)) { + res.status(409).json({ error: `分支已存在: ${branch}` }); + return; + } + + if (switchAfterCreate) { + await git.checkoutLocalBranch(branch); + } else { + await git.raw(['branch', branch]); + } + + res.json({ + ok: true, + branch, + switched: switchAfterCreate, + }); + } catch (error) { + sendGitError(res, error, '创建分支失败'); + } + }); + + api.get('/workspace/git/log', async (req, res) => { + const resolvedDirectory = resolveWorkspaceDirectory(getWorkspaceDirectoryInput(req)); + if (!resolvedDirectory.ok) { + res.status(resolvedDirectory.status).json({ error: resolvedDirectory.error }); + return; + } + + const rawLimit = typeof req.query.limit === 'string' ? Number.parseInt(req.query.limit, 10) : 30; + const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 100) : 30; + + try { + const git = simpleGit(resolvedDirectory.directory); + if (!(await ensureGitRepo(git, res))) { + return; + } + + let log; + try { + log = await git.log({ maxCount: limit }); + } catch (error) { + if (isNoCommitsError(error)) { + res.json({ entries: [] }); + return; + } + throw error; + } + + res.json({ + entries: log.all.map(entry => ({ + sha: entry.hash, + message: entry.message, + authorName: entry.author_name, + authorEmail: entry.author_email, + date: entry.date, + })), + }); + } catch (error) { + sendGitError(res, error, '获取历史版本失败'); + } + }); + + api.get('/workspace/git/log/detail', async (req, res) => { + const resolvedDirectory = resolveWorkspaceDirectory(getWorkspaceDirectoryInput(req)); + if (!resolvedDirectory.ok) { + res.status(resolvedDirectory.status).json({ error: resolvedDirectory.error }); + return; + } + + const sha = typeof req.query.sha === 'string' ? req.query.sha.trim() : ''; + if (!sha) { + res.status(400).json({ error: '缺少 sha。' }); + return; + } + + if (!/^[0-9a-f]{7,40}$/i.test(sha)) { + res.status(400).json({ error: '非法 commit sha。' }); + return; + } + + try { + const git = simpleGit(resolvedDirectory.directory); + if (!(await ensureGitRepo(git, res))) { + return; + } + + const format = '%H%n%an%n%ae%n%aI%n%s'; + const [metaOutput, statsOutput, diffOutput] = await Promise.all([ + git.raw(['show', '-s', `--format=${format}`, sha]), + git.show(['--no-patch', '--stat', '--format=', sha]), + git.show(['--format=', '--patch', sha]), + ]); + + const metaLines = metaOutput.trim().split('\n'); + res.json({ + sha: metaLines[0] || sha, + authorName: metaLines[1] || '', + authorEmail: metaLines[2] || '', + date: metaLines[3] || '', + message: metaLines.slice(4).join('\n').trim(), + stats: statsOutput.trim(), + diff: diffOutput, + }); + } catch (error) { + sendGitError(res, error, '获取历史版本详情失败'); + } + }); + + api.post('/workspace/git/branch/delete', async (req, res) => { + const resolvedDirectory = resolveWorkspaceDirectory(getWorkspaceDirectoryInput(req)); + if (!resolvedDirectory.ok) { + res.status(resolvedDirectory.status).json({ error: resolvedDirectory.error }); + return; + } + + const branch = typeof req.body?.branch === 'string' ? req.body.branch.trim() : ''; + if (!branch) { + res.status(400).json({ error: '缺少 branch。' }); + return; + } + + try { + const git = simpleGit(resolvedDirectory.directory); + if (!(await ensureGitRepo(git, res))) { + return; + } + + const branches = await git.branchLocal(); + if (!branches.all.includes(branch)) { + res.status(404).json({ error: `分支不存在: ${branch}` }); + return; + } + + if (branches.current === branch) { + res.status(409).json({ error: '不能删除当前所在分支。请先切换到其它分支。' }); + return; + } + + await git.deleteLocalBranch(branch, false); + res.json({ ok: true, branch }); + } catch (error) { + sendGitError(res, error, '删除分支失败'); + } + }); +} diff --git a/src/admin/routes/workspace-terminal.ts b/src/admin/routes/workspace-terminal.ts new file mode 100644 index 0000000..6d00b20 --- /dev/null +++ b/src/admin/routes/workspace-terminal.ts @@ -0,0 +1,435 @@ +import crypto from 'node:crypto'; +import express from 'express'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import type { Response } from 'express'; +import { getWorkspaceDirectoryInput, resolveWorkspaceDirectory } from './workspace-utils.js'; + +const COMMAND_TIMEOUT_MS = 120_000; +const SESSION_IDLE_TTL_MS = 30 * 60 * 1000; +const CWD_PREFIX = '__OPENCODE_BRIDGE_CWD__'; +const EXIT_PREFIX = '__OPENCODE_BRIDGE_EXIT__'; +const BOOTSTRAP_PREFIX = '__OPENCODE_BRIDGE_BOOTSTRAP__'; + +type PendingCommand = { + id: string; + stdout: string; + stderr: string; + resolve: (result: { ok: boolean; exitCode: number; stdout: string; stderr: string; cwd: string }) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; +}; + +type TerminalSession = { + id: string; + directory: string; + currentDirectory: string; + shellLabel: string; + process: ChildProcessWithoutNullStreams; + pending: PendingCommand | null; + ready: Promise; + readyResolve: () => void; + readyReject: (error: Error) => void; + bootstrapId: string; + bootstrapStdout: string; + bootstrapStderr: string; + bootstrapPending: boolean; + lastUsedAt: number; +}; + +function sendTerminalError(res: Response, error: unknown, fallbackMessage: string, status = 502): void { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(`[Workspace Terminal] ${fallbackMessage}:`, error); + res.status(status).json({ error: message || fallbackMessage }); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function normalizeOutput(value: string): string { + return value + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n'); +} + +function getShellConfig(): { shell: string; args: string[]; label: string } { + if (process.platform === 'win32') { + return { + shell: 'powershell.exe', + args: ['-NoLogo', '-NoProfile', '-Command', '-'], + label: 'powershell', + }; + } + + return { + shell: '/bin/bash', + args: ['-l'], + label: 'bash', + }; +} + +function buildBootstrapCommand(bootstrapId: string): string | null { + if (process.platform === 'win32') { + return null; + } + + return `export PS1='\\$ ' +shopt -s expand_aliases >/dev/null 2>&1 || true +for __ocb_file in /etc/bash.bashrc /etc/bashrc "$HOME/.bashrc" "$HOME/.bash_aliases"; do + [ -f "$__ocb_file" ] || continue + . "$__ocb_file" >/dev/null 2>&1 || true +done +alias ll >/dev/null 2>&1 || alias ll='ls -alF' +alias la >/dev/null 2>&1 || alias la='ls -A' +alias l >/dev/null 2>&1 || alias l='ls -CF' +printf '%s%s__ready\n' '${BOOTSTRAP_PREFIX}' '${bootstrapId}' +`; +} + +function buildWrappedCommand(commandId: string, command: string): string { + if (process.platform === 'win32') { + return `${command} +$__ocb_status = if ($?) { 0 } elseif ($LASTEXITCODE -ne $null) { $LASTEXITCODE } else { 1 } +Write-Output "${CWD_PREFIX}${commandId}__$((Get-Location).Path)" +Write-Output "${EXIT_PREFIX}${commandId}__$__ocb_status" +`; + } + + return `${command} +__ocb_status=$? +printf '%s%s__%s\n' '${CWD_PREFIX}' '${commandId}' "$PWD" +printf '%s%s__%s\n' '${EXIT_PREFIX}' '${commandId}' "$__ocb_status" +`; +} + +class WorkspaceTerminalManager { + private readonly sessions = new Map(); + + constructor() { + const cleanupTimer = setInterval(() => this.cleanupIdleSessions(), 5 * 60 * 1000); + cleanupTimer.unref?.(); + } + + open(directory: string): { sessionId: string; shell: string; cwd: string } { + const { shell, args, label } = getShellConfig(); + const childProcess = spawn(shell, args, { + cwd: directory, + env: { + ...process.env, + TERM: process.env.TERM || 'xterm-256color', + }, + stdio: 'pipe', + windowsHide: true, + }); + + const sessionId = crypto.randomUUID(); + const bootstrapId = crypto.randomUUID(); + let readyResolve!: () => void; + let readyReject!: (error: Error) => void; + const ready = new Promise((resolve, reject) => { + readyResolve = resolve; + readyReject = reject; + }); + const session: TerminalSession = { + id: sessionId, + directory, + currentDirectory: directory, + shellLabel: label, + process: childProcess, + pending: null, + ready, + readyResolve, + readyReject, + bootstrapId, + bootstrapStdout: '', + bootstrapStderr: '', + bootstrapPending: process.platform !== 'win32', + lastUsedAt: Date.now(), + }; + + childProcess.stdout.setEncoding('utf8'); + childProcess.stderr.setEncoding('utf8'); + + childProcess.stdout.on('data', chunk => { + this.handleStdout(session, String(chunk)); + }); + + childProcess.stderr.on('data', chunk => { + this.handleStderr(session, String(chunk)); + }); + + childProcess.on('error', error => { + this.failSession(session.id, error instanceof Error ? error : new Error(String(error))); + }); + + childProcess.on('exit', (code, signal) => { + const reason = code !== null + ? `shell 已退出(exit code ${code})` + : `shell 已退出(signal ${signal || 'unknown'})`; + this.failSession(session.id, new Error(reason)); + }); + + this.sessions.set(sessionId, session); + this.startBootstrap(session); + return { + sessionId, + shell: label, + cwd: directory, + }; + } + + async execute(sessionId: string, command: string): Promise<{ ok: boolean; exitCode: number; stdout: string; stderr: string; cwd: string }> { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error('终端会话不存在或已关闭'); + } + + await session.ready; + + if (session.pending) { + throw new Error('终端正在执行上一条命令,请稍后再试'); + } + + const trimmed = command.trim(); + if (!trimmed) { + throw new Error('缺少命令'); + } + + session.lastUsedAt = Date.now(); + + return new Promise((resolve, reject) => { + const commandId = crypto.randomUUID(); + const timeout = setTimeout(() => { + this.failSession(sessionId, new Error(`命令执行超时(${COMMAND_TIMEOUT_MS / 1000} 秒),已关闭当前终端会话`)); + }, COMMAND_TIMEOUT_MS); + + session.pending = { + id: commandId, + stdout: '', + stderr: '', + resolve, + reject, + timeout, + }; + + session.process.stdin.write(buildWrappedCommand(commandId, command)); + }); + } + + close(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (!session) { + return; + } + + const pending = session.pending; + if (pending) { + clearTimeout(pending.timeout); + pending.reject(new Error('终端会话已关闭')); + session.pending = null; + } + + session.process.kill(); + this.sessions.delete(sessionId); + } + + private cleanupIdleSessions(): void { + const now = Date.now(); + for (const [sessionId, session] of this.sessions) { + if (now - session.lastUsedAt > SESSION_IDLE_TTL_MS) { + this.close(sessionId); + } + } + } + + private handleStdout(session: TerminalSession, chunk: string): void { + if (session.bootstrapPending) { + session.bootstrapStdout += chunk; + const marker = `${BOOTSTRAP_PREFIX}${session.bootstrapId}__ready`; + const markerIndex = session.bootstrapStdout.indexOf(marker); + if (markerIndex >= 0) { + session.bootstrapPending = false; + session.bootstrapStdout = session.bootstrapStdout.slice(markerIndex + marker.length); + session.readyResolve(); + } + return; + } + + const pending = session.pending; + if (!pending) { + return; + } + + pending.stdout += chunk; + const parsed = this.tryExtractCommandResult(pending.stdout, pending.id, session.currentDirectory); + if (!parsed) { + return; + } + + pending.stdout = parsed.remainder; + session.currentDirectory = parsed.cwd; + session.lastUsedAt = Date.now(); + this.resolvePending(session, { + ok: true, + exitCode: parsed.exitCode, + stdout: parsed.stdout, + stderr: normalizeOutput(pending.stderr), + cwd: parsed.cwd, + }); + } + + private handleStderr(session: TerminalSession, chunk: string): void { + if (session.bootstrapPending) { + session.bootstrapStderr += chunk; + return; + } + + if (!session.pending) { + return; + } + + session.pending.stderr += chunk; + } + + private tryExtractCommandResult( + rawStdout: string, + commandId: string, + fallbackCwd: string + ): { exitCode: number; stdout: string; cwd: string; remainder: string } | null { + const exitPattern = new RegExp(`${escapeRegExp(EXIT_PREFIX)}${escapeRegExp(commandId)}__(-?\\d+)(?:\\r?\\n|$)`); + const exitMatch = exitPattern.exec(rawStdout); + if (!exitMatch) { + return null; + } + + const exitEnd = exitMatch.index + exitMatch[0].length; + const relevantOutput = rawStdout.slice(0, exitEnd); + const remainder = rawStdout.slice(exitEnd); + + const cwdPattern = new RegExp(`${escapeRegExp(CWD_PREFIX)}${escapeRegExp(commandId)}__(.*?)(?:\\r?\\n|$)`); + const cwdMatch = cwdPattern.exec(relevantOutput); + const cwd = cwdMatch?.[1]?.trim() || fallbackCwd; + const stdout = normalizeOutput( + relevantOutput + .replace(cwdPattern, '') + .replace(exitPattern, '') + ).replace(/^\n+/, ''); + + return { + exitCode: Number.parseInt(exitMatch[1], 10) || 0, + stdout, + cwd, + remainder, + }; + } + + private resolvePending( + session: TerminalSession, + result: { ok: boolean; exitCode: number; stdout: string; stderr: string; cwd: string } + ): void { + const pending = session.pending; + if (!pending) { + return; + } + + clearTimeout(pending.timeout); + session.pending = null; + pending.resolve(result); + } + + private failSession(sessionId: string, error: Error): void { + const session = this.sessions.get(sessionId); + if (!session) { + return; + } + + if (session.bootstrapPending) { + session.bootstrapPending = false; + session.readyReject(error); + } + + const pending = session.pending; + if (pending) { + clearTimeout(pending.timeout); + session.pending = null; + pending.reject(error); + } + + session.process.kill(); + this.sessions.delete(sessionId); + } + + private startBootstrap(session: TerminalSession): void { + const bootstrapCommand = buildBootstrapCommand(session.bootstrapId); + if (!bootstrapCommand) { + session.bootstrapPending = false; + session.readyResolve(); + return; + } + + session.process.stdin.write(bootstrapCommand, error => { + if (error) { + this.failSession(session.id, error instanceof Error ? error : new Error(String(error))); + } + }); + } +} + +const terminalManager = new WorkspaceTerminalManager(); + +export function registerWorkspaceTerminalRoutes(api: express.Router): void { + api.post('/workspace/terminal/open', async (req, res) => { + const resolvedDirectory = resolveWorkspaceDirectory(getWorkspaceDirectoryInput(req)); + if (!resolvedDirectory.ok) { + res.status(resolvedDirectory.status).json({ error: resolvedDirectory.error }); + return; + } + + try { + const session = terminalManager.open(resolvedDirectory.directory); + res.json({ + ok: true, + sessionId: session.sessionId, + shell: session.shell, + cwd: session.cwd, + }); + } catch (error) { + sendTerminalError(res, error, '创建终端会话失败'); + } + }); + + api.post('/workspace/terminal/execute', async (req, res) => { + const sessionId = typeof req.body?.sessionId === 'string' ? req.body.sessionId.trim() : ''; + const command = typeof req.body?.command === 'string' ? req.body.command : ''; + + if (!sessionId) { + res.status(400).json({ error: '缺少 sessionId' }); + return; + } + + if (!command.trim()) { + res.status(400).json({ error: '缺少命令' }); + return; + } + + try { + const result = await terminalManager.execute(sessionId, command); + res.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : '命令执行失败'; + const status = /不存在|已关闭/.test(message) ? 404 : message.includes('上一条命令') ? 409 : 502; + sendTerminalError(res, error, '命令执行失败', status); + } + }); + + api.post('/workspace/terminal/close', async (req, res) => { + const sessionId = typeof req.body?.sessionId === 'string' ? req.body.sessionId.trim() : ''; + if (!sessionId) { + res.status(400).json({ error: '缺少 sessionId' }); + return; + } + + terminalManager.close(sessionId); + res.json({ ok: true }); + }); +} diff --git a/src/admin/routes/workspace-utils.ts b/src/admin/routes/workspace-utils.ts new file mode 100644 index 0000000..9aeb3af --- /dev/null +++ b/src/admin/routes/workspace-utils.ts @@ -0,0 +1,132 @@ +import path from 'node:path'; +import type { Request } from 'express'; +import { DirectoryPolicy, type DirectoryErrorCode } from '../../utils/directory-policy.js'; + +export type WorkspaceDirectoryResult = + | { ok: true; directory: string } + | { ok: false; status: number; error: string }; + +export type WorkspacePathResult = + | { ok: true; relativePath: string; absolutePath: string } + | { ok: false; status: number; error: string }; + +function mapDirectoryErrorStatus(code: DirectoryErrorCode): number { + switch (code) { + case 'not_found': + case 'not_directory': + return 404; + case 'not_accessible': + case 'not_allowed': + case 'dangerous_path': + case 'realpath_not_allowed': + case 'git_root_not_allowed': + case 'explicit_requires_allowlist': + return 403; + default: + return 400; + } +} + +export function getWorkspaceDirectoryInput(req: Request): unknown { + if (typeof req.query.directory === 'string') { + return req.query.directory; + } + + if (req.body && typeof req.body === 'object' && 'directory' in req.body) { + return (req.body as Record).directory; + } + + return undefined; +} + +export function resolveWorkspaceDirectory(rawDirectory: unknown): WorkspaceDirectoryResult { + const explicitDirectory = + typeof rawDirectory === 'string' && rawDirectory.trim() + ? rawDirectory.trim() + : undefined; + + // AI 工作区脱钩 ALLOWED_DIRECTORIES 白名单:Web 管理面板已认证, + // 白名单仅约束平台接入(telegram/discord/qq/wecom 等外部消息入口)。 + // 这里仍保留格式校验、危险路径拦截、存在性/可访问性与 realpath 规范化。 + const resolved = DirectoryPolicy.resolve( + explicitDirectory + ? { explicitDirectory, scope: 'workspace' } + : { scope: 'workspace' } + ); + + if (!resolved.ok) { + return { + ok: false, + status: mapDirectoryErrorStatus(resolved.code), + error: resolved.userMessage, + }; + } + + if (!resolved.directory) { + return { + ok: false, + status: 400, + error: '缺少工作目录,请先为会话绑定目录,或在配置中设置 DEFAULT_WORK_DIRECTORY。', + }; + } + + return { + ok: true, + directory: resolved.directory, + }; +} + +export function resolveWorkspacePath(rootDirectory: string, rawPath: unknown): WorkspacePathResult { + if (rawPath === undefined || rawPath === null || rawPath === '') { + return { + ok: true, + relativePath: '', + absolutePath: rootDirectory, + }; + } + + if (typeof rawPath !== 'string') { + return { + ok: false, + status: 400, + error: 'path 参数必须是字符串。', + }; + } + + const trimmedPath = rawPath.trim(); + if (!trimmedPath) { + return { + ok: true, + relativePath: '', + absolutePath: rootDirectory, + }; + } + + if (path.isAbsolute(trimmedPath)) { + return { + ok: false, + status: 400, + error: 'path 必须是相对路径。', + }; + } + + const absolutePath = path.resolve(rootDirectory, trimmedPath); + const relativePath = path.relative(rootDirectory, absolutePath); + const escapesRoot = + relativePath.startsWith('..') + || path.isAbsolute(relativePath); + + if (escapesRoot) { + return { + ok: false, + status: 400, + error: '路径超出工作目录范围。', + }; + } + + return { + ok: true, + relativePath: relativePath.split(path.sep).join('/'), + absolutePath, + }; +} diff --git a/src/cli/commands/resources.ts b/src/cli/commands/resources.ts new file mode 100644 index 0000000..35e50aa --- /dev/null +++ b/src/cli/commands/resources.ts @@ -0,0 +1,578 @@ +/** + * CLI Resource Management Commands + * + * Implements `bridge resource` subcommands for managing: + * - Skills (list, create, edit, delete, enable, disable) + * - MCP Servers (list, add, edit, delete, enable, disable) + * - Agents (list, create, edit, delete) + * - Model Providers (list, set-key, remove-key, models, refresh) + */ + +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs/promises'; + +import { skillRegistry } from '../../services/resources/skills/registry.js'; +import { getMCPRegistry } from '../../services/resources/mcp/manager.js'; +import { getAgentRegistry } from '../../services/resources/agents/manager.js'; +import { getProviderRegistry } from '../../services/resources/providers/manager.js'; +import { initResourceSystem } from '../../services/resources/index.js'; +import type { ResourceScope } from '../../services/resources/types.js'; + +/** + * Parse scope from string or default to 'project' + */ +function parseScope(scope?: string): ResourceScope { + if (scope === 'user' || scope === 'project') { + return scope; + } + return 'project'; +} + +/** + * Launch editor for file + */ +async function launchEditor(filePath: string): Promise { + const editor = process.env.EDITOR || process.env.VISUAL || 'vim'; + const editorArgs = editor.split(' '); + const editorCmd = editorArgs[0]; + const editorParams = [...editorArgs.slice(1), filePath]; + + console.log(`Launching editor: ${editor} ${filePath}`); + + await new Promise((resolve, reject) => { + const child = spawn(editorCmd, editorParams, { + stdio: 'inherit', + }); + + child.on('exit', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Editor exited with code ${code}`)); + } + }); + + child.on('error', (err) => { + reject(err); + }); + }); +} + +/** + * Ensure resource system is initialized + */ +async function ensureInitialized(): Promise { + const { isResourceSystemInitialized } = await import('../../services/resources/index.js'); + if (!isResourceSystemInitialized()) { + await initResourceSystem(); + } +} + +// ============================================================================ +// Skills Commands +// ============================================================================ + +/** + * List all skills + */ +export async function skillList(): Promise { + await ensureInitialized(); + const skills = skillRegistry.list(); + + if (skills.length === 0) { + console.log('No skills found.'); + return; + } + + console.log('\nSkills:\n'); + console.table( + skills.map((s) => ({ + Name: s.name, + Scope: s.scope, + Status: s.status, + Enabled: s.enabled ? '✓' : '✗', + Shadowed: s.shadowed ? '(shadowed)' : '', + Description: s.description || '', + })) + ); +} + +/** + * Create a new skill + */ +export async function skillCreate( + name: string, + options: { + markdown?: string; + scope?: string; + description?: string; + enabled?: boolean; + } +): Promise { + await ensureInitialized(); + + const scope = parseScope(options.scope); + + // Read markdown from file if provided + let body = ''; + if (options.markdown) { + body = await fs.readFile(options.markdown, 'utf-8'); + } + + const skill = skillRegistry.create({ + name, + scope, + frontmatter: { + description: options.description || '', + version: '1.0.0', + enabled: options.enabled ?? true, + }, + body, + }); + + console.log(`✓ Skill "${name}" created at ${skill.dir}`); +} + +/** + * Edit a skill (opens in editor) + */ +export async function skillEdit(name: string, options: { scope?: string }): Promise { + await ensureInitialized(); + + const scope = options.scope ? parseScope(options.scope) : undefined; + const skill = skillRegistry.get(name, scope); + + if (!skill) { + console.error(`Skill "${name}" not found${scope ? ` in ${scope} scope` : ''}`); + process.exit(1); + } + + await launchEditor(skill.filePath); + console.log(`✓ Skill "${name}" edited. Changes will be hot-reloaded.`); +} + +/** + * Delete a skill + */ +export async function skillDelete(name: string, options: { scope?: string }): Promise { + await ensureInitialized(); + + const scope = options.scope ? parseScope(options.scope) : undefined; + skillRegistry.delete(name, scope); + + console.log(`✓ Skill "${name}" deleted.`); +} + +/** + * Enable a skill + */ +export async function skillEnable(name: string, options: { scope?: string }): Promise { + await ensureInitialized(); + + const scope = options.scope ? parseScope(options.scope) : undefined; + skillRegistry.toggle(name, true, scope); + + console.log(`✓ Skill "${name}" enabled.`); +} + +/** + * Disable a skill + */ +export async function skillDisable(name: string, options: { scope?: string }): Promise { + await ensureInitialized(); + + const scope = options.scope ? parseScope(options.scope) : undefined; + skillRegistry.toggle(name, false, scope); + + console.log(`✓ Skill "${name}" disabled.`); +} + +// ============================================================================ +// MCP Commands +// ============================================================================ + +/** + * List all MCP servers + */ +export async function mcpList(): Promise { + await ensureInitialized(); + const registry = getMCPRegistry(); + const servers = registry.list(); + + if (servers.length === 0) { + console.log('No MCP servers found.'); + return; + } + + console.log('\nMCP Servers:\n'); + console.table( + servers.map((s) => ({ + Name: s.name, + Scope: s.scope, + Transport: s.transport, + Enabled: s.enabled ? '✓' : '✗', + Valid: s.valid ? '✓' : '✗', + Shadowed: s.shadowed ? '(shadowed)' : '', + Description: s.description || '', + Error: s.error || '', + })) + ); +} + +/** + * Add a new MCP server + */ +export async function mcpAdd( + name: string, + options: { + transport: 'stdio' | 'sse' | 'http'; + command?: string; + url?: string; + scope?: string; + description?: string; + enabled?: boolean; + } +): Promise { + await ensureInitialized(); + + const scope = parseScope(options.scope); + const registry = getMCPRegistry(); + + let input: any = { + description: options.description, + enabled: options.enabled ?? true, + transport: options.transport, + }; + + if (options.transport === 'stdio') { + if (!options.command) { + console.error('stdio transport requires --command'); + process.exit(1); + } + input.command = options.command; + } else { + if (!options.url) { + console.error(`${options.transport} transport requires --url`); + process.exit(1); + } + input.url = options.url; + } + + const config = await registry.create(name, input, scope); + console.log(`✓ MCP server "${name}" created at ${scope} layer`); + console.log(` Transport: ${config.transport}`); +} + +/** + * Edit an MCP server (opens in editor) + */ +export async function mcpEdit(name: string, options: { scope?: string }): Promise { + await ensureInitialized(); + + const scope = options.scope ? parseScope(options.scope) : undefined; + const registry = getMCPRegistry(); + const config = registry.get(name, scope); + + if (!config) { + console.error(`MCP server "${name}" not found${scope ? ` in ${scope} scope` : ''}`); + process.exit(1); + } + + const dir = (await import('../../services/resources/paths.js')).getResourceDir('mcp', scope || 'project'); + const filePath = path.join(dir, `${name}.json`); + + await launchEditor(filePath); + console.log(`✓ MCP server "${name}" edited. Changes will be hot-reloaded.`); +} + +/** + * Delete an MCP server + */ +export async function mcpDelete(name: string, options: { scope?: string }): Promise { + await ensureInitialized(); + + const scope = options.scope ? parseScope(options.scope) : undefined; + const registry = getMCPRegistry(); + await registry.delete(name, scope || 'project'); + + console.log(`✓ MCP server "${name}" deleted.`); +} + +/** + * Enable an MCP server + */ +export async function mcpEnable(name: string, options: { scope?: string }): Promise { + await ensureInitialized(); + + const scope = options.scope ? parseScope(options.scope) : undefined; + const registry = getMCPRegistry(); + await registry.toggle(name, true, scope); + + console.log(`✓ MCP server "${name}" enabled.`); +} + +/** + * Disable an MCP server + */ +export async function mcpDisable(name: string, options: { scope?: string }): Promise { + await ensureInitialized(); + + const scope = options.scope ? parseScope(options.scope) : undefined; + const registry = getMCPRegistry(); + await registry.toggle(name, false, scope); + + console.log(`✓ MCP server "${name}" disabled.`); +} + +// ============================================================================ +// Agent Commands +// ============================================================================ + +/** + * List all agents + */ +export async function agentList(): Promise { + await ensureInitialized(); + const registry = getAgentRegistry(); + const agents = registry.list(); + + if (agents.length === 0) { + console.log('No agents found.'); + return; + } + + console.log('\nAgents:\n'); + console.table( + agents.map((a) => ({ + Name: a.name, + Scope: a.scope, + Mode: a.mode || '-', + Enabled: a.enabled ? '✓' : '✗', + Valid: a.valid ? '✓' : '✗', + Shadowed: a.shadowed ? '(shadowed)' : '', + Description: a.description || '', + Error: a.error || '', + })) + ); +} + +/** + * Create a new agent + */ +export async function agentCreate( + name: string, + options: { + mode?: 'primary' | 'subagent' | 'all'; + prompt?: string; + scope?: string; + description?: string; + enabled?: boolean; + } +): Promise { + await ensureInitialized(); + + const scope = parseScope(options.scope); + const registry = getAgentRegistry(); + + const config = await registry.create(name, { + description: options.description, + mode: options.mode, + prompt: options.prompt, + enabled: options.enabled ?? true, + }, scope); + + console.log(`✓ Agent "${name}" created at ${scope} layer`); + if (config.mode) { + console.log(` Mode: ${config.mode}`); + } +} + +/** + * Edit an agent (opens in editor) + */ +export async function agentEdit(name: string, options: { scope?: string }): Promise { + await ensureInitialized(); + + const scope = options.scope ? parseScope(options.scope) : undefined; + const registry = getAgentRegistry(); + const config = registry.get(name, scope); + + if (!config) { + console.error(`Agent "${name}" not found${scope ? ` in ${scope} scope` : ''}`); + process.exit(1); + } + + const dir = (await import('../../services/resources/paths.js')).getResourceDir('agents', scope || 'project'); + const filePath = path.join(dir, `${name}.json`); + + await launchEditor(filePath); + console.log(`✓ Agent "${name}" edited. Changes will be hot-reloaded.`); +} + +/** + * Delete an agent + */ +export async function agentDelete(name: string, options: { scope?: string }): Promise { + await ensureInitialized(); + + const scope = options.scope ? parseScope(options.scope) : undefined; + const registry = getAgentRegistry(); + await registry.delete(name, scope || 'project'); + + console.log(`✓ Agent "${name}" deleted.`); +} + +// ============================================================================ +// Model/Provider Commands +// ============================================================================ + +/** + * List all providers + */ +export async function modelProviders(): Promise { + await ensureInitialized(); + const registry = getProviderRegistry(); + const providers = registry.list(); + + if (providers.length === 0) { + console.log('No providers found.'); + return; + } + + console.log('\nProviders:\n'); + console.table( + providers.map((p) => ({ + ID: p.providerId, + Name: p.displayName || p.providerId, + Type: p.type, + Configured: p.configured ? '✓' : '✗', + Editable: p.editable ? '✓' : '✗', + })) + ); +} + +/** + * Set API key for a provider + */ +export async function modelSetKey(providerId: string, apiKey: string): Promise { + await ensureInitialized(); + const registry = getProviderRegistry(); + + await registry.setKey(providerId, apiKey); + console.log(`✓ API key set for provider "${providerId}"`); +} + +/** + * Remove API key for a provider + */ +export async function modelRemoveKey(providerId: string): Promise { + await ensureInitialized(); + const registry = getProviderRegistry(); + + await registry.removeKey(providerId); + console.log(`✓ API key removed for provider "${providerId}"`); +} + +/** + * Show models for a provider or all models + */ +export async function modelModels(providerId?: string): Promise { + await ensureInitialized(); + const registry = getProviderRegistry(); + + if (providerId) { + const models = registry.getModels(providerId); + if (models.length === 0) { + console.log(`No models found for provider "${providerId}"`); + return; + } + + console.log(`\nModels for ${providerId}:\n`); + models.forEach((m) => console.log(` ${m}`)); + } else { + const allModels = registry.getAllModels(); + if (allModels.length === 0) { + console.log('No models found. Run "opencode-bridge resource model refresh" to fetch models.'); + return; + } + + console.log('\nAll Models:\n'); + console.table( + allModels.map((m) => ({ + Provider: m.providerId, + Model: m.modelId, + Full: m.fullName, + })) + ); + } +} + +/** + * Refresh models cache from OpenCode + */ +export async function modelRefresh(): Promise { + await ensureInitialized(); + const registry = getProviderRegistry(); + + console.log('Refreshing models cache from OpenCode...'); + await registry.refreshModels(); + console.log('✓ Models cache refreshed'); + + const allModels = registry.getAllModels(); + const totalProviders = new Set(allModels.map((m) => m.providerId)).size; + console.log(` Total: ${allModels.length} models from ${totalProviders} providers`); +} + +/** + * Login to a provider via OAuth using opencode providers login + */ +export async function modelLogin(providerId: string): Promise { + console.log(`Starting OAuth login flow for provider: ${providerId}`); + console.log('This will open a browser window for authentication...'); + + await new Promise((resolve, reject) => { + const child = spawn('opencode', ['providers', 'login', providerId], { + stdio: 'inherit', + }); + + child.on('exit', (code) => { + if (code === 0) { + console.log(`✓ Successfully logged in to provider "${providerId}"`); + resolve(); + } else { + reject(new Error(`Login failed with exit code ${code}`)); + } + }); + + child.on('error', (err) => { + reject(new Error(`Failed to start login process: ${err.message}`)); + }); + }); +} + +/** + * Logout from a provider via OAuth using opencode providers logout + */ +export async function modelLogout(providerId: string): Promise { + console.log(`Logging out from provider: ${providerId}`); + + await new Promise((resolve, reject) => { + const child = spawn('opencode', ['providers', 'logout', providerId], { + stdio: 'inherit', + }); + + child.on('exit', (code) => { + if (code === 0) { + console.log(`✓ Successfully logged out from provider "${providerId}"`); + resolve(); + } else { + reject(new Error(`Logout failed with exit code ${code}`)); + } + }); + + child.on('error', (err) => { + reject(new Error(`Failed to start logout process: ${err.message}`)); + }); + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..17096fa --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,522 @@ +/** + * CLI 路由入口 + * + * 调用关系: + * bin/opencode-bridge.js → dist/cli/index.js#run(argv) + * + * 子命令: + * <无> 智能入口(首次未配置 → TUI 向导;已配置 → 启动服务) + * init 强制进入 TUI 向导(重新配置) + * start 直接启动服务(绕过 TUI) + * bridge resource 资源管理子命令 + * help / --help 显示用法 + * --version / -v 显示版本 + * + * Electron 模式(process.versions.electron 存在 或 ELECTRON_RUN_AS_NODE=1) + * 不会进入此入口,由 electron/main.ts 直接接管。 + */ + +import { VERSION } from '../utils/version.js'; + +// Import resource commands +import { + skillList, + skillCreate, + skillEdit, + skillDelete, + skillEnable, + skillDisable, +} from './commands/resources.js'; +import { + mcpList, + mcpAdd, + mcpEdit, + mcpDelete, + mcpEnable, + mcpDisable, +} from './commands/resources.js'; +import { + agentList, + agentCreate, + agentEdit, + agentDelete, +} from './commands/resources.js'; +import { + modelProviders, + modelSetKey, + modelRemoveKey, + modelModels, + modelRefresh, + modelLogin, + modelLogout, +} from './commands/resources.js'; + +function isElectronEnv(): boolean { + return !!(process.versions as any).electron || process.env.ELECTRON_RUN_AS_NODE === '1'; +} + +function printHelp(): void { + const lines = [ + `OpenCode Bridge v${VERSION}`, + '', + 'Usage:', + ' opencode-bridge Start service (or run TUI wizard on first run)', + ' opencode-bridge init Re-run the interactive TUI wizard', + ' opencode-bridge start Start the bridge service (skip wizard)', + ' opencode-bridge bridge resource Manage resources', + ' opencode-bridge --version Print version', + ' opencode-bridge --help Show this help', + '', + 'Resource Management:', + ' opencode-bridge bridge resource skill list List all skills', + ' opencode-bridge bridge resource skill create [options] Create new skill', + ' opencode-bridge bridge resource skill edit Edit skill (opens editor)', + ' opencode-bridge bridge resource skill delete Delete skill', + ' opencode-bridge bridge resource skill enable Enable skill', + ' opencode-bridge bridge resource skill disable Disable skill', + '', + ' opencode-bridge bridge resource mcp list List all MCP servers', + ' opencode-bridge bridge resource mcp add [options] Add MCP server', + ' opencode-bridge bridge resource mcp edit Edit MCP config', + ' opencode-bridge bridge resource mcp delete Delete MCP server', + ' opencode-bridge bridge resource mcp enable Enable MCP server', + ' opencode-bridge bridge resource mcp disable Disable MCP server', + '', + ' opencode-bridge bridge resource agent list List all agents', + ' opencode-bridge bridge resource agent create [options] Create agent', + ' opencode-bridge bridge resource agent edit Edit agent', + ' opencode-bridge bridge resource agent delete Delete agent', + '', + ' opencode-bridge bridge resource model providers List model providers', + ' opencode-bridge bridge resource model set-key Set API key', + ' opencode-bridge bridge resource model remove-key Remove API key', + ' opencode-bridge bridge resource model models [provider] Show models', + ' opencode-bridge bridge resource model refresh Refresh models cache', + ' opencode-bridge bridge resource model login OAuth login (opens browser)', + ' opencode-bridge bridge resource model logout OAuth logout', + '', + 'Common flags:', + ' --config-dir Override config directory (default: ./data)', + '', + 'Docs: https://github.com/HNGM-HP/opencode-bridge#readme', + ]; + console.log(lines.join('\n')); +} + +function printVersion(): void { + console.log(VERSION); +} + +async function startServiceOnly(): Promise { + // 直接进入 main() + const mod = await import('../index.js'); + await mod.startBridge(); + // startBridge 内部已注册 SIGINT/SIGTERM;此处返回后事件循环继续保活 +} + +async function runWizardThenMaybeStart(force: boolean): Promise { + const { runWizard } = await import('./tui-wizard.js'); + const result = await runWizard({ force }); + if (!result.startService) { + // 用户选择仅退出 / 仅启动 web 但 web 模式我们仍会启动服务; + // 因此走到这里基本是"保存配置但不启动桥接" + if (!result.webStartedInWizard) { + process.exit(0); + } + // 如果在向导里启动了 web 但选了"不启动桥接",保留进程运行 web + return; + } + await startServiceOnly(); +} + +export async function run(argv: string[]): Promise { + const sub = argv[0]; + + if (sub === '--version' || sub === '-v') { + printVersion(); + return; + } + if (sub === '--help' || sub === '-h' || sub === 'help') { + printHelp(); + return; + } + if (sub === 'init') { + await runWizardThenMaybeStart(true); + return; + } + if (sub === 'start') { + await startServiceOnly(); + return; + } + + // Handle "bridge resource" subcommands + if (sub === 'bridge' && argv[1] === 'resource') { + await handleResourceCommand(argv.slice(2)); + return; + } + + // 无子命令:智能入口 + // - Electron 内置(不应走到这里,但稳健起见保留):直接启动服务 + // - 无头模式且未配置:TUI 向导 + // - 无头模式且已配置:直接启动服务 + if (isElectronEnv()) { + await startServiceOnly(); + return; + } + + // 检查是否是首次运行(无任何平台配置) + let firstRun = true; + try { + const { hasAnyPlatformConfigured } = await import('./tui-wizard.js'); + firstRun = !hasAnyPlatformConfigured(); + } catch { + firstRun = true; + } + + if (firstRun) { + await runWizardThenMaybeStart(false); + } else { + await startServiceOnly(); + } +} + +/** + * Handle "bridge resource" subcommands + */ +async function handleResourceCommand(args: string[]): Promise { + const resourceType = args[0]; + const action = args[1]; + const target = args[2]; + const rest = args.slice(3); + + try { + // Skills + if (resourceType === 'skill') { + switch (action) { + case 'list': + await skillList(); + break; + case 'create': + if (!target) { + console.error('Usage: bridge resource skill create [options]'); + process.exit(1); + } + await skillCreate(target, parseOptions(rest)); + break; + case 'edit': + if (!target) { + console.error('Usage: bridge resource skill edit '); + process.exit(1); + } + await skillEdit(target, parseOptions(rest)); + break; + case 'delete': + if (!target) { + console.error('Usage: bridge resource skill delete '); + process.exit(1); + } + await skillDelete(target, parseOptions(rest)); + break; + case 'enable': + if (!target) { + console.error('Usage: bridge resource skill enable '); + process.exit(1); + } + await skillEnable(target, parseOptions(rest)); + break; + case 'disable': + if (!target) { + console.error('Usage: bridge resource skill disable '); + process.exit(1); + } + await skillDisable(target, parseOptions(rest)); + break; + default: + console.error(`Unknown skill action: ${action}`); + printResourceHelp('skill'); + process.exit(1); + } + return; + } + + // MCP + if (resourceType === 'mcp') { + switch (action) { + case 'list': + await mcpList(); + break; + case 'add': + if (!target) { + console.error('Usage: bridge resource mcp add [options]'); + process.exit(1); + } + await mcpAdd(target, parseMcpOptions(rest)); + break; + case 'edit': + if (!target) { + console.error('Usage: bridge resource mcp edit '); + process.exit(1); + } + await mcpEdit(target, parseOptions(rest)); + break; + case 'delete': + if (!target) { + console.error('Usage: bridge resource mcp delete '); + process.exit(1); + } + await mcpDelete(target, parseOptions(rest)); + break; + case 'enable': + if (!target) { + console.error('Usage: bridge resource mcp enable '); + process.exit(1); + } + await mcpEnable(target, parseOptions(rest)); + break; + case 'disable': + if (!target) { + console.error('Usage: bridge resource mcp disable '); + process.exit(1); + } + await mcpDisable(target, parseOptions(rest)); + break; + default: + console.error(`Unknown mcp action: ${action}`); + printResourceHelp('mcp'); + process.exit(1); + } + return; + } + + // Agents + if (resourceType === 'agent') { + switch (action) { + case 'list': + await agentList(); + break; + case 'create': + if (!target) { + console.error('Usage: bridge resource agent create [options]'); + process.exit(1); + } + await agentCreate(target, parseOptions(rest)); + break; + case 'edit': + if (!target) { + console.error('Usage: bridge resource agent edit '); + process.exit(1); + } + await agentEdit(target, parseOptions(rest)); + break; + case 'delete': + if (!target) { + console.error('Usage: bridge resource agent delete '); + process.exit(1); + } + await agentDelete(target, parseOptions(rest)); + break; + default: + console.error(`Unknown agent action: ${action}`); + printResourceHelp('agent'); + process.exit(1); + } + return; + } + + // Model/Providers + if (resourceType === 'model') { + switch (action) { + case 'providers': + await modelProviders(); + break; + case 'set-key': + if (!target || !rest[0]) { + console.error('Usage: bridge resource model set-key '); + process.exit(1); + } + await modelSetKey(target, rest[0]); + break; + case 'remove-key': + if (!target) { + console.error('Usage: bridge resource model remove-key '); + process.exit(1); + } + await modelRemoveKey(target); + break; + case 'models': + await modelModels(target); + break; + case 'refresh': + await modelRefresh(); + break; + case 'login': + if (!target) { + console.error('Usage: bridge resource model login '); + process.exit(1); + } + await modelLogin(target); + break; + case 'logout': + if (!target) { + console.error('Usage: bridge resource model logout '); + process.exit(1); + } + await modelLogout(target); + break; + default: + console.error(`Unknown model action: ${action}`); + printResourceHelp('model'); + process.exit(1); + } + return; + } + + console.error(`Unknown resource type: ${resourceType}`); + printResourceHelp(); + process.exit(1); + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +/** + * Parse command-line options (e.g., --scope user --description "My skill") + */ +function parseOptions(args: string[]): Record { + const options: Record = {}; + let i = 0; + + while (i < args.length) { + const arg = args[i]; + + if (arg.startsWith('--')) { + const key = arg.slice(2); + const nextArg = args[i + 1]; + + if (nextArg && !nextArg.startsWith('--')) { + // Option with value + options[key] = nextArg; + i += 2; + } else { + // Flag without value + options[key] = true; + i += 1; + } + } else { + i += 1; + } + } + + return options; +} + +/** + * Parse MCP-specific options with proper typing + */ +function parseMcpOptions(args: string[]): { + transport: 'stdio' | 'sse' | 'http'; + command?: string; + url?: string; + scope?: string; + description?: string; + enabled?: boolean; +} { + const options = parseOptions(args); + + // Check for required transport option + const transport = options.transport; + if (!transport || typeof transport !== 'string' || !['stdio', 'sse', 'http'].includes(transport)) { + console.error('Error: --transport is required and must be one of: stdio, sse, http'); + process.exit(1); + } + + return { + transport: transport as 'stdio' | 'sse' | 'http', + command: options.command as string | undefined, + url: options.url as string | undefined, + scope: options.scope as string | undefined, + description: options.description as string | undefined, + enabled: options.enabled as boolean | undefined, + }; +} + +/** + * Print help for resource subcommands + */ +function printResourceHelp(resourceType?: string): void { + if (resourceType === 'skill') { + console.log(` +Skill Management: + bridge resource skill list List all skills + bridge resource skill create [options] Create new skill + bridge resource skill edit Edit skill (opens $EDITOR) + bridge resource skill delete Delete skill + bridge resource skill enable Enable skill + bridge resource skill disable Disable skill + +Options for create: + --description Skill description + --markdown Path to markdown file + --scope Scope (default: project) + --enabled Enable on creation (default) + --disabled Disable on creation +`); + } else if (resourceType === 'mcp') { + console.log(` +MCP Server Management: + bridge resource mcp list List all MCP servers + bridge resource mcp add [options] Add MCP server + bridge resource mcp edit Edit MCP config (opens $EDITOR) + bridge resource mcp delete Delete MCP server + bridge resource mcp enable Enable MCP server + bridge resource mcp disable Disable MCP server + +Options for add: + --transport Transport type (required) + --command Command for stdio transport + --url URL for sse/http transport + --description Server description + --scope Scope (default: project) + --enabled Enable on creation (default) + --disabled Disable on creation +`); + } else if (resourceType === 'agent') { + console.log(` +Agent Management: + bridge resource agent list List all agents + bridge resource agent create [options] Create agent + bridge resource agent edit Edit agent (opens $EDITOR) + bridge resource agent delete Delete agent + +Options for create: + --description Agent description + --mode Agent mode + --prompt System prompt + --scope Scope (default: project) + --enabled Enable on creation (default) + --disabled Disable on creation +`); + } else if (resourceType === 'model') { + console.log(` +Model/Provider Management: + bridge resource model providers List all providers + bridge resource model set-key Set API key for provider + bridge resource model remove-key Remove API key for provider + bridge resource model models [provider] Show models (all or by provider) + bridge resource model refresh Refresh models cache from OpenCode + bridge resource model login OAuth login (opens browser) + bridge resource model logout OAuth logout +`); + } else { + console.log(` +Resource Management: + bridge resource skill ... Manage skills + bridge resource mcp ... Manage MCP servers + bridge resource agent ... Manage agents + bridge resource model ... Manage model providers + +Use 'bridge resource --help' for more details on each resource type. +`); + } +} diff --git a/src/cli/messages.ts b/src/cli/messages.ts new file mode 100644 index 0000000..9b3516f --- /dev/null +++ b/src/cli/messages.ts @@ -0,0 +1,293 @@ +/** + * TUI 向导多语言文案 + * + * 仅覆盖 CLI 交互所需文本,与 web 端 i18n 互不依赖: + * - web 端语言偏好存于 localStorage(浏览器内) + * - CLI 端语言偏好存于 admin_meta.cli_lang,由 configStore 持久化 + */ + +export type CliLang = 'zh' | 'en'; + +export interface CliMessages { + // 通用 + yes: string; + no: string; + back: string; + cancel: string; + saved: string; + saveFailed: string; + pressEnter: string; + + // banner + banner: (version: string) => string; + bannerForcedInit: string; + bannerFirstRun: string; + + // 语言选择 + pickLanguageTitle: string; + langZh: string; + langEn: string; + + // 入口选择 + entryTitle: string; + entryConfigViaTui: string; + entryConfigViaWeb: string; + entryStartService: string; + entryHelp: string; + entryExit: string; + + // 平台选择(首次接入) + initialPlatformTitle: string; + initialPlatformSkip: string; + + // 主菜单(轮询菜单) + mainMenuTitle: string; + mainMenuLanguage: string; + mainMenuInitialPlatform: string; + mainMenuPlatforms: string; + mainMenuOpencode: string; + mainMenuRouter: string; + mainMenuReliability: string; + mainMenuOutput: string; + mainMenuWeb: string; + mainMenuHelp: string; + mainMenuStartService: string; + mainMenuExit: string; + + // 平台子菜单 + platformsMenuTitle: string; + platformEnable: (label: string) => string; + platformDisable: (label: string) => string; + platformConfigure: (label: string) => string; + + // 字段输入提示 + inputRequired: string; + inputOptional: string; + + // OpenCode + opencodeHost: string; + opencodePort: string; + opencodeAutoStart: string; + opencodeAutoStartFg: string; + + // 群聊行为 + groupRequireMention: string; + groupReplyRequireMention: string; + allowedUsers: string; + + // 输出 + showThinking: string; + showTool: string; + + // 可靠性 + reliabilityCronEnabled: string; + reliabilityHeartbeatEnabled: string; + + // Web 服务器 + webEnabled: string; + webPort: string; + webStarted: (url: string) => string; + webStopped: string; + + // 启动/退出 + startingService: string; + serviceStarted: string; + bye: string; + + // 帮助 + helpTitle: string; + helpReadme: string; + helpEnglish: string; + helpIssues: string; + helpReleases: string; + helpDocs: string; + + // 错误 + errAdminBusy: string; +} + +const zh: CliMessages = { + yes: '是', + no: '否', + back: '返回', + cancel: '取消', + saved: '✅ 已保存到本地配置', + saveFailed: '❌ 保存失败', + pressEnter: '按回车继续...', + + banner: v => + [ + '╔════════════════════════════════════════════════╗', + `║ OpenCode Bridge 终端配置向导 v${v.padEnd(10)} ║`, + '╚════════════════════════════════════════════════╝', + ].join('\n'), + bannerForcedInit: '当前为 init 模式,将进入完整配置流程', + bannerFirstRun: '检测到尚未配置任何接入平台,进入首次安装向导', + + pickLanguageTitle: '请选择语言 / Please choose language', + langZh: '中文', + langEn: 'English', + + entryTitle: '请选择配置方式', + entryConfigViaTui: '在终端中通过 TUI 完成配置(推荐用于无桌面环境)', + entryConfigViaWeb: '启动 Web 管理面板,在浏览器中配置', + entryStartService: '跳过配置,直接启动服务', + entryHelp: '查看帮助 / 使用文档', + entryExit: '退出(不启动服务)', + + initialPlatformTitle: '请选择一个先接入的平台(可稍后再加)', + initialPlatformSkip: '跳过 / 暂不配置', + + mainMenuTitle: '主菜单 — 选择要修改的项', + mainMenuLanguage: '🌐 切换语言', + mainMenuInitialPlatform: '📌 选择/切换主接入平台', + mainMenuPlatforms: '🔌 平台配置(增删与凭据)', + mainMenuOpencode: '🧠 OpenCode 连接', + mainMenuRouter: '👥 群聊行为 / 白名单', + mainMenuReliability: '🩺 可靠性 / Cron / 心跳', + mainMenuOutput: '📤 输出显示(思维链 / 工具链)', + mainMenuWeb: '🌐 Web 管理面板', + mainMenuHelp: '❓ 帮助 / 使用文档', + mainMenuStartService: '🚀 保存并启动桥接服务', + mainMenuExit: '🚪 退出(保存配置但不启动服务)', + + platformsMenuTitle: '平台配置 — 启用 / 关闭 / 修改凭据', + platformEnable: l => `启用「${l}」`, + platformDisable: l => `关闭「${l}」`, + platformConfigure: l => `配置「${l}」凭据`, + + inputRequired: '(必填)', + inputOptional: '(可选)', + + opencodeHost: 'OpenCode 服务地址 (host)', + opencodePort: 'OpenCode 服务端口 (port)', + opencodeAutoStart: '是否自动启动 opencode serve?', + opencodeAutoStartFg: '前台模式(Windows 弹出 attach 控制台)?', + + groupRequireMention: '群聊是否要求 @ 机器人才会响应?', + groupReplyRequireMention: '群聊回复是否也要求 @?', + allowedUsers: '白名单用户(逗号分隔,留空表示不限制)', + + showThinking: '是否显示思维链 (thinking chain)?', + showTool: '是否显示工具调用链 (tool chain)?', + + reliabilityCronEnabled: '启用 Cron 调度?', + reliabilityHeartbeatEnabled: '启用主动心跳?', + + webEnabled: '启用 Web 管理面板?', + webPort: 'Web 管理面板端口', + webStarted: u => `Web 管理面板已启动:${u}`, + webStopped: 'Web 管理面板已关闭(接入平台仍在运行)', + + startingService: '正在启动桥接服务...', + serviceStarted: '✅ 服务已启动,按 Ctrl+C 停止', + bye: '再见 👋', + + helpTitle: '帮助 / 使用文档', + helpReadme: '中文 README', + helpEnglish: 'English README', + helpIssues: 'GitHub Issues(提问与反馈)', + helpReleases: 'GitHub Releases(下载安装包)', + helpDocs: '项目主页', + + errAdminBusy: '⚠️ Web 管理面板已在运行中,请先关闭再启动', +}; + +const en: CliMessages = { + yes: 'Yes', + no: 'No', + back: 'Back', + cancel: 'Cancel', + saved: '✅ Saved to local config', + saveFailed: '❌ Save failed', + pressEnter: 'Press Enter to continue...', + + banner: v => + [ + '╔════════════════════════════════════════════════╗', + `║ OpenCode Bridge TUI setup wizard v${v.padEnd(10)} ║`, + '╚════════════════════════════════════════════════╝', + ].join('\n'), + bannerForcedInit: 'Running in init mode — full configuration flow', + bannerFirstRun: 'No platform configured yet — entering first-run wizard', + + pickLanguageTitle: 'Please choose language / 请选择语言', + langZh: '中文', + langEn: 'English', + + entryTitle: 'How would you like to configure?', + entryConfigViaTui: 'Configure here in the terminal (recommended for headless)', + entryConfigViaWeb: 'Launch the web admin UI and configure in a browser', + entryStartService: 'Skip — start the bridge service now', + entryHelp: 'Show help / documentation', + entryExit: 'Exit (do not start the service)', + + initialPlatformTitle: 'Pick a platform to connect first (you can add more later)', + initialPlatformSkip: 'Skip / configure later', + + mainMenuTitle: 'Main menu — pick a section to edit', + mainMenuLanguage: '🌐 Switch language', + mainMenuInitialPlatform: '📌 Pick / switch the primary platform', + mainMenuPlatforms: '🔌 Platforms (enable / disable / credentials)', + mainMenuOpencode: '🧠 OpenCode connection', + mainMenuRouter: '👥 Group behaviour / allow-list', + mainMenuReliability: '🩺 Reliability / cron / heartbeat', + mainMenuOutput: '📤 Output display (thinking / tool chain)', + mainMenuWeb: '🌐 Web admin UI', + mainMenuHelp: '❓ Help / documentation', + mainMenuStartService: '🚀 Save & start the bridge service', + mainMenuExit: '🚪 Exit (save config but do not start service)', + + platformsMenuTitle: 'Platforms — enable / disable / set credentials', + platformEnable: l => `Enable "${l}"`, + platformDisable: l => `Disable "${l}"`, + platformConfigure: l => `Configure "${l}" credentials`, + + inputRequired: '(required)', + inputOptional: '(optional)', + + opencodeHost: 'OpenCode host', + opencodePort: 'OpenCode port', + opencodeAutoStart: 'Auto-start opencode serve?', + opencodeAutoStartFg: 'Foreground mode (pop attach console on Windows)?', + + groupRequireMention: 'Require @bot mention in groups before reply?', + groupReplyRequireMention: 'Require @bot mention for replies in groups?', + allowedUsers: 'Allow-listed users (comma-separated, leave empty for no limit)', + + showThinking: 'Show thinking chain?', + showTool: 'Show tool-call chain?', + + reliabilityCronEnabled: 'Enable cron scheduler?', + reliabilityHeartbeatEnabled: 'Enable proactive heartbeat?', + + webEnabled: 'Enable web admin UI?', + webPort: 'Web admin port', + webStarted: u => `Web admin UI started at ${u}`, + webStopped: 'Web admin UI stopped (platform adapters keep running)', + + startingService: 'Starting bridge service...', + serviceStarted: '✅ Service running, press Ctrl+C to stop', + bye: 'Bye 👋', + + helpTitle: 'Help / documentation', + helpReadme: 'Chinese README', + helpEnglish: 'English README', + helpIssues: 'GitHub Issues (questions & feedback)', + helpReleases: 'GitHub Releases (download installers)', + helpDocs: 'Project homepage', + + errAdminBusy: '⚠️ Web admin UI is already running — stop it first', +}; + +const PACKS: Record = { zh, en }; + +export function getMessages(lang: CliLang): CliMessages { + return PACKS[lang] ?? zh; +} + +/** 启发式:从环境变量推断默认语言(zh-CN/zh / 其它) */ +export function detectDefaultLang(): CliLang { + const env = (process.env.LANG || process.env.LC_ALL || process.env.LC_MESSAGES || '').toLowerCase(); + return env.startsWith('zh') ? 'zh' : 'en'; +} diff --git a/src/cli/tui-wizard.ts b/src/cli/tui-wizard.ts new file mode 100644 index 0000000..51ead15 --- /dev/null +++ b/src/cli/tui-wizard.ts @@ -0,0 +1,636 @@ +/** + * TUI 交互式向导 + * + * 设计要点: + * - 与 web 端共用同一份 ConfigStore(SQLite 的 settings 表 id=1) + * - 语言偏好存于 admin_meta.cli_lang + * - 离线场景:不依赖 admin server / opencode 是否在跑,纯本地 DB 操作 + * - 退出方式:用户选择"启动服务"返回 { startService: true },否则 { startService: false } + * 外层 CLI 入口据此决定是否调用 startBridge() + */ + +import { + select, + input, + confirm, + password, +} from '@inquirer/prompts'; +import { configStore, type BridgeSettings } from '../store/config-store.js'; +import { VERSION } from '../utils/version.js'; +import { getMessages, detectDefaultLang, type CliLang, type CliMessages } from './messages.js'; + +// ────────────────────────────────────────────── +// 平台元数据(与 web 端 onboarding-platforms.ts 保持一致) +// ────────────────────────────────────────────── + +interface PlatformMeta { + id: string; + label: { zh: string; en: string }; + enabledKey: keyof BridgeSettings; + /** 启用该平台必须填的凭据字段(多账号平台 weixin/dingtalk 留空,引导提示去 web 端配账号) */ + fields: Array<{ + key: keyof BridgeSettings; + label: { zh: string; en: string }; + required: boolean; + secret?: boolean; + type?: 'text' | 'select'; + choices?: Array<{ value: string; label: { zh: string; en: string } }>; + }>; + /** 多账号平台(无法在 TUI 内一次性配置) */ + multiAccount?: boolean; + multiAccountTip?: { zh: string; en: string }; +} + +const PLATFORMS: PlatformMeta[] = [ + { + id: 'feishu', + label: { zh: '飞书', en: 'Feishu' }, + enabledKey: 'FEISHU_ENABLED', + fields: [ + { key: 'FEISHU_APP_ID', label: { zh: 'App ID', en: 'App ID' }, required: true }, + { key: 'FEISHU_APP_SECRET', label: { zh: 'App Secret', en: 'App Secret' }, required: true, secret: true }, + { key: 'FEISHU_ENCRYPT_KEY', label: { zh: 'Encrypt Key', en: 'Encrypt Key' }, required: false, secret: true }, + { key: 'FEISHU_VERIFICATION_TOKEN', label: { zh: 'Verification Token', en: 'Verification Token' }, required: false, secret: true }, + ], + }, + { + id: 'discord', + label: { zh: 'Discord', en: 'Discord' }, + enabledKey: 'DISCORD_ENABLED', + fields: [ + { key: 'DISCORD_TOKEN', label: { zh: 'Bot Token', en: 'Bot Token' }, required: true, secret: true }, + { key: 'DISCORD_CLIENT_ID', label: { zh: 'Client ID', en: 'Client ID' }, required: true }, + ], + }, + { + id: 'wecom', + label: { zh: '企业微信', en: 'WeCom' }, + enabledKey: 'WECOM_ENABLED', + fields: [ + { key: 'WECOM_BOT_ID', label: { zh: 'Bot ID', en: 'Bot ID' }, required: true }, + { key: 'WECOM_SECRET', label: { zh: 'Secret', en: 'Secret' }, required: true, secret: true }, + ], + }, + { + id: 'telegram', + label: { zh: 'Telegram', en: 'Telegram' }, + enabledKey: 'TELEGRAM_ENABLED', + fields: [ + { key: 'TELEGRAM_BOT_TOKEN', label: { zh: 'Bot Token', en: 'Bot Token' }, required: true, secret: true }, + ], + }, + { + id: 'qq', + label: { zh: 'QQ', en: 'QQ' }, + enabledKey: 'QQ_ENABLED', + fields: [ + { + key: 'QQ_PROTOCOL', + label: { zh: '协议', en: 'Protocol' }, + required: true, + type: 'select', + choices: [ + { value: 'official', label: { zh: '官方 API', en: 'Official API' } }, + { value: 'onebot', label: { zh: 'OneBot 协议', en: 'OneBot' } }, + ], + }, + { key: 'QQ_APP_ID', label: { zh: 'App ID(官方)', en: 'App ID (official)' }, required: false }, + { key: 'QQ_SECRET', label: { zh: 'Secret(官方)', en: 'Secret (official)' }, required: false, secret: true }, + { key: 'QQ_ONEBOT_HTTP_URL', label: { zh: 'OneBot HTTP URL', en: 'OneBot HTTP URL' }, required: false }, + { key: 'QQ_ONEBOT_WS_URL', label: { zh: 'OneBot WS URL', en: 'OneBot WS URL' }, required: false }, + ], + }, + { + id: 'whatsapp', + label: { zh: 'WhatsApp', en: 'WhatsApp' }, + enabledKey: 'WHATSAPP_ENABLED', + fields: [ + { + key: 'WHATSAPP_MODE', + label: { zh: '模式', en: 'Mode' }, + required: true, + type: 'select', + choices: [ + { value: 'personal', label: { zh: '个人版(扫码)', en: 'Personal (QR login)' } }, + { value: 'business', label: { zh: '商业版(API)', en: 'Business (API)' } }, + ], + }, + { key: 'WHATSAPP_BUSINESS_PHONE_ID', label: { zh: 'Business Phone ID', en: 'Business Phone ID' }, required: false }, + { key: 'WHATSAPP_BUSINESS_ACCESS_TOKEN', label: { zh: 'Business Access Token', en: 'Business Access Token' }, required: false, secret: true }, + ], + }, + { + id: 'weixin', + label: { zh: '个人微信', en: 'WeChat (personal)' }, + enabledKey: 'WEIXIN_ENABLED', + fields: [], + multiAccount: true, + multiAccountTip: { + zh: '个人微信为多账号平台,账号 / 网关 / 二维码登录请前往 Web 管理面板配置;TUI 仅控制总开关', + en: 'WeChat personal is multi-account; use the web admin to add accounts and scan QR. TUI only toggles the master switch', + }, + }, + { + id: 'dingtalk', + label: { zh: '钉钉', en: 'DingTalk' }, + enabledKey: 'DINGTALK_ENABLED', + fields: [], + multiAccount: true, + multiAccountTip: { + zh: '钉钉为多账号平台,client_id / client_secret 请前往 Web 管理面板配置;TUI 仅控制总开关', + en: 'DingTalk is multi-account; use the web admin to add accounts. TUI only toggles the master switch', + }, + }, +]; + +// ────────────────────────────────────────────── +// 语言持久化(admin_meta.cli_lang) +// ────────────────────────────────────────────── + +function readSavedLang(): CliLang | null { + // configStore 暴露的语言读写接口;若尚未实现则回落到 BridgeSettings 字段 + const cur = configStore.get() as BridgeSettings & { CLI_LANG?: string }; + if (cur.CLI_LANG === 'zh' || cur.CLI_LANG === 'en') return cur.CLI_LANG; + return null; +} + +function saveLang(lang: CliLang): void { + configStore.merge({ CLI_LANG: lang } as Partial); +} + +// ────────────────────────────────────────────── +// 平台启用状态判断 +// ────────────────────────────────────────────── + +function isTrueFlag(v: string | undefined): boolean { + return v === 'true' || v === '1'; +} + +export function hasAnyPlatformConfigured(): boolean { + const s = configStore.get(); + return PLATFORMS.some(p => isTrueFlag(s[p.enabledKey] as string | undefined)); +} + +// ────────────────────────────────────────────── +// 通用工具:处理 inquirer 的 Ctrl+C / ESC 抛错 +// ────────────────────────────────────────────── + +async function safe(fn: () => Promise): Promise { + try { + return await fn(); + } catch (err: any) { + if (err && (err.name === 'ExitPromptError' || err.code === 'ERR_USE_AFTER_CLOSE')) { + return null; + } + throw err; + } +} + +function pause(msg: string): Promise { + return safe(() => input({ message: msg })); +} + +// ────────────────────────────────────────────── +// 子流程 +// ────────────────────────────────────────────── + +async function pickLanguage(currentLang: CliLang): Promise { + const m = getMessages(currentLang); + const ans = await safe(() => + select({ + message: m.pickLanguageTitle, + choices: [ + { value: 'zh', name: m.langZh }, + { value: 'en', name: m.langEn }, + ], + default: currentLang, + }), + ); + if (!ans) return currentLang; + saveLang(ans); + return ans; +} + +async function configurePlatformFields(meta: PlatformMeta, lang: CliLang, m: CliMessages): Promise { + if (meta.multiAccount) { + const tip = meta.multiAccountTip ? meta.multiAccountTip[lang] : ''; + console.log(`\n${tip}\n`); + await pause(m.pressEnter); + return; + } + + const cur = configStore.get(); + const patch: Partial = {}; + for (const f of meta.fields) { + const label = f.label[lang]; + const tag = f.required ? m.inputRequired : m.inputOptional; + const message = `${label} ${tag}`; + + if (f.type === 'select' && f.choices) { + const ans = await safe(() => + select({ + message, + choices: f.choices!.map(c => ({ value: c.value, name: c.label[lang] })), + default: (cur[f.key] as string | undefined) || f.choices![0].value, + }), + ); + if (ans !== null) (patch as any)[f.key] = ans; + continue; + } + + if (f.secret) { + const ans = await safe(() => + password({ + message, + mask: '*', + validate: v => (f.required && !v ? (lang === 'zh' ? '该字段必填' : 'Required') : true), + }), + ); + if (ans !== null && ans !== '') (patch as any)[f.key] = ans; + continue; + } + + const ans = await safe(() => + input({ + message, + default: (cur[f.key] as string | undefined) || '', + validate: v => (f.required && !v ? (lang === 'zh' ? '该字段必填' : 'Required') : true), + }), + ); + if (ans !== null && ans !== '') (patch as any)[f.key] = ans; + } + + if (Object.keys(patch).length > 0) { + configStore.merge(patch); + console.log(m.saved); + } +} + +async function togglePlatformEnabled(meta: PlatformMeta, enabled: boolean, m: CliMessages): Promise { + configStore.merge({ [meta.enabledKey]: enabled ? 'true' : 'false' } as Partial); + console.log(m.saved); +} + +async function platformsMenu(lang: CliLang): Promise { + const m = getMessages(lang); + // 循环菜单 + // eslint-disable-next-line no-constant-condition + while (true) { + const cur = configStore.get(); + const choices = PLATFORMS.map(p => { + const on = isTrueFlag(cur[p.enabledKey] as string | undefined); + const status = on ? '✅' : '⚪'; + return { value: p.id, name: `${status} ${p.label[lang]}` }; + }); + choices.push({ value: '__back__', name: `← ${m.back}` }); + + const pick = await safe(() => + select({ message: m.platformsMenuTitle, choices, pageSize: 12 }), + ); + if (!pick || pick === '__back__') return; + const meta = PLATFORMS.find(p => p.id === pick)!; + const on = isTrueFlag(configStore.get()[meta.enabledKey] as string | undefined); + + const action = await safe(() => + select({ + message: meta.label[lang], + choices: [ + { value: 'configure', name: m.platformConfigure(meta.label[lang]) }, + { + value: 'toggle', + name: on ? m.platformDisable(meta.label[lang]) : m.platformEnable(meta.label[lang]), + }, + { value: '__back__', name: `← ${m.back}` }, + ], + }), + ); + if (!action || action === '__back__') continue; + if (action === 'configure') { + await configurePlatformFields(meta, lang, m); + // 配置完成后,如果存在凭据但未启用,提示用户启用 + const fresh = configStore.get(); + if (!isTrueFlag(fresh[meta.enabledKey] as string | undefined) && !meta.multiAccount) { + const enable = await safe(() => + confirm({ + message: lang === 'zh' ? `是否同时启用「${meta.label.zh}」?` : `Enable "${meta.label.en}" now?`, + default: true, + }), + ); + if (enable) await togglePlatformEnabled(meta, true, m); + } + } else if (action === 'toggle') { + await togglePlatformEnabled(meta, !on, m); + } + } +} + +async function opencodeMenu(lang: CliLang): Promise { + const m = getMessages(lang); + const cur = configStore.get(); + const patch: Partial = {}; + + const host = await safe(() => input({ message: m.opencodeHost, default: cur.OPENCODE_HOST || 'localhost' })); + if (host !== null) patch.OPENCODE_HOST = host; + + const port = await safe(() => input({ message: m.opencodePort, default: cur.OPENCODE_PORT || '4096' })); + if (port !== null) patch.OPENCODE_PORT = port; + + const auto = await safe(() => + confirm({ message: m.opencodeAutoStart, default: isTrueFlag(cur.OPENCODE_AUTO_START) }), + ); + if (auto !== null) patch.OPENCODE_AUTO_START = auto ? 'true' : 'false'; + + if (auto) { + const fg = await safe(() => + confirm({ message: m.opencodeAutoStartFg, default: isTrueFlag(cur.OPENCODE_AUTO_START_FOREGROUND) }), + ); + if (fg !== null) patch.OPENCODE_AUTO_START_FOREGROUND = fg ? 'true' : 'false'; + } + + if (Object.keys(patch).length > 0) { + configStore.merge(patch); + console.log(m.saved); + } +} + +async function routerMenu(lang: CliLang): Promise { + const m = getMessages(lang); + const cur = configStore.get(); + const patch: Partial = {}; + + const requireAt = await safe(() => + confirm({ message: m.groupRequireMention, default: isTrueFlag(cur.GROUP_REQUIRE_MENTION ?? 'true') }), + ); + if (requireAt !== null) patch.GROUP_REQUIRE_MENTION = requireAt ? 'true' : 'false'; + + const replyAt = await safe(() => + confirm({ message: m.groupReplyRequireMention, default: isTrueFlag(cur.GROUP_REPLY_REQUIRE_MENTION ?? 'false') }), + ); + if (replyAt !== null) patch.GROUP_REPLY_REQUIRE_MENTION = replyAt ? 'true' : 'false'; + + const allow = await safe(() => + input({ message: m.allowedUsers, default: cur.ALLOWED_USERS || '' }), + ); + if (allow !== null) patch.ALLOWED_USERS = allow; + + if (Object.keys(patch).length > 0) { + configStore.merge(patch); + console.log(m.saved); + } +} + +async function reliabilityMenu(lang: CliLang): Promise { + const m = getMessages(lang); + const cur = configStore.get(); + const patch: Partial = {}; + + const cron = await safe(() => + confirm({ message: m.reliabilityCronEnabled, default: isTrueFlag(cur.RELIABILITY_CRON_ENABLED ?? 'true') }), + ); + if (cron !== null) patch.RELIABILITY_CRON_ENABLED = cron ? 'true' : 'false'; + + const hb = await safe(() => + confirm({ + message: m.reliabilityHeartbeatEnabled, + default: isTrueFlag(cur.RELIABILITY_PROACTIVE_HEARTBEAT_ENABLED ?? 'false'), + }), + ); + if (hb !== null) patch.RELIABILITY_PROACTIVE_HEARTBEAT_ENABLED = hb ? 'true' : 'false'; + + if (Object.keys(patch).length > 0) { + configStore.merge(patch); + console.log(m.saved); + } +} + +async function outputMenu(lang: CliLang): Promise { + const m = getMessages(lang); + const cur = configStore.get(); + const patch: Partial = {}; + + const think = await safe(() => + confirm({ message: m.showThinking, default: isTrueFlag(cur.SHOW_THINKING_CHAIN ?? 'true') }), + ); + if (think !== null) patch.SHOW_THINKING_CHAIN = think ? 'true' : 'false'; + + const tool = await safe(() => + confirm({ message: m.showTool, default: isTrueFlag(cur.SHOW_TOOL_CHAIN ?? 'true') }), + ); + if (tool !== null) patch.SHOW_TOOL_CHAIN = tool ? 'true' : 'false'; + + if (Object.keys(patch).length > 0) { + configStore.merge(patch); + console.log(m.saved); + } +} + +/** Web 管理面板控制:在 TUI 内可启停 admin server,且不影响平台适配器 */ +async function webAdminMenu(lang: CliLang, ctx: WizardContext): Promise { + const m = getMessages(lang); + const cur = configStore.get(); + // 是否启用以及端口 + const enable = await safe(() => + confirm({ message: m.webEnabled, default: !isTrueFlag(cur.WEB_ADMIN_DISABLED ?? 'false') }), + ); + if (enable === null) return; + + const port = await safe(() => + input({ message: m.webPort, default: cur.ADMIN_PORT || '4098' }), + ); + if (port === null) return; + + configStore.merge({ + WEB_ADMIN_DISABLED: enable ? 'false' : 'true', + ADMIN_PORT: port, + } as Partial); + console.log(m.saved); + + if (enable) { + if (ctx.adminServer) { + console.log(m.errAdminBusy); + return; + } + try { + const portNum = parseInt(port, 10) || 4098; + const { createAdminServer } = await import('../admin/admin-server.js'); + const srv = createAdminServer({ port: portNum, startedAt: ctx.startedAt, version: VERSION }); + srv.start(); + ctx.adminServer = srv; + console.log(m.webStarted(`http://localhost:${portNum}`)); + } catch (err) { + console.error(m.saveFailed, err); + } + } else if (ctx.adminServer) { + ctx.adminServer.stop(); + ctx.adminServer = null; + console.log(m.webStopped); + } +} + +async function helpMenu(lang: CliLang): Promise { + const m = getMessages(lang); + const links: Array<{ label: string; url: string }> = [ + { label: m.helpReadme, url: 'https://github.com/HNGM-HP/opencode-bridge#readme' }, + { label: m.helpEnglish, url: 'https://github.com/HNGM-HP/opencode-bridge/blob/main/README-en.md' }, + { label: m.helpIssues, url: 'https://github.com/HNGM-HP/opencode-bridge/issues' }, + { label: m.helpReleases, url: 'https://github.com/HNGM-HP/opencode-bridge/releases' }, + { label: m.helpDocs, url: 'https://github.com/HNGM-HP/opencode-bridge' }, + ]; + console.log('\n' + m.helpTitle); + console.log('─'.repeat(48)); + for (const l of links) { + console.log(` • ${l.label}`); + console.log(` ${l.url}`); + } + console.log('─'.repeat(48) + '\n'); + await pause(m.pressEnter); +} + +// ────────────────────────────────────────────── +// 入口点:runWizard +// ────────────────────────────────────────────── + +interface WizardContext { + adminServer: { start: () => void; stop: () => void } | null; + startedAt: Date; +} + +export interface WizardResult { + /** 是否启动桥接服务 */ + startService: boolean; + /** 是否在 TUI 中已启动 web 管理面板(启动后由调用方决定保留/复用) */ + webStartedInWizard: boolean; +} + +/** 首次接入平台选择(TUI step "选择一个平台作为接入项") */ +async function pickInitialPlatform(lang: CliLang): Promise { + const m = getMessages(lang); + const choices = PLATFORMS.map(p => ({ value: p.id, name: p.label[lang] })); + choices.push({ value: '__skip__', name: m.initialPlatformSkip }); + const pick = await safe(() => + select({ message: m.initialPlatformTitle, choices, pageSize: 10 }), + ); + if (!pick || pick === '__skip__') return; + const meta = PLATFORMS.find(p => p.id === pick)!; + await configurePlatformFields(meta, lang, m); + // 自动启用(多账号平台仍需 web 端添加账号才会真正生效,但开关先打上) + await togglePlatformEnabled(meta, true, m); +} + +async function mainMenuLoop(lang: CliLang, ctx: WizardContext): Promise { + let currentLang = lang; + // eslint-disable-next-line no-constant-condition + while (true) { + const m = getMessages(currentLang); + const choice = await safe(() => + select({ + message: m.mainMenuTitle, + pageSize: 14, + choices: [ + { value: 'lang', name: m.mainMenuLanguage }, + { value: 'initialPlatform', name: m.mainMenuInitialPlatform }, + { value: 'platforms', name: m.mainMenuPlatforms }, + { value: 'opencode', name: m.mainMenuOpencode }, + { value: 'router', name: m.mainMenuRouter }, + { value: 'reliability', name: m.mainMenuReliability }, + { value: 'output', name: m.mainMenuOutput }, + { value: 'web', name: m.mainMenuWeb }, + { value: 'help', name: m.mainMenuHelp }, + { value: 'start', name: m.mainMenuStartService }, + { value: 'exit', name: m.mainMenuExit }, + ], + }), + ); + if (!choice || choice === 'exit') { + return { startService: false, webStartedInWizard: !!ctx.adminServer }; + } + if (choice === 'start') { + return { startService: true, webStartedInWizard: !!ctx.adminServer }; + } + if (choice === 'lang') currentLang = await pickLanguage(currentLang); + else if (choice === 'initialPlatform') await pickInitialPlatform(currentLang); + else if (choice === 'platforms') await platformsMenu(currentLang); + else if (choice === 'opencode') await opencodeMenu(currentLang); + else if (choice === 'router') await routerMenu(currentLang); + else if (choice === 'reliability') await reliabilityMenu(currentLang); + else if (choice === 'output') await outputMenu(currentLang); + else if (choice === 'web') await webAdminMenu(currentLang, ctx); + else if (choice === 'help') await helpMenu(currentLang); + } +} + +export interface RunWizardOptions { + /** init 模式:跳过 first-run 探测、固定进入完整向导 */ + force?: boolean; +} + +export async function runWizard(opts: RunWizardOptions = {}): Promise { + const ctx: WizardContext = { adminServer: null, startedAt: new Date() }; + + // 1. 语言初始化(已保存则直接用,否则首次询问) + const saved = readSavedLang(); + let lang: CliLang; + if (saved) { + lang = saved; + } else { + lang = detectDefaultLang(); + lang = await pickLanguage(lang); + } + let m = getMessages(lang); + + // 2. Banner + console.log('\n' + m.banner(VERSION)); + console.log(opts.force ? m.bannerForcedInit : m.bannerFirstRun); + console.log(''); + + // 3. 入口选择 + const entry = await safe(() => + select({ + message: m.entryTitle, + pageSize: 8, + choices: [ + { value: 'tui', name: m.entryConfigViaTui }, + { value: 'web', name: m.entryConfigViaWeb }, + { value: 'start', name: m.entryStartService }, + { value: 'help', name: m.entryHelp }, + { value: 'exit', name: m.entryExit }, + ], + }), + ); + + if (!entry || entry === 'exit') { + console.log(m.bye); + return { startService: false, webStartedInWizard: false }; + } + + if (entry === 'help') { + await helpMenu(lang); + // 帮助看完后回到入口 + return runWizard(opts); + } + + if (entry === 'start') { + return { startService: true, webStartedInWizard: false }; + } + + if (entry === 'web') { + // 启动 admin server,让用户去浏览器配置;进程将由 startBridge() 接管或单独保留 + const cur = configStore.get(); + const portNum = parseInt(cur.ADMIN_PORT || '4098', 10) || 4098; + const { createAdminServer } = await import('../admin/admin-server.js'); + const srv = createAdminServer({ port: portNum, startedAt: ctx.startedAt, version: VERSION }); + srv.start(); + ctx.adminServer = srv; + console.log('\n' + m.webStarted(`http://localhost:${portNum}`) + '\n'); + // Web 模式下也启动桥接服务(进程合并),以便平台适配器能立即工作 + return { startService: true, webStartedInWizard: true }; + } + + // entry === 'tui':先做"首次接入平台"挑选,再进入主菜单 + if (!opts.force && !hasAnyPlatformConfigured()) { + await pickInitialPlatform(lang); + } + + m = getMessages(lang); + return mainMenuLoop(lang, ctx); +} diff --git a/src/commands/parser.ts b/src/commands/parser.ts index 64e47c7..268d577 100644 --- a/src/commands/parser.ts +++ b/src/commands/parser.ts @@ -24,6 +24,7 @@ export type CommandType = | 'panel' // 控制面板 | 'effort' // 调整推理强度 | 'admin' // 管理员设置 + | 'config' // 当前聊天配置 | 'help' // 显示帮助 | 'status' // 查看状态 | 'command' // 透传命令 @@ -31,7 +32,9 @@ export type CommandType = | 'send' // 发送文件到飞书 | 'rename' // 重命名当前会话 | 'cron' // Cron 调度管理 - | 'restart'; // 重启服务组件 + | 'restart' // 重启服务组件 + | 'qc' // 快捷命令卡 + | 'session_ctl'; // 会话控制面板 // 解析后的命令 export interface ParsedCommand { @@ -39,8 +42,6 @@ export interface ParsedCommand { text?: string; // prompt类型的文本内容 modelName?: string; // model类型的模型名称 agentName?: string; // agent类型的名称 - roleAction?: 'create'; - roleSpec?: string; sessionAction?: 'new' | 'switch'; sessionId?: string; // session switch的目标ID sessionDirectory?: string; // session new 时指定的目录 @@ -59,6 +60,9 @@ export interface ParsedCommand { effortReset?: boolean; promptEffort?: EffortLevel; adminAction?: 'add'; + configScope?: 'session' | 'output'; + configKey?: 'order' | 'help_with_qc' | 'session_with_ctl' | 'session_with_change' | 'only_text'; + configValue?: string; renameTitle?: string; // rename 类型的新会话名称(可选,无参数时弹卡片) cronAction?: CronIntentAction; cronArgs?: string; @@ -189,16 +193,6 @@ export function parseCommand(text: string): ParsedCommand { return bangCommand; } - // 中文自然语言创建角色(不带 /) - const textRoleCreateMatch = trimmed.match(/^创建角色\s+([\s\S]+)$/); - if (textRoleCreateMatch) { - return { - type: 'role', - roleAction: 'create', - roleSpec: textRoleCreateMatch[1].trim(), - }; - } - // 中文自然语言发送文件(不带 /) const sendFileMatch = trimmed.match(/^发送文件\s+([\s\S]+)$/); if (sendFileMatch) { @@ -272,7 +266,7 @@ export function parseCommand(text: string): ParsedCommand { return { type: 'model' }; // 无参数时显示当前模型 case 'models': - return { type: 'models' }; // 列出所有可用模型 + return { type: 'models', listAll: args.length > 0 && args[0].toLowerCase() === 'all' }; case 'agent': if (args.length > 0) { @@ -284,16 +278,9 @@ export function parseCommand(text: string): ParsedCommand { return { type: 'agents' }; // 列出所有可用角色 case 'role': - case '角色': { - if (args.length > 0 && (args[0].toLowerCase() === 'create' || args[0] === '创建')) { - return { - type: 'role', - roleAction: 'create', - roleSpec: args.slice(1).join(' ').trim(), - }; - } + case '角色': + // 角色创建功能已迁移至资源管理系统,此处仅保留role类型用于其他扩展 return { type: 'role' }; - } case 'session': if (args.length === 0) { @@ -388,6 +375,13 @@ export function parseCommand(text: string): ParsedCommand { case 'controls': return { type: 'panel' }; + case 'qc': + return { type: 'qc' }; + + case 'session_ctl': + case 'session-ctl': + return { type: 'session_ctl' }; + case 'effort': case 'strength': { if (args.length === 0) { @@ -424,6 +418,55 @@ export function parseCommand(text: string): ParsedCommand { case 'add_admin': return { type: 'admin', adminAction: 'add' }; + case 'config': { + if (args.length >= 2 && args[0].toLowerCase() === 'session' && args[1].toLowerCase() === 'order') { + return { + type: 'config', + configScope: 'session', + configKey: 'order', + ...(args[2] ? { configValue: args[2].trim().toLowerCase() } : {}), + }; + } + + if (args.length >= 2 && args[0].toLowerCase() === 'session' && args[1].toLowerCase() === 'help_with_qc') { + return { + type: 'config', + configScope: 'session', + configKey: 'help_with_qc', + ...(args[2] ? { configValue: args[2].trim().toLowerCase() } : {}), + }; + } + + if (args.length >= 2 && args[0].toLowerCase() === 'session' && args[1].toLowerCase() === 'session_with_ctl') { + return { + type: 'config', + configScope: 'session', + configKey: 'session_with_ctl', + ...(args[2] ? { configValue: args[2].trim().toLowerCase() } : {}), + }; + } + + if (args.length >= 2 && args[0].toLowerCase() === 'session' && args[1].toLowerCase() === 'session_with_change') { + return { + type: 'config', + configScope: 'session', + configKey: 'session_with_change', + ...(args[2] ? { configValue: args[2].trim().toLowerCase() } : {}), + }; + } + + if (args.length >= 2 && args[0].toLowerCase() === 'output' && args[1].toLowerCase() === 'onlytext') { + return { + type: 'config', + configScope: 'output', + configKey: 'only_text', + ...(args[2] ? { configValue: args[2].trim().toLowerCase() } : {}), + }; + } + + return { type: 'config' }; + } + case 'help': case 'h': case '?': @@ -484,6 +527,7 @@ export function getHelpText(): string { 🛠️ **常用命令** • \`/model\` 查看当前模型 • \`/model <名称>\` 切换模型 (e.g. \`/model gpt-4\`) +• \`/models\` 查看当前可选模型;\`/models all\` 展开全部当前可选模型 • \`/agent\` 查看当前角色 • \`/agent <名称>\` 切换角色 (e.g. \`/agent general\`) • \`/agent off\` 切回默认角色 @@ -492,7 +536,8 @@ export function getHelpText(): string { • \`/effort default\` 清除会话强度,恢复模型默认 • \`#xhigh 帮我深度分析这段代码\` 仅当前消息临时覆盖强度 • \`创建角色 名称=旅行助手; 描述=帮我做行程规划; 类型=主; 工具=webfetch\` 新建自定义角色 -• \`/panel\` 推送交互式控制面板卡片 ✨ +• \`/panel\` 推送模型与角色面板 ✨ +• \`/qc\` 推送快捷命令卡 • \`/undo\` 撤回上一轮对话 (如果你发错或 AI 答错) • \`/stop\` 停止当前正在生成的回答 • \`/compact\` 压缩当前会话上下文(调用 OpenCode summarize) @@ -502,9 +547,14 @@ export function getHelpText(): string { • \`/session new\` 开启新话题(重置上下文);\`/session new <别名或路径>\` 指定项目 • \`/session new --name <名称>\` 创建时直接命名 (e.g. \`/session new --name 技术架构评审\`) • \`/rename <新名称>\` 随时重命名当前会话 (e.g. \`/rename Q3后端API设计讨论\`) +• \`/session_ctl\` 推送会话控制面板(重命名 / 新建 / 切换) • \`/session \` 手动绑定已有会话(需开启 \`ENABLE_MANUAL_SESSION_BIND\`) • \`/create_chat\` 或 \`/建群\` 私聊中调出建群卡片(新建或绑定已有会话) • \`/project list\` 列出可用项目;\`/project default\` 查看/设置/清除群默认项目 +• \`/config session order default|last_time\` 切换会话列表排序方式(默认排序 / 按最后修改时间倒序) +• \`/config session help_with_qc true|false\` 控制 /help 后是否跟随推送 /qc(默认 false) +• \`/config session session_with_ctl true|false\` 控制 /sessions 后是否跟随推送 /session_ctl(默认 false) +• \`/config session session_with_change true|false\` 控制会话列表中是否展示“切换至此Session”按钮(默认 false) • \`/clear\` 等价 \`/session new\`;\`/clear free session\` 清理空闲群聊并手动扫描僵尸 Cron • \`/status\` 查看当前绑定状态和群聊生命周期信息 • \`/commands\` 生成并发送最新命令清单文件 @@ -524,5 +574,14 @@ export function getHelpText(): string { • \`发送文件 <路径或描述>\` 中文自然语言触发(同上) • \`/restart opencode\` 重启本地 OpenCode 进程(仅 loopback) +🎯 **资源管理** +• 智能体/技能/MCP服务器管理功能已迁移至新的资源管理系统 +• Web UI: 访问 http://host:port/resources 进行可视化管理 +• CLI: 使用 \`opencode-bridge bridge resource\` 命令管理 + • \`bridge resource agent --help\` 管理智能体 + • \`bridge resource skill --help\` 管理技能 + • \`bridge resource mcp --help\` 管理MCP服务器 + • \`bridge resource model --help\` 管理模型提供商 + ${cronHelpBlock}`; } diff --git a/src/config/migrator.ts b/src/config/migrator.ts index bd32b5d..5addb22 100644 --- a/src/config/migrator.ts +++ b/src/config/migrator.ts @@ -50,6 +50,7 @@ const MIGRATABLE_KEYS: (keyof BridgeSettings)[] = [ 'OUTPUT_UPDATE_INTERVAL', 'MAX_DELAYED_RESPONSE_WAIT_MS', 'ENABLE_MANUAL_SESSION_BIND', 'ROUTER_MODE', 'ATTACHMENT_MAX_SIZE', 'DEFAULT_PROVIDER', 'DEFAULT_MODEL', + 'IMAGE_VISION_PREPROCESS', 'VISION_OCR_MODEL', 'VISION_OCR_PROMPT', ]; /** diff --git a/src/config/platform.ts b/src/config/platform.ts index 37ee277..c715401 100644 --- a/src/config/platform.ts +++ b/src/config/platform.ts @@ -167,8 +167,19 @@ export const opencodeConfig = { get port() { return parseInt(process.env.OPENCODE_PORT || '4096', 10); }, get serverUsername() { return process.env.OPENCODE_SERVER_USERNAME?.trim() || 'opencode'; }, get serverPassword() { return process.env.OPENCODE_SERVER_PASSWORD?.trim() || undefined; }, - get autoStart() { return parseBooleanEnv(process.env.OPENCODE_AUTO_START, false); }, + get autoStart() { return parseBooleanEnv(process.env.OPENCODE_AUTO_START, true); }, + /** @deprecated 不再使用,保留仅供旧配置读取迁移 */ get autoStartCmd() { return process.env.OPENCODE_AUTO_START_CMD?.trim() || 'opencode serve'; }, + /** + * 后台启动成功后是否同时弹出前台 attach 窗口(Windows 专用) + * Windows 默认开启:后台 serve 就绪后会自动弹出一个 CMD 窗口运行 `opencode attach http://localhost:`, + * 方便在独立窗口里与 opencode CLI 交互。设置 OPENCODE_AUTO_START_FOREGROUND=false 可关闭。 + * 非 Windows 平台始终关闭(attach 窗口功能依赖 Windows cmd /start 弹窗机制)。 + */ + get autoStartForeground() { + const defaultValue = process.platform === 'win32'; + return parseBooleanEnv(process.env.OPENCODE_AUTO_START_FOREGROUND, defaultValue); + }, get baseUrl() { return `http://${this.host}:${this.port}`; }, @@ -198,6 +209,24 @@ export const modelConfig = { const model = process.env.DEFAULT_MODEL?.trim(); return provider && model ? model : undefined; }, + get chatModelWhitelist(): string[] { + const raw = process.env.CHAT_MODEL_WHITELIST?.trim(); + if (!raw) return []; + + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((item): item is string => typeof item === 'string') + .map(item => item.trim()) + .filter(Boolean); + } catch { + return raw + .split(/[\r\n,;]+/) + .map(item => item.trim()) + .filter(Boolean); + } + }, }; export const permissionConfig = { @@ -284,6 +313,39 @@ export const attachmentConfig = { get maxSize() { return parseInt(process.env.ATTACHMENT_MAX_SIZE || String(50 * 1024 * 1024), 10); }, }; +/** + * 默认 OCR 提示词(仅当用户未配置 VISION_OCR_PROMPT 时使用) + */ +export const DEFAULT_VISION_OCR_PROMPT = + '请详细描述这张图片的内容,包括所有可见的文字、表格、结构、人物和关键视觉信息。输出中文描述。'; + +/** + * 非多模态模型图片预处理配置 + * + * 当主模型不支持 image 输入时,bridge 借用 opencode 内已配置的多模态 model + * 做 OCR / 图片描述,把结果注入为 text part 后再转发给主模型。 + * + * 这三个字段都通过 SQLite + Web UI 维护,不走 .env。 + */ +export const visionPreprocessConfig = { + /** 功能总开关 */ + get enabled() { return parseBooleanEnv(process.env.IMAGE_VISION_PREPROCESS, false); }, + /** 存储格式:"providerID/modelID",空串代表未配置 */ + get modelRef() { return process.env.VISION_OCR_MODEL?.trim() || ''; }, + /** 解析后的 provider/model 对,未配置返回 undefined */ + get model(): { providerID: string; modelID: string } | undefined { + const raw = this.modelRef; + if (!raw) return undefined; + const slash = raw.indexOf('/'); + if (slash <= 0 || slash === raw.length - 1) return undefined; + const providerID = raw.slice(0, slash).trim(); + const modelID = raw.slice(slash + 1).trim(); + if (!providerID || !modelID) return undefined; + return { providerID, modelID }; + }, + get prompt() { return process.env.VISION_OCR_PROMPT?.trim() || DEFAULT_VISION_OCR_PROMPT; }, +}; + function parseProjectAliases(value: string | undefined): Record { if (!value) return {}; try { @@ -475,4 +537,4 @@ export function isPlatformConfigured(platform: 'feishu' | 'discord' | 'wecom' | default: return false; } -} \ No newline at end of file +} diff --git a/src/feishu/cards.ts b/src/feishu/cards.ts index 9182a17..f5e4fda 100644 --- a/src/feishu/cards.ts +++ b/src/feishu/cards.ts @@ -215,6 +215,185 @@ export function buildStatusCard(data: StatusCardData): object { }; } +export interface MarkdownCardPage { + title: string; + markdown: string; + template?: 'blue' | 'green' | 'red' | 'orange' | 'grey'; +} + +export function buildMarkdownCard(page: MarkdownCardPage): object { + const content = page.markdown.trim() || '(无内容)'; + return { + schema: '2.0', + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: 'plain_text', + content: page.title, + }, + template: page.template || 'blue', + }, + body: { + elements: [ + { + tag: 'markdown', + content, + }, + ], + }, + }; +} + +export interface HelpShortcutAction { + label: string; + command: string; +} + +export interface ShortcutCardData { + title: string; + description?: string; + chatId: string; + chatType: 'p2p' | 'group'; + shortcuts: HelpShortcutAction[]; + template?: 'blue' | 'green' | 'red' | 'orange' | 'grey'; +} + +export interface HelpCardData { + title: string; + markdown: string; + chatId: string; + chatType: 'p2p' | 'group'; + shortcuts: HelpShortcutAction[]; + template?: 'blue' | 'green' | 'red' | 'orange' | 'grey'; +} + +export function buildHelpCard(data: HelpCardData): object { + const shortcutRows: HelpShortcutAction[][] = []; + for (let index = 0; index < data.shortcuts.length; index += 3) { + shortcutRows.push(data.shortcuts.slice(index, index + 3)); + } + + const elements: object[] = [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: data.markdown.trim() || '(无内容)', + }, + }, + ]; + + if (shortcutRows.length > 0) { + elements.push({ + tag: 'hr', + }); + elements.push({ + tag: 'markdown', + content: '**快捷命令**\n点击后会直接执行对应命令。', + }); + + for (const row of shortcutRows) { + elements.push({ + tag: 'action', + actions: row.map(item => ({ + tag: 'button', + text: { + tag: 'plain_text', + content: item.label, + }, + type: 'default', + value: { + action: 'help_run_command', + command: item.command, + chatId: data.chatId, + chatType: data.chatType, + }, + })), + }); + } + } + + return { + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: 'plain_text', + content: data.title, + }, + template: data.template || 'blue', + }, + elements, + }; +} + +export function buildShortcutCommandCard(data: ShortcutCardData): object { + const shortcutRows: HelpShortcutAction[][] = []; + for (let index = 0; index < data.shortcuts.length; index += 3) { + shortcutRows.push(data.shortcuts.slice(index, index + 3)); + } + + const resolveShortcutActionValue = (item: HelpShortcutAction): Record => { + if (item.command.trim() === '/create_chat') { + return { + action: 'create_chat', + chatId: data.chatId, + chatType: data.chatType, + }; + } + + return { + action: 'help_run_command', + command: item.command, + chatId: data.chatId, + chatType: data.chatType, + }; + }; + + const elements: object[] = []; + if (data.description?.trim()) { + elements.push({ + tag: 'div', + text: { + tag: 'lark_md', + content: data.description.trim(), + }, + }); + } + + for (const row of shortcutRows) { + elements.push({ + tag: 'action', + actions: row.map(item => ({ + tag: 'button', + text: { + tag: 'plain_text', + content: item.label, + }, + type: 'default', + value: resolveShortcutActionValue(item), + })), + }); + } + + return { + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: 'plain_text', + content: data.title, + }, + template: data.template || 'blue', + }, + elements, + }; +} + // 控制面板卡片 export interface ControlCardData { conversationKey: string; @@ -245,7 +424,7 @@ export function buildControlCard(data: ControlCardData): object { header: { title: { tag: 'plain_text', - content: '🎛️ 会话控制面板', + content: '🎛️ 模型与角色面板', }, template: 'blue', }, @@ -300,6 +479,240 @@ export function buildControlCard(data: ControlCardData): object { }; } +export interface SessionCtlSessionOption { + label: string; + value: string; +} + +export interface SessionControlCardData { + chatId: string; + chatType: 'p2p' | 'group'; + currentDirectory: string; + currentSessionId: string; + currentSessionTitle: string; + selectedSessionId: string; + sessionOptions: SessionCtlSessionOption[]; + totalSessionCount?: number; +} + +export const SESSION_CTL_CURRENT_VALUE = '__current_session__'; +export const SESSION_CTL_NEW_VALUE = '__new_session__'; + +export function buildSessionControlCard(data: SessionControlCardData): object { + const shownExistingCount = data.sessionOptions.filter(option => + option.value !== SESSION_CTL_CURRENT_VALUE && option.value !== SESSION_CTL_NEW_VALUE + ).length; + const totalSessionCount = typeof data.totalSessionCount === 'number' && data.totalSessionCount >= shownExistingCount + ? data.totalSessionCount + : shownExistingCount; + const selected = data.selectedSessionId || SESSION_CTL_CURRENT_VALUE; + const formElements: object[] = [ + { + tag: 'select_static', + name: 'session_target', + placeholder: { tag: 'plain_text', content: '选择会话' }, + options: data.sessionOptions.map(option => ({ + text: { tag: 'plain_text', content: option.label }, + value: option.value, + })), + }, + { + tag: 'input', + name: 'session_name', + placeholder: { + tag: 'plain_text', + content: '会话名称(切换到其他会话时可留空)', + }, + }, + ]; + + formElements.push({ + tag: 'button', + text: { tag: 'plain_text', content: '确认提交' }, + type: 'primary', + action_type: 'form_submit', + name: 'session_ctl_submit', + value: { + action: 'session_ctl_submit', + chatId: data.chatId, + chatType: data.chatType, + selectedSessionId: selected, + }, + }); + + return { + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: 'plain_text', + content: '🧭 会话控制面板', + }, + template: 'blue', + }, + elements: [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: [ + `**当前会话工作区目录**: \`${data.currentDirectory}\``, + `**SessionID**: \`${data.currentSessionId}\``, + `**OpenCode侧会话名称**: ${data.currentSessionTitle}`, + ].join('\n'), + }, + }, + { + tag: 'form', + name: 'session_ctl_form', + elements: formElements, + }, + { + tag: 'note', + elements: [ + { + tag: 'plain_text', + content: [ + '选择“当前会话”时:输入名称后提交,将修改当前会话名称。', + '选择“新建 OpenCode 会话”时:名称可留空,留空则使用默认命名规则。', + '选择其他会话时:将先切换到目标会话;若填写名称,则会在切换后顺带重命名该会话。', + totalSessionCount > shownExistingCount + ? `当前仅展示最近 ${shownExistingCount} 个可切换会话(总计 ${totalSessionCount} 个)。` + : '未主动选择时默认按“当前会话”处理。', + ].join('\n'), + }, + ], + }, + ], + }; +} + +export interface SessionListActionCardData { + title: string; + sessionId: string; + markdown: string; + chatId: string; + chatType: 'p2p' | 'group'; +} + +export function buildSessionListActionCard(data: SessionListActionCardData): object { + return { + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: 'plain_text', + content: data.title, + }, + template: 'blue', + }, + elements: [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: data.markdown, + }, + }, + { + tag: 'action', + actions: [ + { + tag: 'button', + text: { + tag: 'plain_text', + content: '切换至此Session', + }, + type: 'primary', + value: { + action: 'session_list_switch', + sessionId: data.sessionId, + chatId: data.chatId, + chatType: data.chatType, + }, + }, + ], + }, + ], + }; +} + +export interface SessionListCardEntry { + sessionId: string; + markdown: string; +} + +export interface SessionListCardData { + title: string; + summaryMarkdown: string; + chatId: string; + chatType: 'p2p' | 'group'; + entries: SessionListCardEntry[]; +} + +export function buildSessionListCard(data: SessionListCardData): object { + const elements: object[] = [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: data.summaryMarkdown.trim() || '(无内容)', + }, + }, + ]; + + data.entries.forEach((entry, index) => { + elements.push({ + tag: 'div', + text: { + tag: 'lark_md', + content: entry.markdown, + }, + }); + elements.push({ + tag: 'action', + actions: [ + { + tag: 'button', + text: { + tag: 'plain_text', + content: '切换至此Session', + }, + type: 'primary', + value: { + action: 'session_list_switch', + sessionId: entry.sessionId, + chatId: data.chatId, + chatType: data.chatType, + }, + }, + ], + }); + + if (index < data.entries.length - 1) { + elements.push({ + tag: 'hr', + }); + } + }); + + return { + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: 'plain_text', + content: data.title, + }, + template: 'blue', + }, + elements, + }; +} + // AI 提问卡片 (question 工具) export interface QuestionOption { label: string; diff --git a/src/feishu/client.ts b/src/feishu/client.ts index d718671..d9af0de 100644 --- a/src/feishu/client.ts +++ b/src/feishu/client.ts @@ -402,7 +402,11 @@ class FeishuClient extends EventEmitter { }); // 创建事件分发器 - this.eventDispatcher = new lark.EventDispatcher({ + this.eventDispatcher = this.createEventDispatcher(); + } + + private createEventDispatcher(): lark.EventDispatcher { + return new lark.EventDispatcher({ encryptKey: feishuConfig.encryptKey, verificationToken: feishuConfig.verificationToken, }); @@ -1337,6 +1341,9 @@ class FeishuClient extends EventEmitter { this.wsClient.close(); this.wsClient = null; } + this.eventDispatcher = this.createEventDispatcher(); + this.cardActionHandler = undefined; + this.cardUpdateQueue.clear(); this.connectionState = 'disconnected'; console.log('[飞书] 已断开连接'); } diff --git a/src/feishu/streamer.ts b/src/feishu/streamer.ts index 3d47bb5..733f7a5 100644 --- a/src/feishu/streamer.ts +++ b/src/feishu/streamer.ts @@ -1,5 +1,5 @@ -import { feishuClient } from './client.js'; import { buildStreamCard } from './cards-stream.js'; +import { feishuClient } from './client.js'; export interface StreamState { text: string; @@ -15,6 +15,8 @@ export interface StreamState { export class CardStreamer { private chatId: string; private messageId: string | null = null; + private finalMessageId: string | null = null; + private deletedStreamingMessageId: string | null = null; private state: StreamState = { text: '', thinking: '', @@ -24,6 +26,7 @@ export class CardStreamer { private lastUpdate: number = 0; private throttleMs: number = 500; private pendingUpdate: NodeJS.Timeout | null = null; + private finalDeliveryPromise: Promise | null = null; constructor(chatId: string) { this.chatId = chatId; @@ -90,6 +93,28 @@ export class CardStreamer { this.lastUpdate = Date.now(); const card = this.buildCard(); await feishuClient.updateCard(this.messageId, card); + + if (this.state.status !== 'processing' && !this.finalMessageId && !this.finalDeliveryPromise) { + this.finalDeliveryPromise = (async () => { + const streamingMessageId = this.messageId; + try { + const finalMessageId = await feishuClient.sendCard(this.chatId, card); + if (finalMessageId) { + this.finalMessageId = finalMessageId; + if (streamingMessageId && this.deletedStreamingMessageId !== streamingMessageId) { + const deleted = await feishuClient.deleteMessage(streamingMessageId); + if (deleted) { + this.deletedStreamingMessageId = streamingMessageId; + } + } + } + } finally { + this.finalDeliveryPromise = null; + } + })(); + + await this.finalDeliveryPromise; + } } private buildCard(): object { diff --git a/src/handlers/card-action.ts b/src/handlers/card-action.ts index b3e84bb..2ef04c5 100644 --- a/src/handlers/card-action.ts +++ b/src/handlers/card-action.ts @@ -7,6 +7,9 @@ import { outputBuffer } from '../opencode/output-buffer.js'; import { commandHandler } from './command.js'; import type { FeishuCardActionEvent } from '../feishu/client.js'; import { isCompletionNotFoundError } from '../feishu/client.js'; +import { isChatModelAllowed, parseChatModelReference } from '../utils/chat-model-whitelist.js'; +import { parseCommand } from '../commands/parser.js'; +import { p2pHandler } from './p2p.js'; export class CardActionHandler { private extractSelectedOption(value: unknown): string | undefined { @@ -51,9 +54,14 @@ export class CardActionHandler { return this.handleAgentSelect(actionValue, event); case 'toggle_thinking': return this.handleToggleThinking(actionValue, event); + case 'help_run_command': + return this.handleHelpRunCommand(actionValue, event); + case 'session_ctl_submit': + return this.handleSessionCtlSubmit(actionValue, event); + case 'session_list_switch': + return this.handleSessionListSwitch(actionValue, event); case 'create_chat': - // P2P 创建会话,由 p2pHandler 处理 - return; + return this.handleCreateChatAction(event); case 'permission_allow': case 'permission_deny': // 权限确认,由 index.ts 直接处理 @@ -149,6 +157,17 @@ export class CardActionHandler { return { toast: { type: 'error', content: '参数错误' } }; } + const parsedModel = parseChatModelReference(selectedOption); + if (parsedModel && !isChatModelAllowed(parsedModel.providerId, parsedModel.modelId)) { + return { + toast: { + type: 'error', + content: '该模型不在当前允许列表中', + i18n_content: { zh_cn: '该模型不在当前允许列表中', en_us: 'This model is not allowed by the current whitelist' } + } + }; + } + // 更新配置 chatSessionStore.updateConfig(chatId, { preferredModel: selectedOption }); console.log(`[CardAction] 已切换模型: ${selectedOption}`); @@ -196,6 +215,147 @@ export class CardActionHandler { // 兼容历史卡片按钮:思考展开已改为飞书原生折叠面板,无需回调更新。 return { msg: 'ok' }; } + + private async handleHelpRunCommand(value: any, event: FeishuCardActionEvent): Promise { + const chatId = typeof value?.chatId === 'string' ? value.chatId : event.chatId; + const commandText = typeof value?.command === 'string' ? value.command.trim() : ''; + const chatType = value?.chatType === 'p2p' ? 'p2p' : 'group'; + const messageId = event.messageId; + + if (!chatId || !commandText || !messageId) { + return { + toast: { + type: 'error', + content: '命令参数缺失', + i18n_content: { zh_cn: '命令参数缺失', en_us: 'Missing command parameters' } + } + }; + } + + if (commandText === '/compact') { + commandHandler.startCompactInBackground(chatId, messageId); + return { + toast: { + type: 'success', + content: '已开始压缩上下文,完成后会通过新消息通知', + i18n_content: { zh_cn: '已开始压缩上下文,完成后会通过新消息通知', en_us: 'Compaction started and will notify when done' } + } + }; + } + + const command = parseCommand(commandText); + try { + await commandHandler.handle(command, { + chatId, + messageId, + senderId: event.openId, + chatType, + }); + return { + toast: { + type: 'success', + content: `已执行 ${commandText}`, + i18n_content: { zh_cn: `已执行 ${commandText}`, en_us: `Executed ${commandText}` } + } + }; + } catch (error) { + console.error('[CardAction] Help shortcut execution failed:', error); + return { + toast: { + type: 'error', + content: `执行失败: ${commandText}`, + i18n_content: { zh_cn: `执行失败: ${commandText}`, en_us: `Failed: ${commandText}` } + } + }; + } + } + + private async handleSessionCtlSubmit(value: any, event: FeishuCardActionEvent): Promise { + const chatId = typeof value?.chatId === 'string' ? value.chatId : event.chatId; + const chatType = value?.chatType === 'p2p' ? 'p2p' : 'group'; + const messageId = event.messageId; + const eventAny = event as unknown as { action?: { form_value?: Record } }; + const formValue = eventAny.action?.form_value; + const selectedSessionId = formValue?.session_target?.trim() + || this.extractSelectedOption(value.selectedSessionId) + || ''; + const sessionName = formValue?.session_name?.trim() || ''; + + if (!chatId || !messageId || !selectedSessionId) { + return { + toast: { + type: 'error', + content: '提交参数缺失', + i18n_content: { zh_cn: '提交参数缺失', en_us: 'Missing submit parameters' } + } + }; + } + + try { + await commandHandler.handleSessionControlSubmit( + chatId, + messageId, + event.openId, + chatType, + selectedSessionId, + sessionName + ); + return { + toast: { + type: 'success', + content: '会话操作已执行', + i18n_content: { zh_cn: '会话操作已执行', en_us: 'Session operation completed' } + } + }; + } catch (error) { + console.error('[CardAction] Session control submit failed:', error); + const message = error instanceof Error ? error.message : '会话操作失败'; + return { + toast: { + type: 'error', + content: message, + i18n_content: { zh_cn: message, en_us: message } + } + }; + } + } + + private async handleSessionListSwitch(value: any, event: FeishuCardActionEvent): Promise { + const chatId = typeof value?.chatId === 'string' ? value.chatId : event.chatId; + const chatType = value?.chatType === 'p2p' ? 'p2p' : 'group'; + const targetSessionId = this.extractSelectedOption(value.sessionId) || ''; + + if (!chatId || !event.messageId || !targetSessionId) { + return { + toast: { + type: 'error', + content: '切换参数缺失', + i18n_content: { zh_cn: '切换参数缺失', en_us: 'Missing switch parameters' } + } + }; + } + + await commandHandler.switchSessionFromCard( + chatId, + event.messageId, + event.openId, + targetSessionId, + chatType + ); + + return { + toast: { + type: 'success', + content: '已执行会话切换', + i18n_content: { zh_cn: '已执行会话切换', en_us: 'Session switch requested' } + } + }; + } + + private async handleCreateChatAction(event: FeishuCardActionEvent): Promise { + const result = await p2pHandler.handleCardAction(event); + return result || { msg: 'ok' }; + } } export const cardActionHandler = new CardActionHandler(); diff --git a/src/handlers/command.ts b/src/handlers/command.ts index 2ca0694..de9dbda 100644 --- a/src/handlers/command.ts +++ b/src/handlers/command.ts @@ -9,8 +9,18 @@ import { type OpencodeAgentInfo, type OpencodeRuntimeConfig, } from '../opencode/client.js'; -import { chatSessionStore } from '../store/chat-session.js'; -import { buildControlCard, buildStatusCard } from '../feishu/cards.js'; +import { chatSessionStore, type SessionOrderMode } from '../store/chat-session.js'; +import { + buildControlCard, + buildMarkdownCard, + buildSessionControlCard, + buildSessionListCard, + buildShortcutCommandCard, + buildStatusCard, + SESSION_CTL_CURRENT_VALUE, + SESSION_CTL_NEW_VALUE, + type SessionCtlSessionOption, +} from '../feishu/cards.js'; import { writeCommandDoc, type CommandDocData } from '../commands/command-doc.js'; import { modelConfig, userConfig } from '../config.js'; import { sendFileToFeishu } from './file-sender.js'; @@ -21,55 +31,12 @@ import { getRuntimeCronManager } from '../reliability/runtime-cron-registry.js'; import { formatRestartResultText, restartOpenCodeProcess } from '../reliability/opencode-restart.js'; import { parseCronIntentWithOpenCode } from '../reliability/cron-semantic.js'; import { cleanupRuntimeCronJobsBySessionId, scanAndCleanupOrphanRuntimeCronJobs } from '../reliability/runtime-cron-orphan.js'; +import { isChatModelAllowed, parseChatModelReference } from '../utils/chat-model-whitelist.js'; -const SUPPORTED_ROLE_TOOLS = [ - 'bash', - 'read', - 'write', - 'edit', - 'list', - 'glob', - 'grep', - 'webfetch', - 'task', - 'todowrite', - 'todoread', -] as const; - -type RoleTool = typeof SUPPORTED_ROLE_TOOLS[number]; - -const ROLE_TOOL_ALIAS: Record = { - bash: 'bash', - shell: 'bash', - 命令行: 'bash', - 终端: 'bash', - read: 'read', - 读取: 'read', - 阅读: 'read', - write: 'write', - 写入: 'write', - edit: 'edit', - 编辑: 'edit', - list: 'list', - 列表: 'list', - glob: 'glob', - 文件匹配: 'glob', - grep: 'grep', - 搜索: 'grep', - webfetch: 'webfetch', - 网页: 'webfetch', - 抓取网页: 'webfetch', - task: 'task', - 子代理: 'task', - todowrite: 'todowrite', - 待办写入: 'todowrite', - todoread: 'todoread', - 待办读取: 'todoread', -}; - -const ROLE_CREATE_USAGE = '用法: 创建角色 名称=旅行助手; 描述=擅长制定旅行计划; 类型=主; 工具=webfetch; 提示词=先给出预算再做路线'; const INTERNAL_HIDDEN_AGENT_NAMES = new Set(['compaction', 'title', 'summary']); const PANEL_MODEL_OPTION_LIMIT = 500; +const SESSION_CTL_OPTION_LIMIT = 100; +const SESSION_CTL_EXISTING_LIMIT = SESSION_CTL_OPTION_LIMIT - 2; const EFFORT_USAGE_TEXT = '用法: /effort(查看) 或 /effort (设置) 或 /effort default(清除)'; const EFFORT_DISPLAY_ORDER = KNOWN_EFFORT_LEVELS; @@ -122,173 +89,506 @@ function normalizeAgentText(text: string): string { .trim(); } -interface RoleCreatePayload { - name: string; - description: string; - mode: 'primary' | 'subagent'; - tools?: Record; - prompt?: string; -} +export class CommandHandler { + private static readonly FEISHU_MARKDOWN_CHUNK_LIMIT = 3800; + private static readonly FEISHU_SESSION_CARD_ENTRY_LIMIT = 5; + private static readonly FEISHU_SESSION_CARD_TEXT_LIMIT = 5200; -type RoleCreateParseResult = - | { ok: true; payload: RoleCreatePayload } - | { ok: false; message: string }; + private splitFeishuMarkdown(markdown: string, limit: number = CommandHandler.FEISHU_MARKDOWN_CHUNK_LIMIT): string[] { + const trimmed = markdown.trim(); + if (!trimmed) { + return ['(无内容)']; + } -type RoleToolsParseResult = - | { ok: true; tools?: Record } - | { ok: false; message: string }; + if (trimmed.length <= limit) { + return [trimmed]; + } -function stripWrappingQuotes(value: string): string { - const trimmed = value.trim(); - if (trimmed.length < 2) return trimmed; - const first = trimmed[0]; - const last = trimmed[trimmed.length - 1]; - if ((first === '"' && last === '"') || (first === '\'' && last === '\'')) { - return trimmed.slice(1, -1).trim(); - } - return trimmed; -} + const lines = trimmed.split('\n'); + const chunks: string[] = []; + let current = ''; -function normalizeRoleMode(value: string): 'primary' | 'subagent' | undefined { - const normalized = value.trim().toLowerCase(); - if (normalized === '主' || normalized === 'primary') return 'primary'; - if (normalized === '子' || normalized === 'subagent') return 'subagent'; - return undefined; -} + for (const line of lines) { + const candidate = current ? `${current}\n${line}` : line; + if (candidate.length > limit && current) { + chunks.push(current.trim()); + current = line; + continue; + } + + if (line.length > limit) { + if (current) { + chunks.push(current.trim()); + current = ''; + } + + let rest = line; + while (rest.length > limit) { + chunks.push(rest.slice(0, limit)); + rest = rest.slice(limit); + } + current = rest; + continue; + } -function buildToolsConfig(value: string): RoleToolsParseResult { - const normalized = value.trim().toLowerCase(); - if (!normalized || normalized === '默认' || normalized === 'default' || normalized === '继承' || normalized === 'all' || normalized === '全部') { - return { ok: true }; + current = candidate; + } + + if (current.trim()) { + chunks.push(current.trim()); + } + + return chunks.length > 0 ? chunks : ['(无内容)']; } - const toolsConfig: Record = Object.fromEntries( - SUPPORTED_ROLE_TOOLS.map(tool => [tool, false]) - ); + private splitMarkdownBlocks( + header: string, + blocks: string[], + limit: number = CommandHandler.FEISHU_MARKDOWN_CHUNK_LIMIT + ): string[] { + const safeHeader = header.trim(); + const safeBlocks = blocks.map(block => block.trim()).filter(Boolean); + if (safeBlocks.length === 0) { + return [safeHeader || '(无内容)']; + } + + const separator = '\n\n---\n\n'; + const pages: string[] = []; + let currentBlocks: string[] = []; + + const buildPage = (items: string[]): string => { + const body = items.join(separator); + return safeHeader ? `${safeHeader}\n\n${body}` : body; + }; + + for (const block of safeBlocks) { + const candidateBlocks = [...currentBlocks, block]; + const candidatePage = buildPage(candidateBlocks); + if (candidatePage.length > limit && currentBlocks.length > 0) { + pages.push(buildPage(currentBlocks)); + currentBlocks = [block]; + continue; + } + + currentBlocks = candidateBlocks; + } + + if (currentBlocks.length > 0) { + pages.push(buildPage(currentBlocks)); + } - if (normalized === 'none' || normalized === '无' || normalized === '关闭' || normalized === 'off') { - return { ok: true, tools: toolsConfig }; + return pages.length > 0 ? pages : [safeHeader || '(无内容)']; } - const rawItems = value.split(/[,,\s]+/).map(item => item.trim()).filter(Boolean); - if (rawItems.length === 0) { - return { ok: true }; + private splitSessionCardEntries( + entries: Array<{ sessionId: string; markdown: string }>, + summaryMarkdown: string + ): Array> { + const pages: Array> = []; + let current: Array<{ sessionId: string; markdown: string }> = []; + let currentLength = summaryMarkdown.trim().length; + + for (const entry of entries) { + const entryLength = entry.markdown.trim().length + 80; + const exceedsCount = current.length >= CommandHandler.FEISHU_SESSION_CARD_ENTRY_LIMIT; + const exceedsLength = current.length > 0 + && currentLength + entryLength > CommandHandler.FEISHU_SESSION_CARD_TEXT_LIMIT; + + if (exceedsCount || exceedsLength) { + pages.push(current); + current = []; + currentLength = summaryMarkdown.trim().length; + } + + current.push(entry); + currentLength += entryLength; + } + + if (current.length > 0) { + pages.push(current); + } + + return pages.length > 0 ? pages : [[]]; } - const unsupported: string[] = []; - for (const rawItem of rawItems) { - const aliasKey = rawItem.toLowerCase(); - const mapped = ROLE_TOOL_ALIAS[aliasKey] || ROLE_TOOL_ALIAS[rawItem]; - if (!mapped) { - unsupported.push(rawItem); - continue; + private async replyFeishuMarkdown( + messageId: string, + chatId: string, + title: string, + markdown: string, + template: 'blue' | 'green' | 'red' | 'orange' | 'grey' = 'blue' + ): Promise { + const chunks = this.splitFeishuMarkdown(markdown); + const firstTitle = chunks.length > 1 ? `${title}(1/${chunks.length})` : title; + await feishuClient.replyCard(messageId, buildMarkdownCard({ + title: firstTitle, + markdown: chunks[0], + template, + })); + + for (let index = 1; index < chunks.length; index++) { + await feishuClient.sendCard(chatId, buildMarkdownCard({ + title: `${title}(${index + 1}/${chunks.length})`, + markdown: chunks[index], + template, + })); } - toolsConfig[mapped] = true; } - if (unsupported.length > 0) { - return { - ok: false, - message: `不支持的工具: ${unsupported.join(', ')}\n可用工具: ${SUPPORTED_ROLE_TOOLS.join(', ')}`, - }; + private formatHelpMarkdown(): string { + return getHelpText().replace(/^📖\s+\*\*.*?\*\*\s*\n*/u, '').trim(); } - return { ok: true, tools: toolsConfig }; -} + public isHelpWithQcEnabled(chatId: string): boolean { + return chatSessionStore.getSession(chatId)?.helpWithQc ?? false; + } -function parseRoleCreateSpec(spec: string): RoleCreateParseResult { - const raw = spec.trim(); - if (!raw) { - return { ok: false, message: `缺少角色参数\n${ROLE_CREATE_USAGE}` }; + public isSessionWithCtlEnabled(chatId: string): boolean { + return chatSessionStore.getSession(chatId)?.sessionWithCtl ?? false; } - const segments = raw.split(/[;;\n]+/).map(item => item.trim()).filter(Boolean); - if (segments.length === 0) { - return { ok: false, message: `缺少角色参数\n${ROLE_CREATE_USAGE}` }; + public isSessionWithChangeEnabled(chatId: string): boolean { + return chatSessionStore.getSession(chatId)?.sessionWithChange ?? false; } - let name = ''; - let description = ''; - let modeRaw = ''; - let toolsRaw = ''; - let prompt = ''; + private getQcShortcuts(): Array<{ label: string; command: string }> { + return [ + { label: '/panel', command: '/panel' }, + { label: '/session_ctl', command: '/session_ctl' }, + { label: '/create_chat', command: '/create_chat' }, + { label: '/model', command: '/model' }, + { label: '/models', command: '/models' }, + { label: '/status', command: '/status' }, + { label: '/project list', command: '/project list' }, + { label: '/compact', command: '/compact' }, + { label: '/commands', command: '/commands' }, + ]; + } - for (const segment of segments) { - const sepIndex = segment.search(/[=::]/); - if (sepIndex < 0) { - if (!name) { - name = stripWrappingQuotes(segment); - } - continue; - } + public async pushQcCard(chatId: string, chatType: 'p2p' | 'group' = 'group'): Promise { + await feishuClient.sendCard(chatId, buildShortcutCommandCard({ + title: '⚡ 快捷命令卡', + description: '点击按钮会直接执行对应命令。', + chatId, + chatType, + shortcuts: this.getQcShortcuts(), + })); + } - const key = segment.slice(0, sepIndex).trim().toLowerCase(); - const value = stripWrappingQuotes(segment.slice(sepIndex + 1)); - if (!value) continue; + public async replyQcCard(messageId: string, chatId: string, chatType: 'p2p' | 'group' = 'group'): Promise { + await feishuClient.replyCard(messageId, buildShortcutCommandCard({ + title: '⚡ 快捷命令卡', + description: '点击按钮会直接执行对应命令。', + chatId, + chatType, + shortcuts: this.getQcShortcuts(), + })); + } - if (key === '名称' || key === '名字' || key === '角色' || key === 'name' || key === 'role') { - name = value; - continue; + private getDisplayWidth(text: string): number { + let width = 0; + for (const char of text) { + width += /[^\u0000-\u00ff]/.test(char) ? 2 : 1; } + return width; + } - if (key === '描述' || key === '说明' || key === 'description' || key === 'desc') { - description = value; - continue; - } + private truncateByDisplayWidth(text: string, maxWidth: number, mode: 'start' | 'end' = 'end'): string { + const normalized = text.trim(); + if (!normalized) return ''; + if (this.getDisplayWidth(normalized) <= maxWidth) return normalized; + + const ellipsis = '...'; + const targetWidth = Math.max(0, maxWidth - this.getDisplayWidth(ellipsis)); + let collected = ''; + let usedWidth = 0; + const chars = [...normalized]; + const source = mode === 'start' ? [...chars].reverse() : chars; - if (key === '类型' || key === '模式' || key === 'mode') { - modeRaw = value; - continue; + for (const char of source) { + const charWidth = this.getDisplayWidth(char); + if (usedWidth + charWidth > targetWidth) break; + collected = mode === 'start' ? `${char}${collected}` : `${collected}${char}`; + usedWidth += charWidth; } - if (key === '工具' || key === 'tools' || key === 'tool') { - toolsRaw = value; - continue; + return mode === 'start' ? `${ellipsis}${collected}` : `${collected}${ellipsis}`; + } + + private formatSessionCtlOptionLabel(session: Awaited>[number], highlightWorkspace: boolean): string { + const title = typeof session.title === 'string' && session.title.trim().length > 0 + ? session.title.trim() + : '未命名会话'; + const compactTitle = this.truncateByDisplayWidth(title, 24, 'end'); + const directory = session.directory?.trim() || '/'; + const shortId = session.id.slice(0, 8); + const compactDirectory = this.truncateByDisplayWidth(directory, 16, 'start'); + const workspaceLabel = highlightWorkspace ? compactDirectory : compactDirectory; + return `${workspaceLabel} / ${shortId} / ${compactTitle}`; + } + + private getSessionLastModifiedTime(session: Awaited>[number]): number { + return session.time?.updated ?? session.time?.created ?? 0; + } + + private async resolveSessionLastActivityMap( + sessions: Awaited> + ): Promise> { + const entries = await Promise.all( + sessions.map(async session => { + try { + const activityTime = await opencodeClient.getSessionLastActivityTime(session.id); + return [session.id, activityTime || this.getSessionLastModifiedTime(session)] as const; + } catch { + return [session.id, this.getSessionLastModifiedTime(session)] as const; + } + }) + ); + + return new Map(entries); + } + + private async buildSessionCtlOptions(chatId: string): Promise<{ options: SessionCtlSessionOption[]; totalSessionCount: number }> { + const currentSessionId = chatSessionStore.getSessionId(chatId); + const allSessions = await opencodeClient.listSessionsAcrossProjects(); + const options: SessionCtlSessionOption[] = []; + + const currentSession = currentSessionId + ? allSessions.find(session => session.id === currentSessionId) + : undefined; + + options.push({ + label: currentSession + ? `当前会话:${this.formatSessionCtlOptionLabel(currentSession, true)}` + : '当前会话(默认)', + value: SESSION_CTL_CURRENT_VALUE, + }); + options.push({ label: '新建 OpenCode 会话', value: SESSION_CTL_NEW_VALUE }); + + const sessionOrderMode = this.getSessionOrderMode(chatId); + const sessionLastActivityMap = sessionOrderMode === 'last_time' + ? await this.resolveSessionLastActivityMap(allSessions) + : null; + const sortedSessions = [...allSessions].sort((a, b) => { + if (sessionOrderMode === 'last_time') { + const left = sessionLastActivityMap?.get(a.id) ?? this.getSessionLastModifiedTime(a); + const right = sessionLastActivityMap?.get(b.id) ?? this.getSessionLastModifiedTime(b); + if (left !== right) return right - left; + return a.id.localeCompare(b.id, 'en'); + } + + const directoryCompare = (a.directory || '/').localeCompare((b.directory || '/'), 'zh-Hans-CN'); + if (directoryCompare !== 0) return directoryCompare; + const left = this.getSessionLastModifiedTime(b); + const right = this.getSessionLastModifiedTime(a); + if (left !== right) return left - right; + return a.id.localeCompare(b.id, 'en'); + }); + + let previousDirectory = ''; + for (const session of sortedSessions.slice(0, SESSION_CTL_EXISTING_LIMIT)) { + const directory = session.directory?.trim() || '/'; + options.push({ + label: this.formatSessionCtlOptionLabel(session, directory !== previousDirectory), + value: session.id, + }); + previousDirectory = directory; } - if (key === '提示词' || key === 'prompt' || key === '系统提示' || key === '指令') { - prompt = value; + return { + options, + totalSessionCount: sortedSessions.length, + }; + } + + public async buildSessionControlCard(chatId: string, chatType: 'p2p' | 'group' = 'group', selectedSessionId: string = SESSION_CTL_CURRENT_VALUE): Promise { + const currentBinding = chatSessionStore.getSession(chatId); + const currentSessionId = currentBinding?.sessionId; + if (!currentSessionId) { + throw new Error('当前没有活跃会话'); } + + const currentSession = await opencodeClient.findSessionAcrossProjects(currentSessionId); + const sessionCtlData = await this.buildSessionCtlOptions(chatId); + return buildSessionControlCard({ + chatId, + chatType, + currentDirectory: currentSession?.directory || currentBinding.resolvedDirectory || currentBinding.defaultDirectory || '未知', + currentSessionId, + currentSessionTitle: currentSession?.title?.trim() || currentBinding.title || '未命名会话', + selectedSessionId, + sessionOptions: sessionCtlData.options, + totalSessionCount: sessionCtlData.totalSessionCount, + }); } - name = name.trim(); - if (!name) { - return { ok: false, message: `缺少角色名称\n${ROLE_CREATE_USAGE}` }; + public async pushSessionControlCard(chatId: string, chatType: 'p2p' | 'group' = 'group', selectedSessionId: string = SESSION_CTL_CURRENT_VALUE): Promise { + await feishuClient.sendCard(chatId, await this.buildSessionControlCard(chatId, chatType, selectedSessionId)); } - if (/\s/.test(name)) { - return { ok: false, message: '角色名称不能包含空格,请使用连续字符(可含中文)。' }; + public async replySessionControlCard(messageId: string, chatId: string, chatType: 'p2p' | 'group' = 'group', selectedSessionId: string = SESSION_CTL_CURRENT_VALUE): Promise { + await feishuClient.replyCard(messageId, await this.buildSessionControlCard(chatId, chatType, selectedSessionId)); } - if (name.length > 40) { - return { ok: false, message: '角色名称长度不能超过 40 个字符。' }; + public async updateSessionControlCard(messageId: string, chatId: string, chatType: 'p2p' | 'group' = 'group', selectedSessionId: string = SESSION_CTL_CURRENT_VALUE): Promise { + return await feishuClient.updateCard(messageId, await this.buildSessionControlCard(chatId, chatType, selectedSessionId)); } - let mode: 'primary' | 'subagent' = 'primary'; - if (modeRaw) { - const parsedMode = normalizeRoleMode(modeRaw); - if (!parsedMode) { - return { ok: false, message: '角色类型仅支持 主 / 子(或 primary / subagent)。' }; + public async replyHelpCard(messageId: string, chatId: string, chatType: 'p2p' | 'group' = 'group'): Promise { + await this.replyFeishuMarkdown( + messageId, + chatId, + '📖 飞书 × OpenCode 机器人指南', + this.formatHelpMarkdown() + ); + + if (this.isHelpWithQcEnabled(chatId)) { + await this.pushQcCard(chatId, chatType); } - mode = parsedMode; } - const toolsResult = buildToolsConfig(toolsRaw); - if (!toolsResult.ok) return { ok: false, message: toolsResult.message }; + private async replyProjectMarkdown( + messageId: string, + chatId: string, + title: string, + markdown: string, + template: 'blue' | 'green' | 'red' | 'orange' | 'grey' = 'blue' + ): Promise { + await this.replyFeishuMarkdown(messageId, chatId, title, markdown, template); + } + + public getSessionOrderMode(chatId: string): SessionOrderMode { + return chatSessionStore.getSession(chatId)?.sessionOrderMode || 'default'; + } - return { - ok: true, - payload: { - name, - description: description || `${name}(自定义角色)`, - mode, - ...(toolsResult.tools ? { tools: toolsResult.tools } : {}), - ...(prompt ? { prompt } : {}), - }, - }; -} + private formatSessionOrderMode(mode: SessionOrderMode): string { + return mode === 'last_time' ? '按最后修改时间倒序' : '默认排序'; + } + + private async handleConfig(chatId: string, messageId: string, command: ParsedCommand): Promise { + if (command.configScope !== 'session' || !command.configKey) { + await this.replyFeishuMarkdown( + messageId, + chatId, + '⚙️ 当前聊天配置', + [ + '**支持的配置命令**', + '- `/config session order` 查看当前会话排序模式', + '- `/config session order default` 使用默认排序', + '- `/config session order last_time` 按最后修改时间倒序', + '- `/config session help_with_qc true|false` 控制 /help 后是否推送 /qc', + '- `/config session session_with_ctl true|false` 控制 /sessions 后是否推送 /session_ctl', + '- `/config session session_with_change true|false` 控制 /sessions 是否展示会话切换按钮', + ].join('\n'), + 'orange' + ); + return; + } + + if ( + command.configKey === 'help_with_qc' + || command.configKey === 'session_with_ctl' + || command.configKey === 'session_with_change' + ) { + const currentValue = command.configKey === 'help_with_qc' + ? this.isHelpWithQcEnabled(chatId) + : command.configKey === 'session_with_ctl' + ? this.isSessionWithCtlEnabled(chatId) + : this.isSessionWithChangeEnabled(chatId); + const currentLabel = currentValue ? 'true' : 'false'; + + if (!command.configValue) { + const commandName = command.configKey; + const desc = command.configKey === 'help_with_qc' + ? '控制 /help 后是否跟随推送 /qc' + : command.configKey === 'session_with_ctl' + ? '控制 /sessions 后是否跟随推送 /session_ctl' + : '控制 /sessions 列表中是否展示“切换至此Session”按钮'; + await this.replyFeishuMarkdown( + messageId, + chatId, + '⚙️ 当前聊天配置', + [ + `配置项:**${commandName}**`, + `说明:${desc}`, + `当前值:\`${currentLabel}\``, + '', + '可选值:', + '- `true` 开启', + '- `false` 关闭', + ].join('\n') + ); + return; + } + + if (command.configValue !== 'true' && command.configValue !== 'false') { + await this.replyFeishuMarkdown( + messageId, + chatId, + '⚙️ 当前聊天配置', + '该配置仅支持 `true` 或 `false`。', + 'red' + ); + return; + } + + const boolValue = command.configValue === 'true'; + if (command.configKey === 'help_with_qc') { + chatSessionStore.updateConfig(chatId, { helpWithQc: boolValue }); + } else if (command.configKey === 'session_with_ctl') { + chatSessionStore.updateConfig(chatId, { sessionWithCtl: boolValue }); + } else { + chatSessionStore.updateConfig(chatId, { sessionWithChange: boolValue }); + } + + await this.replyFeishuMarkdown( + messageId, + chatId, + '✅ 当前聊天配置已更新', + `已将 **${command.configKey}** 设置为 \`${String(boolValue)}\``, + 'green' + ); + return; + } + + if (!command.configValue) { + const mode = this.getSessionOrderMode(chatId); + await this.replyFeishuMarkdown( + messageId, + chatId, + '⚙️ 会话排序配置', + [ + `当前模式:**${this.formatSessionOrderMode(mode)}**`, + '', + '可选值:', + '- `default` 默认排序', + '- `last_time` 按最后修改时间倒序', + ].join('\n') + ); + return; + } + + if (command.configValue !== 'default' && command.configValue !== 'last_time') { + await this.replyFeishuMarkdown( + messageId, + chatId, + '⚙️ 会话排序配置', + '不支持的排序模式。请使用 `/config session order default` 或 `/config session order last_time`。', + 'red' + ); + return; + } + + chatSessionStore.updateConfig(chatId, { sessionOrderMode: command.configValue }); + await this.replyFeishuMarkdown( + messageId, + chatId, + '✅ 会话排序配置已更新', + `当前模式已切换为:**${this.formatSessionOrderMode(command.configValue)}**`, + 'green' + ); + } -export class CommandHandler { private parseProviderModel(raw?: string): { providerId: string; modelId: string } | null { if (!raw) { return null; @@ -432,6 +732,9 @@ export class CommandHandler { if (!fallbackNormalized) { return; } + if (!isChatModelAllowed(providerId, fallbackNormalized)) { + return; + } const key = `${providerId.toLowerCase()}:${fallbackNormalized.toLowerCase()}`; if (dedupe.has(key)) { @@ -453,6 +756,9 @@ export class CommandHandler { if (!modelId) { return; } + if (!isChatModelAllowed(providerId, modelId)) { + return; + } const modelName = typeof modelRecord.name === 'string' && modelRecord.name.trim() ? modelRecord.name.trim() @@ -715,6 +1021,37 @@ export class CommandHandler { await feishuClient.reply(messageId, `✅ 上下文压缩完成(模型: ${model.providerId}:${model.modelId})`); } + public startCompactInBackground(chatId: string, triggerMessageId?: string): void { + void (async () => { + const sessionId = chatSessionStore.getSessionId(chatId); + if (!sessionId) { + await feishuClient.sendText(chatId, '❌ 当前没有活跃的会话,请先发送消息建立会话'); + return; + } + + const model = await this.resolveCompactModel(chatId); + if (!model) { + await feishuClient.sendText(chatId, '❌ 未找到可用模型,无法执行上下文压缩'); + return; + } + + try { + const compacted = await opencodeClient.summarizeSession(sessionId, model.providerId, model.modelId); + if (!compacted) { + await feishuClient.sendText(chatId, `❌ 上下文压缩失败(模型: ${model.providerId}:${model.modelId})`); + return; + } + + await feishuClient.sendText(chatId, `✅ 上下文压缩完成(模型: ${model.providerId}:${model.modelId})`); + } catch (error) { + console.error('[Command] 后台压缩失败:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + const prefix = triggerMessageId ? '❌ 上下文压缩执行失败' : '❌ 后台压缩执行失败'; + await feishuClient.sendText(chatId, `${prefix}: ${errorMessage}`); + } + })(); + } + private getPrivateSessionShortId(userId: string): string { const normalized = userId.startsWith('ou_') ? userId.slice(3) : userId; return normalized.slice(0, 4); @@ -742,7 +1079,11 @@ export class CommandHandler { try { switch (command.type) { case 'help': - await feishuClient.reply(messageId, getHelpText()); + await this.replyHelpCard(messageId, chatId, context.chatType); + break; + + case 'qc': + await this.replyQcCard(messageId, chatId, context.chatType); break; case 'status': @@ -769,10 +1110,25 @@ export class CommandHandler { } else if (command.projectAction === 'default_show') { await this.handleProjectDefaultShow(chatId, messageId); } else { - await feishuClient.reply(messageId, '用法: /project list 或 /project default set <路径或别名>'); + await this.replyProjectMarkdown( + messageId, + chatId, + '📁 项目命令', + [ + '**用法**', + '- `/project list` 查看可用项目', + '- `/project default` 查看当前群默认项目', + '- `/project default set <路径或别名>` 设置当前群默认项目', + '- `/project default clear` 清除当前群默认项目', + ].join('\n') + ); } break; + case 'config': + await this.handleConfig(chatId, messageId, command); + break; + case 'clear': console.log(`[Command] clear 命令, clearScope=${command.clearScope}`); if (command.clearScope === 'free_session') { @@ -814,6 +1170,10 @@ export class CommandHandler { await this.handleModel(chatId, messageId, context.senderId, context.chatType, command.modelName); break; + case 'models': + await this.handleModels(chatId, messageId, command.listAll ?? false); + break; + case 'agent': await this.handleAgent(chatId, messageId, context.senderId, context.chatType, command.agentName); break; @@ -823,11 +1183,8 @@ export class CommandHandler { break; case 'role': - if (command.roleAction === 'create') { - await this.handleRoleCreate(chatId, messageId, context.senderId, context.chatType, command.roleSpec || ''); - } else { - await feishuClient.reply(messageId, `支持的角色命令:\n- ${ROLE_CREATE_USAGE}`); - } + // 角色创建功能已迁移至资源管理系统 + await feishuClient.reply(messageId, `⚠️ 角色创建功能已迁移\n\n请使用以下方式管理智能体:\n• Web UI: 访问 http://host:port/resources\n• CLI: opencode-bridge bridge resource agent --help`); break; case 'undo': @@ -838,12 +1195,19 @@ export class CommandHandler { await this.handlePanel(chatId, messageId, context.chatType); break; + case 'session_ctl': + await this.replySessionControlCard(messageId, chatId, context.chatType); + break; + case 'commands': await this.handleCommandsCard(chatId, messageId); break; case 'sessions': await this.handleListSessions(chatId, messageId, command.listAll); + if (this.isSessionWithCtlEnabled(chatId)) { + await this.pushSessionControlCard(chatId, context.chatType); + } break; case 'send': @@ -963,7 +1327,12 @@ export class CommandHandler { // 尝试获取 session 详情? 暂时跳过 } - await feishuClient.reply(messageId, `🤖 **OpenCode 状态**\n\n${status}\n${extra}`); + await this.replyFeishuMarkdown( + messageId, + chatId, + '🤖 OpenCode 状态', + `${status}${extra ? `\n${extra}` : ''}` + ); } private async handleNewSession( @@ -1038,7 +1407,13 @@ export class CommandHandler { const projects = DirectoryPolicy.listAvailableProjects(knownDirs); if (projects.length === 0) { - await feishuClient.reply(messageId, DirectoryPolicy.buildProjectListEmptyMessage()); + await this.replyProjectMarkdown( + messageId, + chatId, + '📋 可用项目列表', + DirectoryPolicy.buildProjectListEmptyMessage(), + 'orange' + ); return; } @@ -1050,9 +1425,11 @@ export class CommandHandler { const chatDefault = chatSessionStore.getSession(chatId)?.defaultDirectory; const defaultLine = chatDefault ? `\n当前群默认: ${chatDefault}` : '\n当前群默认: 跟随全局'; - await feishuClient.reply( + await this.replyProjectMarkdown( messageId, - `📋 **可用项目列表**\n\n${lines.join('\n')}${defaultLine}\n\n使用 \`/session new <项目名或路径>\` 创建指定项目的会话` + chatId, + '📋 可用项目列表', + `${lines.join('\n')}${defaultLine}\n\n使用 \`/session new <项目名或路径>\` 创建指定项目的会话` ); } @@ -1064,12 +1441,24 @@ export class CommandHandler { ): Promise { if (action === 'clear') { chatSessionStore.updateConfig(chatId, { defaultDirectory: undefined }); - await feishuClient.reply(messageId, '✅ 已清除群默认项目,将跟随全局默认'); + await this.replyProjectMarkdown( + messageId, + chatId, + '✅ 默认项目已清除', + '已清除当前群默认项目,后续将跟随全局默认。', + 'green' + ); return; } if (!value) { - await feishuClient.reply(messageId, '用法: /project default set <路径或别名>'); + await this.replyProjectMarkdown( + messageId, + chatId, + '📁 设置默认项目', + '用法:`/project default set <路径或别名>`', + 'orange' + ); return; } @@ -1084,40 +1473,95 @@ export class CommandHandler { chatSessionStore.updateConfig(chatId, { defaultDirectory: dirResult.directory }); const label = dirResult.projectName ? ` (${dirResult.projectName})` : ''; - await feishuClient.reply(messageId, `✅ 已设置群默认项目: ${dirResult.directory}${label}`); + await this.replyProjectMarkdown( + messageId, + chatId, + '✅ 默认项目已设置', + `当前群默认项目已设置为:\n\`${dirResult.directory}${label}\``, + 'green' + ); } private async handleProjectDefaultShow(chatId: string, messageId: string): Promise { const chatDefault = chatSessionStore.getSession(chatId)?.defaultDirectory; if (chatDefault) { - await feishuClient.reply(messageId, `当前群默认项目: ${chatDefault}\n使用 \`/project default clear\` 清除`); + await this.replyProjectMarkdown( + messageId, + chatId, + '📁 当前默认项目', + `当前群默认项目:\n\`${chatDefault}\`\n\n使用 \`/project default clear\` 清除。` + ); } else { - await feishuClient.reply(messageId, '当前群未设置默认项目(跟随全局默认)\n使用 \`/project default set <路径或别名>\` 设置'); + await this.replyProjectMarkdown( + messageId, + chatId, + '📁 当前默认项目', + '当前群未设置默认项目,正在跟随全局默认。\n\n使用 `/project default set <路径或别名>` 设置。' + ); } } - private async handleSwitchSession( + public async handleSessionControlSubmit( + chatId: string, + messageId: string, + userId: string, + chatType: 'p2p' | 'group', + selectedSessionId: string, + sessionName?: string + ): Promise { + const trimmedName = sessionName?.trim() || ''; + + if (selectedSessionId === SESSION_CTL_CURRENT_VALUE) { + if (!trimmedName) { + throw new Error('会话名称不能为空'); + } + await this.handleRename(chatId, messageId, trimmedName); + return; + } + + if (selectedSessionId === SESSION_CTL_NEW_VALUE) { + await this.handleNewSession(chatId, messageId, userId, chatType, undefined, trimmedName || undefined); + return; + } + + const switched = await this.handleSwitchSession(chatId, messageId, userId, selectedSessionId, chatType); + if (switched && trimmedName) { + await this.handleRename(chatId, messageId, trimmedName); + } + } + + public async switchSessionFromCard( chatId: string, messageId: string, userId: string, targetSessionId: string, chatType: 'p2p' | 'group' ): Promise { + await this.handleSwitchSession(chatId, messageId, userId, targetSessionId, chatType); + } + + private async handleSwitchSession( + chatId: string, + messageId: string, + userId: string, + targetSessionId: string, + chatType: 'p2p' | 'group' + ): Promise { if (!userConfig.enableManualSessionBind) { await feishuClient.reply(messageId, '❌ 当前环境未开启“绑定已有会话”能力'); - return; + return false; } const normalizedSessionId = targetSessionId.trim(); if (!normalizedSessionId) { await feishuClient.reply(messageId, '❌ 会话 ID 不能为空'); - return; + return false; } const targetSession = await opencodeClient.findSessionAcrossProjects(normalizedSessionId); if (!targetSession) { await feishuClient.reply(messageId, `❌ 未找到会话: ${normalizedSessionId}`); - return; + return false; } const previousChatId = chatSessionStore.getChatId(normalizedSessionId); @@ -1148,6 +1592,7 @@ export class CommandHandler { } await feishuClient.reply(messageId, replyLines.join('\n')); + return true; } private async handleListSessions(chatId: string, messageId: string, listAll: boolean = false): Promise { @@ -1160,9 +1605,8 @@ export class CommandHandler { try { if (listAll) { - // 全量:聚合所有已知目录的会话 - const storeKnownDirs = chatSessionStore.getKnownDirectories(); - sessions = await opencodeClient.listAllSessions(storeKnownDirs); + // 全量:聚合 OpenCode 项目列表 + 已知目录中的会话 + sessions = await opencodeClient.listSessionsAcrossProjects(); } else { // 默认:只查当前项目目录的会话 sessions = await opencodeClient.listSessions(currentDirectory ? { directory: currentDirectory } : undefined); @@ -1209,6 +1653,7 @@ export class CommandHandler { chatDetail: string; status: string; statusRank: number; + updatedAt: number; } const rows: SessionListRow[] = []; @@ -1219,7 +1664,8 @@ export class CommandHandler { const projectName = bindingInfo?.projectName; const chatDetail = bindingInfo ? bindingInfo.chatIds.join(', ') : '无'; const status = bindingInfo ? 'OpenCode可用/已绑定' : 'OpenCode可用/未绑定'; - rows.push({ directory, projectName, title, sessionId: session.id, chatDetail, status, statusRank: bindingInfo ? 0 : 1 }); + const updatedAt = this.getSessionLastModifiedTime(session); + rows.push({ directory, projectName, title, sessionId: session.id, chatDetail, status, statusRank: bindingInfo ? 0 : 1, updatedAt }); localBindings.delete(session.id); } @@ -1236,30 +1682,59 @@ export class CommandHandler { chatDetail: bindingInfo.chatIds.join(', '), status: '仅本地映射(可能已失活)', statusRank: 2, + updatedAt: chatSessionStore.getSession(bindingInfo.chatIds[0])?.createdAt ?? 0, }); } - const normalizeDirectoryForSort = (directory: string): string => { - const normalized = directory.trim(); - return (!normalized || normalized === '-') ? '\uffff' : normalized; - }; + const sessionOrderMode = this.getSessionOrderMode(chatId); + if (sessionOrderMode === 'last_time') { + const sessionLastActivityMap = await this.resolveSessionLastActivityMap(sessions); + for (const row of rows) { + const resolvedTime = sessionLastActivityMap.get(row.sessionId); + if (typeof resolvedTime === 'number' && resolvedTime > 0) { + row.updatedAt = resolvedTime; + } + } + } - rows.sort((left, right) => { - const directoryCompare = normalizeDirectoryForSort(left.directory).localeCompare( - normalizeDirectoryForSort(right.directory), 'zh-Hans-CN' - ); - if (directoryCompare !== 0) return directoryCompare; - if (left.statusRank !== right.statusRank) return left.statusRank - right.statusRank; - const titleCompare = left.title.localeCompare(right.title, 'zh-Hans-CN'); - if (titleCompare !== 0) return titleCompare; - return left.sessionId.localeCompare(right.sessionId, 'en'); - }); + if (sessionOrderMode === 'last_time') { + rows.sort((left, right) => { + if (left.updatedAt !== right.updatedAt) return right.updatedAt - left.updatedAt; + if (left.statusRank !== right.statusRank) return left.statusRank - right.statusRank; + const titleCompare = left.title.localeCompare(right.title, 'zh-Hans-CN'); + if (titleCompare !== 0) return titleCompare; + return left.sessionId.localeCompare(right.sessionId, 'en'); + }); + } else { + const normalizeDirectoryForSort = (directory: string): string => { + const normalized = directory.trim(); + return (!normalized || normalized === '-') ? '\uffff' : normalized; + }; + + rows.sort((left, right) => { + const directoryCompare = normalizeDirectoryForSort(left.directory).localeCompare( + normalizeDirectoryForSort(right.directory), 'zh-Hans-CN' + ); + if (directoryCompare !== 0) return directoryCompare; + if (left.statusRank !== right.statusRank) return left.statusRank - right.statusRank; + const titleCompare = left.title.localeCompare(right.title, 'zh-Hans-CN'); + if (titleCompare !== 0) return titleCompare; + return left.sessionId.localeCompare(right.sessionId, 'en'); + }); + } - const tableHeader = '工作区目录 | SessionID | OpenCode侧会话名称 | 绑定群明细 | 当前会话状态'; const rowTexts: string[] = []; for (const row of rows) { const directoryDisplay = row.projectName ? `${row.directory} (${row.projectName})` : row.directory; - rowTexts.push(`${directoryDisplay} | ${row.sessionId} | ${row.title} | ${row.chatDetail} | ${row.status}`); + rowTexts.push( + [ + `**工作区目录**: \`${directoryDisplay}\``, + `**SessionID**: \`${row.sessionId}\``, + `**OpenCode侧会话名称**: ${row.title}`, + `**绑定群明细**: ${row.chatDetail}`, + `**当前会话状态**: ${row.status}`, + ].join('\n') + ); } if (rowTexts.length === 0) { @@ -1270,34 +1745,63 @@ export class CommandHandler { return; } - const rowChunks: string[] = []; - let currentRows = ''; - for (const row of rowTexts) { - if ((tableHeader.length + currentRows.length + row.length + 2) > 3000 && currentRows.length > 0) { - rowChunks.push(currentRows.trimEnd()); - currentRows = ''; - } - currentRows += `${row}\n`; - } - if (currentRows.trim().length > 0) rowChunks.push(currentRows.trimEnd()); - - const chunks = rowChunks.map(chunk => `${tableHeader}\n${chunk}`); - if (chunks.length === 0) { - await feishuClient.reply(messageId, `${tableHeader}\n(无数据)`); - return; - } - const totalCount = rowTexts.length; const header = opencodeUnavailable ? `📚 会话列表(总计 ${totalCount},OpenCode 暂不可达,仅展示本地映射)` : `📚 会话列表(总计 ${totalCount})`; + const hint = listAll ? '' : '\n💡 提示:使用 `/sessions all` 查看所有项目的会话'; + const sessionWithChange = this.isSessionWithChangeEnabled(chatId); - const hint = listAll ? '' : '\n💡 提示:使用 `/sessions all` 查看所有项目的会话\n'; + const summaryMarkdown = `${header}${hint}`; - await feishuClient.reply(messageId, `${header}${hint}\n${chunks[0]}`); + if (!sessionWithChange) { + const pages = this.splitMarkdownBlocks(summaryMarkdown, rowTexts); + await feishuClient.replyCard(messageId, buildMarkdownCard({ + title: pages.length > 1 ? '📚 会话列表(1/' + pages.length + ')' : '📚 会话列表', + markdown: pages[0], + })); - for (let index = 1; index < chunks.length; index++) { - await feishuClient.sendText(chatId, `📚 会话列表(续 ${index + 1}/${chunks.length})\n${chunks[index]}`); + for (let index = 1; index < pages.length; index++) { + await feishuClient.sendCard(chatId, buildMarkdownCard({ + title: `📚 会话列表(${index + 1}/${pages.length})`, + markdown: pages[index], + })); + } + return; + } + + const chatType = chatSessionStore.getSession(chatId)?.chatType === 'p2p' ? 'p2p' : 'group'; + const entries = rows.map(row => { + const directoryDisplay = row.projectName ? `${row.directory} (${row.projectName})` : row.directory; + return { + sessionId: row.sessionId, + markdown: [ + `**工作区目录**: \`${directoryDisplay}\``, + `**SessionID**: \`${row.sessionId}\``, + `**OpenCode侧会话名称**: ${row.title}`, + `**绑定群明细**: ${row.chatDetail}`, + `**当前会话状态**: ${row.status}`, + ].join('\n'), + }; + }); + const pages = this.splitSessionCardEntries(entries, summaryMarkdown); + + await feishuClient.replyCard(messageId, buildSessionListCard({ + title: pages.length > 1 ? '📚 会话列表(1/' + pages.length + ')' : '📚 会话列表', + chatId, + chatType, + summaryMarkdown, + entries: pages[0], + })); + + for (let index = 1; index < pages.length; index++) { + await feishuClient.sendCard(chatId, buildSessionListCard({ + title: `📚 会话列表(${index + 1}/${pages.length})`, + chatId, + chatType, + summaryMarkdown, + entries: pages[index], + })); } } @@ -1384,6 +1888,12 @@ export class CommandHandler { } else { // 即使没找到匹配的,如果格式正确也允许强制设置(针对自定义或未列出的模型) if (normalizedModelName.includes(':') || normalizedModelName.includes('/')) { + const parsedModel = parseChatModelReference(normalizedModelName); + if (parsedModel && !isChatModelAllowed(parsedModel.providerId, parsedModel.modelId)) { + await feishuClient.reply(messageId, `❌ 模型 "${normalizedModelName}" 不在当前允许列表中`); + return; + } + const separator = normalizedModelName.includes(':') ? ':' : '/'; const [provider, model] = normalizedModelName.split(separator); const newValue = `${provider}:${model}`; @@ -1404,6 +1914,44 @@ export class CommandHandler { } } + private async handleModels(chatId: string, messageId: string, listAll: boolean): Promise { + try { + const providersResult = await opencodeClient.getProviders(); + const providers = Array.isArray(providersResult.providers) ? providersResult.providers : []; + const sections: string[] = []; + let totalCount = 0; + + for (const provider of providers) { + const providerModels = this.extractProviderModels(provider); + if (providerModels.length === 0) { + continue; + } + + const providerName = String((provider as Record).name || (provider as Record).id || 'Unknown'); + totalCount += providerModels.length; + const visibleModels = listAll ? providerModels : providerModels.slice(0, 20); + const lines = visibleModels.map(model => { + const label = model.modelName || model.modelId; + return `- ${label} (\`${model.providerId}:${model.modelId}\`)`; + }); + + if (!listAll && providerModels.length > 20) { + lines.push(`- ... 共 ${providerModels.length} 个模型`); + } + + sections.push(`### ${providerName}\n${lines.join('\n')}`); + } + + const markdown = totalCount > 0 + ? [`共 ${totalCount} 个当前可选模型,使用 \`/model <名称>\` 切换`, ...sections].join('\n\n') + : '暂无当前可选模型'; + + await this.replyFeishuMarkdown(messageId, chatId, '📋 可用模型列表', markdown); + } catch (error) { + await feishuClient.reply(messageId, `❌ 获取模型列表失败: ${error}`); + } + } + private async handleEffort( chatId: string, messageId: string, @@ -1615,80 +2163,6 @@ export class CommandHandler { return config.agent; } - private async handleRoleCreate( - chatId: string, - messageId: string, - userId: string, - chatType: 'p2p' | 'group', - roleSpec: string - ): Promise { - const parsed = parseRoleCreateSpec(roleSpec); - if (!parsed.ok) { - await feishuClient.reply(messageId, `❌ 创建角色失败\n${parsed.message}`); - return; - } - - let session = chatSessionStore.getSession(chatId); - if (!session) { - const title = `群聊会话-${chatId.slice(-4)}`; - const chatDefault = chatSessionStore.getSession(chatId)?.defaultDirectory; - const dirResult = DirectoryPolicy.resolve({ chatDefaultDirectory: chatDefault }); - const effectiveDir = dirResult.ok && dirResult.source !== 'server_default' ? dirResult.directory : undefined; - const newSession = await opencodeClient.createSession(title, effectiveDir); - if (!newSession) { - await feishuClient.reply(messageId, '❌ 无法创建会话以保存角色设置'); - return; - } - chatSessionStore.setSession(chatId, newSession.id, userId, title, { chatType, resolvedDirectory: newSession.directory }); - session = chatSessionStore.getSession(chatId); - } - - const payload = parsed.payload; - const [agents, config] = await Promise.all([ - opencodeClient.getAgents(), - opencodeClient.getConfig(), - ]); - - const roleAgentMap = this.getRoleAgentMap(config); - const existingConfig = roleAgentMap[payload.name]; - const nameConflict = agents.find(agent => agent.name.toLowerCase() === payload.name.toLowerCase()); - if (nameConflict && !existingConfig) { - await feishuClient.reply(messageId, `❌ 角色名称已被占用: ${payload.name}\n请更换一个名称后重试。`); - return; - } - - const nextAgentConfig: OpencodeAgentConfig = { - description: payload.description, - mode: payload.mode, - ...(payload.prompt ? { prompt: payload.prompt } : {}), - ...(payload.tools ? { tools: payload.tools } : {}), - }; - - const nextConfig: OpencodeRuntimeConfig = { - ...config, - agent: { - ...roleAgentMap, - [payload.name]: nextAgentConfig, - }, - }; - - const updated = await opencodeClient.updateConfig(nextConfig); - if (!updated) { - await feishuClient.reply(messageId, '❌ 创建角色失败:写入 OpenCode 配置失败'); - return; - } - - if (session) { - chatSessionStore.updateConfig(chatId, { preferredAgent: payload.name }); - } - const actionText = existingConfig ? '已更新' : '已创建'; - const modeText = payload.mode === 'subagent' ? '子角色' : '主角色'; - await feishuClient.reply( - messageId, - `✅ ${actionText}角色: ${payload.name}\n类型: ${modeText}\n当前群已切换到该角色。\n若 /panel 未立即显示新角色,请重启 OpenCode。` - ); - } - private async handleAgent( chatId: string, messageId: string, @@ -1766,25 +2240,20 @@ export class CommandHandler { const modelOptionValues = new Set(); const safeProviders = Array.isArray(providers) ? providers : []; - for (const p of safeProviders) { - // 安全获取 models,兼容数组和对象 - const modelsRaw = (p as any).models; - const models = Array.isArray(modelsRaw) - ? modelsRaw - : (modelsRaw && typeof modelsRaw === 'object' ? Object.values(modelsRaw) : []); - - for (const m of models) { - const modelId = (m as any).id || (m as any).modelID || (m as any).name; - const modelName = (m as any).name || modelId; - const providerId = (p as any).id || (p as any).providerID; - - if (modelId && providerId) { - const label = `[${p.name || providerId}] ${modelName}`; - const value = `${providerId}:${modelId}`; - if (!modelOptionValues.has(value)) { - modelOptionValues.add(value); - modelOptions.push({ label, value }); - } + for (const provider of safeProviders) { + const providerId = this.extractProviderId(provider); + if (!providerId) { + continue; + } + + const providerName = String((provider as Record).name || providerId); + const providerModels = this.extractProviderModels(provider); + for (const model of providerModels) { + const label = `[${providerName}] ${model.modelName || model.modelId}`; + const value = `${model.providerId}:${model.modelId}`; + if (!modelOptionValues.has(value)) { + modelOptionValues.add(value); + modelOptions.push({ label, value }); } } } diff --git a/src/handlers/dingtalk.ts b/src/handlers/dingtalk.ts index 0f349ef..9f9f29d 100644 --- a/src/handlers/dingtalk.ts +++ b/src/handlers/dingtalk.ts @@ -11,6 +11,7 @@ import { opencodeClient } from '../opencode/client.js'; import { outputBuffer } from '../opencode/output-buffer.js'; import { chatSessionStore } from '../store/chat-session.js'; import { parseCommand, type ParsedCommand } from '../commands/parser.js'; +import { normalizeEffortLevel, type EffortLevel } from '../commands/effort.js'; import { DirectoryPolicy } from '../utils/directory-policy.js'; import { buildSessionTimestamp } from '../utils/session-title.js'; import { shouldSkipGroupMessage } from '../utils/group-mention.js'; @@ -322,8 +323,66 @@ export class DingtalkHandler { dispatchOptions.fallbackDirectories = fallbackDirectories; } - if (promptEffort) { - dispatchOptions.effort = promptEffort; + // 确定 effort(优先使用 promptEffort,其次是 sessionConfig.preferredEffort) + let effectiveEffort = promptEffort || sessionConfig?.preferredEffort; + + // 验证 effort 是否与当前模型兼容 + if (effectiveEffort && sessionConfig?.preferredModel) { + const [providerId, modelId] = sessionConfig.preferredModel.split(':'); + if (providerId && modelId) { + try { + const providersPayload = await opencodeClient.getProviders(); + const providers = Array.isArray(providersPayload.providers) ? providersPayload.providers : []; + const providerLower = providerId.toLowerCase(); + const modelLower = modelId.toLowerCase(); + + for (const provider of providers) { + if (!provider || typeof provider !== 'object') continue; + const providerRecord = provider as Record; + const providerIdRaw = typeof providerRecord.id === 'string' ? providerRecord.id.trim() : ''; + if (!providerIdRaw || providerIdRaw.toLowerCase() !== providerLower) continue; + + const modelsRaw = providerRecord.models; + const modelList = Array.isArray(modelsRaw) + ? modelsRaw + : (modelsRaw && typeof modelsRaw === 'object' ? Object.values(modelsRaw) : []); + + for (const modelItem of modelList) { + if (!modelItem || typeof modelItem !== 'object') continue; + const modelRecord = modelItem as Record; + const modelIdRaw = typeof modelRecord.id === 'string' + ? modelRecord.id.trim() + : (typeof modelRecord.modelID === 'string' ? modelRecord.modelID.trim() : ''); + if (!modelIdRaw || modelIdRaw.toLowerCase() !== modelLower) continue; + + // 解析模型支持的 variants + const variants = modelRecord.variants; + if (variants && typeof variants === 'object' && !Array.isArray(variants)) { + const supportedVariants: EffortLevel[] = []; + for (const key of Object.keys(variants as Record)) { + const normalized = normalizeEffortLevel(key); + if (normalized && normalized !== 'none' && !supportedVariants.includes(normalized)) { + supportedVariants.push(normalized); + } + } + // 如果当前 effort 不在支持列表中,清除它 + const normalizedEffort = normalizeEffortLevel(effectiveEffort); + if (normalizedEffort && supportedVariants.length > 0 && !supportedVariants.includes(normalizedEffort)) { + effectiveEffort = undefined; + } + } + break; + } + break; + } + } catch (error) { + console.debug('[钉钉] 获取模型支持的 variants 失败,跳过验证:', error instanceof Error ? error.message : String(error)); + } + } + } + + if (effectiveEffort) { + dispatchOptions.effort = effectiveEffort; } if (sessionConfig?.preferredModel) { @@ -334,10 +393,6 @@ export class DingtalkHandler { dispatchOptions.agent = sessionConfig.preferredAgent; } - if (sessionConfig?.preferredEffort) { - dispatchOptions.effort = sessionConfig.preferredEffort; - } - // 发送消息到 OpenCode await opencodeClient.sendMessage(sessionId, text, dispatchOptions); diff --git a/src/handlers/discord.ts b/src/handlers/discord.ts index 2c93a69..8af3f16 100644 --- a/src/handlers/discord.ts +++ b/src/handlers/discord.ts @@ -26,6 +26,7 @@ import { permissionHandler } from '../permissions/handler.js'; import { chatSessionStore } from '../store/chat-session.js'; import { validateFilePath } from './file-sender.js'; import { DirectoryPolicy } from '../utils/directory-policy.js'; +import { isChatModelAllowed, parseChatModelReference } from '../utils/chat-model-whitelist.js'; import type { PlatformMessageEvent, PlatformSender } from '../platform/types.js'; import { buildCronHelpText, @@ -651,6 +652,9 @@ class DiscordHandler { if (!modelId) { continue; } + if (!isChatModelAllowed(providerId, modelId)) { + continue; + } const value = `${providerId}:${modelId}`; if (seen.has(value)) { @@ -1713,7 +1717,16 @@ class DiscordHandler { const session = chatSessionStore.getSessionByConversation('discord', event.conversationId); const preferredModel = this.parseProviderModel(session?.preferredModel); - const variant = effortParsed.effort || session?.preferredEffort; + let variant = effortParsed.effort || session?.preferredEffort; + + // 验证 variant 是否与当前模型兼容 + if (variant) { + const support = await this.getEffortSupportInfo(event.conversationId); + if (support.supportedEfforts.length > 0 && !support.supportedEfforts.includes(variant)) { + // 当前模型不支持该 variant,不传递(让模型自动选择) + variant = undefined; + } + } const pendingMessageId = await this.safePending(event); try { @@ -2213,6 +2226,12 @@ ${pending.risk ? `- 风险:${pending.risk}` : ''} return; } + const parsedModel = parseChatModelReference(selected); + if (!parsedModel || !isChatModelAllowed(parsedModel.providerId, parsedModel.modelId)) { + await this.safeInteractionReply(interaction, '❌ 该模型不在当前允许列表中。'); + return; + } + chatSessionStore.updateConfigByConversation('discord', conversationId, { preferredModel: selected }); await this.safeInteractionReply(interaction, `✅ 已切换模型: ${selected}`); } diff --git a/src/handlers/group.ts b/src/handlers/group.ts index bd57cad..6ea75a5 100644 --- a/src/handlers/group.ts +++ b/src/handlers/group.ts @@ -5,9 +5,10 @@ import { outputBuffer } from '../opencode/output-buffer.js'; import { questionHandler, type PendingQuestion } from '../opencode/question-handler.js'; import { parseQuestionAnswerText } from '../opencode/question-parser.js'; import { parseCommand } from '../commands/parser.js'; -import type { EffortLevel } from '../commands/effort.js'; +import { normalizeEffortLevel, KNOWN_EFFORT_LEVELS, type EffortLevel } from '../commands/effort.js'; import { commandHandler } from './command.js'; import { modelConfig, attachmentConfig } from '../config.js'; +import { preprocessVisionParts, type VisionPart } from '../services/vision-ocr.js'; import { DirectoryPolicy } from '../utils/directory-policy.js'; import { buildSessionTimestamp } from '../utils/session-title.js'; import { buildStreamCard } from '../feishu/cards-stream.js'; @@ -399,21 +400,9 @@ export class GroupHandler { parts.push({ type: 'text', text: effectiveText }); } - if (attachments && attachments.length > 0) { - const prepared = await this.prepareAttachmentParts(messageId, attachments); - if (prepared.warnings.length > 0) { - await feishuClient.reply(messageId, `⚠️ 附件警告:\n${prepared.warnings.join('\n')}`); - } - parts.push(...prepared.parts); - } - - if (parts.length === 0) { - await feishuClient.reply(messageId, '未检测到有效内容'); - outputBuffer.setStatus(`chat:${chatId}`, 'completed'); - return; - } - - // 提取 providerId 和 modelId + // ── 提前解析 providerId/modelId/directory ── + // prepareAttachmentParts 需要这些上下文来判断主模型是否支持 image 输入, + // 并在不支持时把图片交给 opencode 内的多模态 model 做 OCR(见 vision-ocr 服务)。 let providerId: string | undefined; let modelId: string | undefined; @@ -436,12 +425,9 @@ export class GroupHandler { } } - // 异步触发 OpenCode 请求,后续输出通过事件流持续推送 - const variant = promptEffort || config?.preferredEffort; - // 从 store 获取会话的工作目录,传递给 OpenCode 以切换 Instance 上下文 + // 从 store 获取会话的工作目录,作为 OCR 临时 session 的上下文 const sessionData = chatSessionStore.getSession(chatId); let directory = sessionData?.resolvedDirectory; - // 如果 store 没有记录(老会话),尝试从 OpenCode 聚合查询并回写缓存 if (!directory) { try { const storeKnownDirs = chatSessionStore.getKnownDirectories(); @@ -449,17 +435,91 @@ export class GroupHandler { const matched = sessions.find(s => s.id === sessionId); if (matched?.directory) { directory = matched.directory; - // 回写缓存,后续消息不再重复查询 chatSessionStore.updateResolvedDirectory(chatId, directory); } } catch (error) { - // 获取失败不阻塞消息发送,但记录调试日志 console.debug('[Group] 获取会话目录失败,将使用默认目录:', error instanceof Error ? error.message : String(error)); } } + + if (attachments && attachments.length > 0) { + const prepared = await this.prepareAttachmentParts(messageId, attachments); + if (prepared.warnings.length > 0) { + await feishuClient.reply(messageId, `⚠️ 附件警告:\n${prepared.warnings.join('\n')}`); + } + parts.push(...prepared.parts); + } + + if (parts.length === 0) { + await feishuClient.reply(messageId, '未检测到有效内容'); + outputBuffer.setStatus(`chat:${chatId}`, 'completed'); + return; + } + + // 异步触发 OpenCode 请求,后续输出通过事件流持续推送 + let variant = promptEffort || config?.preferredEffort; + + // 验证 variant 是否与当前模型兼容 + if (variant && providerId && modelId) { + try { + const providersPayload = await opencodeClient.getProviders(); + const providers = Array.isArray(providersPayload.providers) ? providersPayload.providers : []; + const providerLower = providerId.toLowerCase(); + const modelLower = modelId.toLowerCase(); + + for (const provider of providers) { + if (!provider || typeof provider !== 'object') continue; + const providerRecord = provider as Record; + const providerIdRaw = typeof providerRecord.id === 'string' ? providerRecord.id.trim() : ''; + if (!providerIdRaw || providerIdRaw.toLowerCase() !== providerLower) continue; + + const modelsRaw = providerRecord.models; + const modelList = Array.isArray(modelsRaw) + ? modelsRaw + : (modelsRaw && typeof modelsRaw === 'object' ? Object.values(modelsRaw) : []); + + for (const modelItem of modelList) { + if (!modelItem || typeof modelItem !== 'object') continue; + const modelRecord = modelItem as Record; + const modelIdRaw = typeof modelRecord.id === 'string' + ? modelRecord.id.trim() + : (typeof modelRecord.modelID === 'string' ? modelRecord.modelID.trim() : ''); + if (!modelIdRaw || modelIdRaw.toLowerCase() !== modelLower) continue; + + // 解析模型支持的 variants + const variants = modelRecord.variants; + if (variants && typeof variants === 'object' && !Array.isArray(variants)) { + const supportedVariants: EffortLevel[] = []; + for (const key of Object.keys(variants as Record)) { + const normalized = normalizeEffortLevel(key); + if (normalized && normalized !== 'none' && !supportedVariants.includes(normalized)) { + supportedVariants.push(normalized); + } + } + // 如果当前 variant 不在支持列表中,清除它 + if (supportedVariants.length > 0 && !supportedVariants.includes(variant)) { + variant = undefined; + } + } + break; + } + break; + } + } catch (error) { + console.debug('[Group] 获取模型支持的 variants 失败,跳过验证:', error instanceof Error ? error.message : String(error)); + } + } + + // ── 非多模态主模型图片回退:主模型不支持 image 时用 OCR 文本替换图片 part ── + const dispatchParts = await preprocessVisionParts( + parts as VisionPart[], + { providerId, modelId, directory }, + '飞书', + ) as OpencodePartInput[]; + await opencodeClient.sendMessagePartsAsync( sessionId, - parts, + dispatchParts, { providerId, modelId, @@ -490,11 +550,15 @@ export class GroupHandler { } // 处理附件 + // + // 注意:图片 → 文本的 OCR 替换不在此函数内进行,统一由上层在发消息前调用 + // `preprocessVisionParts(parts, {providerId, modelId, directory}, '飞书')` 完成。 + // 这样可以保证飞书 / Chat API / 其它平台的图片回退行为一致,且嗅探只跑一次。 private async prepareAttachmentParts( messageId: string, - attachments: FeishuAttachment[] - ): Promise<{ parts: OpencodeFilePartInput[]; warnings: string[] }> { - const parts: OpencodeFilePartInput[] = []; + attachments: FeishuAttachment[], + ): Promise<{ parts: OpencodePartInput[]; warnings: string[] }> { + const parts: OpencodePartInput[] = []; const warnings: string[] = []; await fs.mkdir(ATTACHMENT_BASE_DIR, { recursive: true }).catch(() => undefined); @@ -516,7 +580,7 @@ export class GroupHandler { const extFromType = attachment.fileType ? normalizeExtension(attachment.fileType) : ''; const extFromContent = contentType ? extensionFromContentType(contentType) : ''; let ext = normalizeExtension(extFromName || extFromType || extFromContent); - + if (!ext && attachment.type === 'image') { ext = '.jpg'; } @@ -536,14 +600,14 @@ export class GroupHandler { await resource.writeFile(filePath); const buffer = await fs.readFile(filePath); const base64 = buffer.toString('base64'); - + let mime = contentType ? contentType.split(';')[0].trim() : ''; if (!mime || mime === 'application/octet-stream') { mime = mimeFromExtension(ext); } - + const dataUrl = `data:${mime};base64,${base64}`; - + parts.push({ type: 'file', mime, diff --git a/src/handlers/p2p.ts b/src/handlers/p2p.ts index 23c84fb..f976277 100644 --- a/src/handlers/p2p.ts +++ b/src/handlers/p2p.ts @@ -10,7 +10,7 @@ import { } from '../feishu/cards.js'; import { DirectoryPolicy } from '../utils/directory-policy.js'; import { buildSessionTimestamp } from '../utils/session-title.js'; -import { parseCommand, getHelpText, type ParsedCommand } from '../commands/parser.js'; +import { parseCommand, type ParsedCommand } from '../commands/parser.js'; import { commandHandler } from './command.js'; import { groupHandler } from './group.js'; import { directoryConfig, userConfig } from '../config.js'; @@ -253,56 +253,141 @@ export class P2PHandler { this.createChatNameInputMap.delete(key); } - - private getSessionDirectory(session: OpencodeSession): string { - return typeof session.directory === 'string' && session.directory.trim().length > 0 - ? session.directory.trim() - : '/'; - } -private getSessionOptionLabel(session: OpencodeSession, highlightWorkspace: boolean): string { - const title = typeof session.title === 'string' && session.title.trim().length > 0 - ? session.title.trim() - : '未命名会话'; - const compactTitle = title.length > 24 ? `${title.slice(0, 24)}...` : title; - const directory = this.getSessionDirectory(session); - const compactDirectory = directory.length > 18 ? `...${directory.slice(-18)}` : directory; - const shortId = session.id.slice(0, 8); - const workspaceLabel = highlightWorkspace ? `【${compactDirectory}】` : compactDirectory; - return `${workspaceLabel} / ${shortId} / ${compactTitle}`; - } - - private sortSessionsForCreateChat(sessions: OpencodeSession[]): OpencodeSession[] { - return [...sessions].sort((a, b) => { - const directoryCompare = this.getSessionDirectory(a).localeCompare(this.getSessionDirectory(b), 'zh-Hans-CN'); - if (directoryCompare !== 0) { - return directoryCompare; - } - - const left = b.time?.updated ?? b.time?.created ?? 0; - const right = a.time?.updated ?? a.time?.created ?? 0; - if (left !== right) { - return left - right; - } - - return a.id.localeCompare(b.id, 'en'); - }); - } - - private async buildCreateChatCardData(selectedSessionId?: string): Promise { - const sessionOptions: CreateChatSessionOption[] = [ - { - label: '新建 OpenCode 会话(默认)', - value: CREATE_CHAT_NEW_SESSION_VALUE, - }, + + private getSessionDirectory(session: OpencodeSession): string { + return typeof session.directory === 'string' && session.directory.trim().length > 0 + ? session.directory.trim() + : '/'; + } + + private getDisplayWidth(text: string): number { + let width = 0; + for (const char of text) { + width += /[^\u0000-\u00ff]/.test(char) ? 2 : 1; + } + return width; + } + + private truncateByDisplayWidth(text: string, maxWidth: number, mode: 'start' | 'end' = 'end'): string { + const normalized = text.trim(); + if (!normalized) { + return ''; + } + + if (this.getDisplayWidth(normalized) <= maxWidth) { + return normalized; + } + + const ellipsis = '...'; + const ellipsisWidth = this.getDisplayWidth(ellipsis); + const targetWidth = Math.max(0, maxWidth - ellipsisWidth); + let collected = ''; + let usedWidth = 0; + const chars = [...normalized]; + const source = mode === 'start' ? [...chars].reverse() : chars; + + for (const char of source) { + const charWidth = this.getDisplayWidth(char); + if (usedWidth + charWidth > targetWidth) { + break; + } + collected = mode === 'start' ? `${char}${collected}` : `${collected}${char}`; + usedWidth += charWidth; + } + + return mode === 'start' ? `${ellipsis}${collected}` : `${collected}${ellipsis}`; + } + + private compactDirectoryLabel(directory: string, highlightWorkspace: boolean): string { + const normalized = directory.trim() || '/'; + const shortDirectory = this.truncateByDisplayWidth(normalized, 16, 'start'); + return shortDirectory; + } + + private getSessionOptionLabel(session: OpencodeSession, highlightWorkspace: boolean): string { + const title = typeof session.title === 'string' && session.title.trim().length > 0 + ? session.title.trim() + : '未命名会话'; + const compactTitle = this.truncateByDisplayWidth(title, 24, 'end'); + const directory = this.getSessionDirectory(session); + const shortId = session.id.slice(0, 8); + const workspaceLabel = this.compactDirectoryLabel(directory, highlightWorkspace); + return `${workspaceLabel} / ${shortId} / ${compactTitle}`; + } + + private sortSessionsForCreateChat(sessions: OpencodeSession[]): OpencodeSession[] { + return [...sessions].sort((a, b) => { + const left = a.time?.updated ?? a.time?.created ?? 0; + const right = b.time?.updated ?? b.time?.created ?? 0; + if (left !== right) { + return right - left; + } + + return a.id.localeCompare(b.id, 'en'); + }); + } + + private async resolveSessionLastActivityMap(sessions: OpencodeSession[]): Promise> { + const entries = await Promise.all( + sessions.map(async session => { + try { + const activityTime = await opencodeClient.getSessionLastActivityTime(session.id); + return [session.id, activityTime || (session.time?.updated ?? session.time?.created ?? 0)] as const; + } catch { + return [session.id, session.time?.updated ?? session.time?.created ?? 0] as const; + } + }) + ); + + return new Map(entries); + } + + private sortSessionsForCreateChatDefault(sessions: OpencodeSession[]): OpencodeSession[] { + return [...sessions].sort((a, b) => { + const directoryCompare = this.getSessionDirectory(a).localeCompare(this.getSessionDirectory(b), 'zh-Hans-CN'); + if (directoryCompare !== 0) { + return directoryCompare; + } + + const left = b.time?.updated ?? b.time?.created ?? 0; + const right = a.time?.updated ?? a.time?.created ?? 0; + if (left !== right) { + return left - right; + } + + return a.id.localeCompare(b.id, 'en'); + }); + } + + private async buildCreateChatCardData(chatId: string, selectedSessionId?: string): Promise { + const sessionOptions: CreateChatSessionOption[] = [ + { + label: '新建 OpenCode 会话(默认)', + value: CREATE_CHAT_NEW_SESSION_VALUE, + }, ]; - let totalSessionCount = 0; - let projectOptions: Array<{ name: string; directory: string; source: 'alias' | 'history' }> = []; - let sessions: OpencodeSession[] = []; - if (userConfig.enableManualSessionBind) { - try { - sessions = this.sortSessionsForCreateChat(await opencodeClient.listSessionsAcrossProjects()); - totalSessionCount = sessions.length; + let totalSessionCount = 0; + let projectOptions: Array<{ name: string; directory: string; source: 'alias' | 'history' }> = []; + let sessions: OpencodeSession[] = []; + if (userConfig.enableManualSessionBind) { + try { + const sessionOrderMode = commandHandler.getSessionOrderMode(chatId); + const allSessions = await opencodeClient.listSessionsAcrossProjects(); + if (sessionOrderMode === 'last_time') { + const sessionLastActivityMap = await this.resolveSessionLastActivityMap(allSessions); + sessions = [...allSessions].sort((left, right) => { + const leftTime = sessionLastActivityMap.get(left.id) ?? left.time?.updated ?? left.time?.created ?? 0; + const rightTime = sessionLastActivityMap.get(right.id) ?? right.time?.updated ?? right.time?.created ?? 0; + if (leftTime !== rightTime) { + return rightTime - leftTime; + } + return left.id.localeCompare(right.id, 'en'); + }); + } else { + sessions = this.sortSessionsForCreateChatDefault(allSessions); + } + totalSessionCount = sessions.length; let previousDirectory = ''; for (const session of sessions.slice(0, CREATE_CHAT_EXISTING_LIMIT)) { @@ -341,8 +426,8 @@ private getSessionOptionLabel(session: OpencodeSession, highlightWorkspace: bool messageId?: string, selectedSessionId?: string, openId?: string - ): Promise { - const cardData = await this.buildCreateChatCardData(selectedSessionId); + ): Promise { + const cardData = await this.buildCreateChatCardData(chatId, selectedSessionId); const card = buildCreateChatCard(cardData); let sentCardMessageId: string | null = null; if (messageId) { @@ -429,8 +514,8 @@ private getSessionOptionLabel(session: OpencodeSession, highlightWorkspace: bool return command.type === 'session' && command.sessionAction === 'new'; } - private async pushFirstContactGuidance(chatId: string, senderId: string, messageId: string): Promise { - const createChatData = await this.buildCreateChatCardData(); + private async pushFirstContactGuidance(chatId: string, senderId: string, messageId: string): Promise { + const createChatData = await this.buildCreateChatCardData(chatId); const card = buildWelcomeCard(senderId, createChatData); const welcomeCardMessageId = await feishuClient.sendCard(chatId, card); this.rememberCreateChatSelection( @@ -439,7 +524,7 @@ private getSessionOptionLabel(session: OpencodeSession, highlightWorkspace: bool welcomeCardMessageId || undefined, senderId ); - await this.safeReply(messageId, chatId, getHelpText()); + await commandHandler.replyHelpCard(messageId, chatId, 'p2p'); try { await commandHandler.pushPanelCard(chatId, 'p2p'); diff --git a/src/handlers/platform-command.handler.ts b/src/handlers/platform-command.handler.ts index f9a9a70..ddf70c9 100644 --- a/src/handlers/platform-command.handler.ts +++ b/src/handlers/platform-command.handler.ts @@ -13,6 +13,7 @@ import { DirectoryPolicy } from '../utils/directory-policy.js'; import { buildSessionTimestamp } from '../utils/session-title.js'; import { modelConfig, userConfig } from '../config.js'; import type { PlatformSender } from '../platform/types.js'; +import { isChatModelAllowed, parseChatModelReference } from '../utils/chat-model-whitelist.js'; const EFFORT_USAGE_TEXT = '用法: /effort(查看) 或 /effort (设置) 或 /effort default(清除)'; const EFFORT_DISPLAY_ORDER = KNOWN_EFFORT_LEVELS; @@ -66,6 +67,8 @@ export class PlatformCommandHandler { case 'session': if (command.sessionAction === 'new') { await this.handleNewSession(chatId, context.senderId, context.chatType, command.sessionDirectory, command.sessionName, sender); + } else if (command.sessionAction === 'switch' && command.sessionId) { + await this.handleSwitchSession(chatId, context.senderId, context.chatType, command.sessionId, sender); } else { await this.sendText(sender, chatId, '用法: /session new 或 /session (列出会话请用 /sessions)'); } @@ -107,7 +110,7 @@ export class PlatformCommandHandler { break; case 'models': - await this.handleModels(chatId, sender); + await this.handleModels(chatId, command.listAll ?? false, sender); break; case 'agent': @@ -160,7 +163,8 @@ export class PlatformCommandHandler { • \`/help\` 显示帮助 • \`/model\` 查看当前模型 • \`/model <名称>\` 切换模型 -• \`/models\` 列出所有可用模型 +• \`/models\` 列出当前可选模型 +• \`/models all\` 列出全部当前可选模型 • \`/agent\` 查看当前角色 • \`/agent <名称>\` 切换角色 • \`/agents\` 列出所有可用角色 @@ -282,6 +286,54 @@ export class PlatformCommandHandler { } } + private async handleSwitchSession( + chatId: string, + userId: string, + chatType: 'p2p' | 'group', + targetSessionId: string, + sender: PlatformSender + ): Promise { + if (!userConfig.enableManualSessionBind) { + await this.sendText(sender, chatId, '当前未开启手动绑定已有会话'); + return; + } + + const normalizedSessionId = targetSessionId.trim(); + if (!normalizedSessionId) { + await this.sendText(sender, chatId, '请提供要切换的 Session ID'); + return; + } + + try { + const session = await opencodeClient.findSessionAcrossProjects(normalizedSessionId); + if (!session) { + await this.sendText(sender, chatId, `未找到会话: ${normalizedSessionId}`); + return; + } + + chatSessionStore.setSessionByConversation( + this.platform, + chatId, + session.id, + userId, + session.title || '未命名会话', + { + chatType, + resolvedDirectory: session.directory, + } + ); + + const lines = [`已切换到会话: ${session.id}`]; + if (session.directory) { + lines.push(`工作目录: ${session.directory}`); + } + await this.sendText(sender, chatId, lines.join('\n')); + } catch (error) { + console.error(`[${this.platform}] 切换会话失败:`, error); + await this.sendText(sender, chatId, '切换会话失败,请稍后重试'); + } + } + private async handleListSessions(chatId: string, listAll: boolean, sender: PlatformSender): Promise { const sessionData = chatSessionStore.getSessionByConversation(this.platform, chatId); const currentDirectory = sessionData?.resolvedDirectory || sessionData?.defaultDirectory; @@ -476,7 +528,12 @@ export class PlatformCommandHandler { chatSessionStore.updateConfigByConversation(this.platform, chatId, { preferredModel: newValue }); await this.sendText(sender, chatId, `✅ 已切换模型: ${newValue}`); } else if (normalizedModelName.includes(':') || normalizedModelName.includes('/')) { - // 支持强制设置 + const parsedModel = parseChatModelReference(modelName); + if (parsedModel && !isChatModelAllowed(parsedModel.providerId, parsedModel.modelId)) { + await this.sendText(sender, chatId, `❌ 模型 "${modelName.trim()}" 不在当前允许列表中`); + return; + } + chatSessionStore.updateConfigByConversation(this.platform, chatId, { preferredModel: modelName.trim() }); await this.sendText(sender, chatId, `⚠️ 已设置模型: ${modelName.trim()}(未在列表中验证)`); } else { @@ -488,7 +545,7 @@ export class PlatformCommandHandler { } } - private async handleModels(chatId: string, sender: PlatformSender): Promise { + private async handleModels(chatId: string, listAll: boolean, sender: PlatformSender): Promise { try { const providersResult = await opencodeClient.getProviders(); const providers = Array.isArray(providersResult.providers) ? providersResult.providers : []; @@ -502,15 +559,16 @@ export class PlatformCommandHandler { const providerName = (provider as Record).name || (provider as Record).id || 'Unknown'; lines.push(`**${providerName}**`); + totalCount += providerModels.length; - for (const model of providerModels.slice(0, 20)) { + const visibleModels = listAll ? providerModels : providerModels.slice(0, 20); + for (const model of visibleModels) { const modelDisplay = model.modelName || model.modelId; const modelKey = `${model.providerId}:${model.modelId}`; lines.push(` • ${modelDisplay} (\`${modelKey}\`)`); - totalCount++; } - if (providerModels.length > 20) { + if (!listAll && providerModels.length > 20) { lines.push(` _... 共 ${providerModels.length} 个模型_`); } lines.push(''); @@ -551,6 +609,7 @@ export class PlatformCommandHandler { const modelRecord = m as Record; const modelId = typeof modelRecord.id === 'string' ? modelRecord.id : undefined; if (!modelId) continue; + if (!isChatModelAllowed(providerId, modelId)) continue; models.push({ providerId, @@ -564,6 +623,7 @@ export class PlatformCommandHandler { for (const [key, value] of Object.entries(modelMap)) { if (value && typeof value === 'object') { const modelRecord = value as Record; + if (!isChatModelAllowed(providerId, key)) continue; models.push({ providerId, modelId: key, @@ -867,4 +927,4 @@ export class PlatformCommandHandler { await this.sendText(sender, chatId, '❌ 重命名失败'); } } -} \ No newline at end of file +} diff --git a/src/handlers/qq.ts b/src/handlers/qq.ts index 8aceb60..a922751 100644 --- a/src/handlers/qq.ts +++ b/src/handlers/qq.ts @@ -7,9 +7,11 @@ import { modelConfig, attachmentConfig } from '../config.js'; import { opencodeClient } from '../opencode/client.js'; +import { preprocessVisionParts, type VisionPart } from '../services/vision-ocr.js'; import { outputBuffer } from '../opencode/output-buffer.js'; -import { chatSessionStore } from '../store/chat-session.js'; +import { chatSessionStore, type SessionOrderMode } from '../store/chat-session.js'; import { parseCommand, type ParsedCommand } from '../commands/parser.js'; +import { normalizeEffortLevel, type EffortLevel } from '../commands/effort.js'; import { DirectoryPolicy } from '../utils/directory-policy.js'; import { buildSessionTimestamp } from '../utils/session-title.js'; import { shouldSkipGroupMessage } from '../utils/group-mention.js'; @@ -17,7 +19,12 @@ import { permissionHandler } from '../permissions/handler.js'; import { questionHandler, type PendingQuestion } from '../opencode/question-handler.js'; import { parseQuestionAnswerText } from '../opencode/question-parser.js'; import type { PlatformMessageEvent, PlatformSender } from '../platform/types.js'; -import type { EffortLevel } from '../commands/effort.js'; +import { + collectAllowedChatModels, + findAllowedChatModel, + isChatModelAllowed, + parseChatModelReference, +} from '../utils/chat-model-whitelist.js'; import { randomUUID } from 'crypto'; import path from 'path'; import { promises as fs } from 'fs'; @@ -164,6 +171,12 @@ function getQQHelpText(): string { /session - 列出当前项目的会话 /session new - 开启新话题 /sessions all - 列出所有会话 +/config session order - 查看当前会话排序模式 +/config session order default - 使用默认排序 +/config session order last_time - 按最后修改时间倒序 +/config output onlyText - 查看 QQ 纯文本输出模式 +/config output onlyText true - 启用 QQ 纯文本输出 +/config output onlyText false - 恢复 QQ Markdown 输出 /clear - 清空对话上下文 /stop - 停止当前生成 /help - 显示此帮助 @@ -182,10 +195,196 @@ function getQQHelpText(): string { 回复"跳过"可跳过当前问题 提示 -切换的模型/角色仅对当前会话生效。`; +切换的模型/角色仅对当前会话生效。 +当前版本电脑 QQ 的 Markdown 渲染可能不稳定;如出现内容被吞或显示异常,可使用 /config output onlyText true 切换为纯文本输出。`; } +function buildQQCmdInput(text: string, show?: string): string { + const encodedText = encodeURIComponent(text); + const encodedShow = encodeURIComponent(show || text); + return ``; +} + +function getQQHelpMarkdown(): string { + const commandLines = [ + `${buildQQCmdInput('/model')} - 查看当前模型`, + `${buildQQCmdInput('/model ', '/model <名称>')} - 切换模型`, + `${buildQQCmdInput('/models')} - 列出所有可用模型`, + `${buildQQCmdInput('/agent')} - 查看当前角色`, + `${buildQQCmdInput('/agent ', '/agent <名称>')} - 切换角色`, + `${buildQQCmdInput('/agents')} - 列出所有可用角色`, + `${buildQQCmdInput('/agent off')} - 切回默认角色`, + `${buildQQCmdInput('/status')} - 查看当前状态`, + `${buildQQCmdInput('/session')} - 列出当前项目的会话`, + `${buildQQCmdInput('/session new')} - 开启新话题`, + `${buildQQCmdInput('/sessions all')} - 列出所有会话`, + `${buildQQCmdInput('/config session order')} - 查看当前会话排序模式`, + `${buildQQCmdInput('/config session order default')} - 使用默认排序`, + `${buildQQCmdInput('/config session order last_time')} - 按最后修改时间倒序`, + `${buildQQCmdInput('/config output onlyText')} - 查看 QQ 纯文本输出模式`, + `${buildQQCmdInput('/config output onlyText true')} - 启用 QQ 纯文本输出`, + `${buildQQCmdInput('/config output onlyText false')} - 恢复 QQ Markdown 输出`, + `${buildQQCmdInput('/clear')} - 清空对话上下文`, + `${buildQQCmdInput('/stop')} - 停止当前生成`, + `${buildQQCmdInput('/help')} - 显示此帮助`, + ]; + + return `# QQ × OpenCode 机器人指南 + +## 如何对话 +直接发送消息即可与 AI 对话。 + +## 常用命令 +${commandLines.join('\n')} + +## 权限确认 +当 AI 需要执行敏感操作时,会发送权限确认消息。 +回复 1 或 允许 - 同意执行 +回复 2 或 拒绝 - 不同意执行 +回复 3 或 始终允许 - 同意并记住此工具 + +## 问答互动 +当 AI 需要您的反馈时,会发送问答消息。 +回复选项编号(如 1、2)选择对应选项 +回复多个编号(如 1 3)可多选 +直接输入文字可提交自定义答案 +回复"跳过"可跳过当前问题 + +## 提示 +切换的模型/角色仅对当前会话生效。 +当前版本电脑 QQ 的 Markdown 渲染可能不稳定;如出现内容被吞或显示异常,可使用 ${buildQQCmdInput('/config output onlyText true')} 切换为纯文本输出。`; +} + +type QQSessionInfo = Awaited>[number]; +const QQ_CARD_SOFT_LIMIT = 2800; + export class QQHandler { + private getSessionOrderMode(chatId: string): SessionOrderMode { + return chatSessionStore.getSessionByConversation('qq', chatId)?.sessionOrderMode || 'default'; + } + + private formatSessionOrderMode(mode: SessionOrderMode): string { + return mode === 'last_time' ? '按最后修改时间倒序' : '默认排序'; + } + + private isQQOnlyTextEnabled(chatId: string): boolean { + return chatSessionStore.getSessionByConversation('qq', chatId)?.qqOutputOnlyText === true; + } + + private getSessionLastModifiedTime(session: QQSessionInfo): number { + return session.time?.updated ?? session.time?.created ?? 0; + } + + private async resolveSessionLastActivityMap(sessions: QQSessionInfo[]): Promise> { + const entries = await Promise.all( + sessions.map(async session => { + try { + const activityTime = await opencodeClient.getSessionLastActivityTime(session.id); + return [session.id, activityTime || this.getSessionLastModifiedTime(session)] as const; + } catch { + return [session.id, this.getSessionLastModifiedTime(session)] as const; + } + }) + ); + + return new Map(entries); + } + + private async sortSessions(chatId: string, sessions: QQSessionInfo[]): Promise { + const sessionOrderMode = this.getSessionOrderMode(chatId); + const sessionLastActivityMap = sessionOrderMode === 'last_time' + ? await this.resolveSessionLastActivityMap(sessions) + : null; + + return [...sessions].sort((a, b) => { + if (sessionOrderMode === 'last_time') { + const left = sessionLastActivityMap?.get(a.id) ?? this.getSessionLastModifiedTime(a); + const right = sessionLastActivityMap?.get(b.id) ?? this.getSessionLastModifiedTime(b); + if (left !== right) return right - left; + return a.id.localeCompare(b.id, 'en'); + } + + const directoryCompare = (a.directory || '/').localeCompare((b.directory || '/'), 'zh-Hans-CN'); + if (directoryCompare !== 0) return directoryCompare; + const left = this.getSessionLastModifiedTime(b); + const right = this.getSessionLastModifiedTime(a); + if (left !== right) return left - right; + return a.id.localeCompare(b.id, 'en'); + }); + } + + private async sendQQCard( + chatId: string, + sender: PlatformSender, + payload: { markdown: string; qqText: string } + ): Promise { + if (this.isQQOnlyTextEnabled(chatId)) { + await sender.sendCard(chatId, { + qqText: payload.qqText, + forcePlainText: true, + }); + return; + } + + await sender.sendCard(chatId, payload); + } + + private async sendQQPagedCard( + chatId: string, + sender: PlatformSender, + introMarkdownLines: string[], + introTextLines: string[], + sections: Array<{ markdown: string; text: string }> + ): Promise { + if (sections.length === 0) { + await this.sendQQCard(chatId, sender, { + markdown: introMarkdownLines.join('\n'), + qqText: introTextLines.join('\n'), + }); + return; + } + + const pages: Array<{ markdown: string; qqText: string }> = []; + let currentMarkdown = introMarkdownLines.join('\n'); + let currentText = introTextLines.join('\n'); + + const pushPage = (): void => { + pages.push({ markdown: currentMarkdown, qqText: currentText }); + currentMarkdown = introMarkdownLines.join('\n'); + currentText = introTextLines.join('\n'); + }; + + for (const section of sections) { + const nextMarkdown = currentMarkdown ? `${currentMarkdown}\n${section.markdown}` : section.markdown; + const nextText = currentText ? `${currentText}\n${section.text}` : section.text; + + if (nextMarkdown.length > QQ_CARD_SOFT_LIMIT || nextText.length > QQ_CARD_SOFT_LIMIT) { + if (currentMarkdown !== introMarkdownLines.join('\n') || currentText !== introTextLines.join('\n')) { + pushPage(); + } + + if (section.markdown.length > QQ_CARD_SOFT_LIMIT || section.text.length > QQ_CARD_SOFT_LIMIT) { + pages.push({ markdown: section.markdown, qqText: section.text }); + continue; + } + + currentMarkdown = introMarkdownLines.join('\n'); + currentText = introTextLines.join('\n'); + } + + currentMarkdown = currentMarkdown ? `${currentMarkdown}\n${section.markdown}` : section.markdown; + currentText = currentText ? `${currentText}\n${section.text}` : section.text; + } + + if (currentMarkdown !== introMarkdownLines.join('\n') || currentText !== introTextLines.join('\n')) { + pages.push({ markdown: currentMarkdown, qqText: currentText }); + } + + for (const page of pages) { + await this.sendQQCard(chatId, sender, page); + } + } + private ensureStreamingBuffer(chatId: string, sessionId: string): void { const key = `chat:qq:${chatId}`; const current = outputBuffer.get(key); @@ -503,7 +702,10 @@ export class QQHandler { ): Promise { switch (command.type) { case 'help': - await sender.sendText(chatId, getQQHelpText()); + await this.sendQQCard(chatId, sender, { + markdown: getQQHelpMarkdown(), + qqText: getQQHelpText(), + }); break; case 'status': { @@ -520,6 +722,10 @@ export class QQHandler { await this.handleSessionCommand(command, chatId, senderId, sender); break; + case 'config': + await this.handleConfigCommand(command, chatId, sender); + break; + case 'model': await this.handleModelCommand(command, chatId, senderId, sender); break; @@ -630,18 +836,49 @@ export class QQHandler { return; } - const lines: string[] = ['会话列表:']; - for (const session of sessions.slice(0, 20)) { + const sortedSessions = await this.sortSessions(chatId, sessions); + const mode = this.getSessionOrderMode(chatId); + + const introMarkdownLines: string[] = [ + `# 会话列表`, + ``, + `当前排序:**${this.formatSessionOrderMode(mode)}**`, + listAll ? `范围:**全部项目**` : `范围:**当前项目**`, + ``, + `使用 ${buildQQCmdInput('/session ', '/session ')} 切换会话`, + ``, + ]; + const introTextLines: string[] = [ + '会话列表:', + `当前排序: ${this.formatSessionOrderMode(mode)}`, + `范围: ${listAll ? '全部项目' : '当前项目'}`, + '使用 /session 切换会话', + '', + ]; + const sections: Array<{ markdown: string; text: string }> = []; + + for (const session of sortedSessions) { const title = session.title || '未命名'; const shortId = session.id.slice(0, 8); - lines.push(`- ${shortId}: ${title}`); + sections.push({ + markdown: `- ${buildQQCmdInput(session.id, `${shortId}: ${title}`)}`, + text: `- ${shortId}: ${title}`, + }); } - if (sessions.length > 20) { - lines.push(`... 共 ${sessions.length} 个会话`); + sections.push({ + markdown: `共 ${sortedSessions.length} 个会话`, + text: `共 ${sortedSessions.length} 个会话`, + }); + + if (!listAll) { + sections.push({ + markdown: `提示:使用 ${buildQQCmdInput('/sessions all')} 查看所有项目会话`, + text: '提示: 使用 /sessions all 查看所有项目会话', + }); } - await sender.sendText(chatId, lines.join('\n')); + await this.sendQQPagedCard(chatId, sender, introMarkdownLines, introTextLines, sections); } catch (error) { console.error('[QQ] 获取会话列表失败:', error); await sender.sendText(chatId, '获取会话列表失败'); @@ -677,39 +914,28 @@ export class QQHandler { const providersResult = await opencodeClient.getProviders(); const providers = Array.isArray(providersResult.providers) ? providersResult.providers : []; - let matchedModel: { providerId: string; modelId: string } | null = null; - for (const provider of providers) { - const providerId = (provider as Record)?.id as string | undefined; - const models = (provider as Record)?.models; - if (!providerId || !models) continue; - - const modelList = Array.isArray(models) ? models : Object.values(models as Record); - for (const model of modelList) { - const modelId = (model as Record)?.id as string | undefined; - if (!modelId) continue; - - if ( - modelId.toLowerCase() === normalizedModelName.toLowerCase() || - `${providerId}:${modelId}`.toLowerCase() === normalizedModelName.toLowerCase() - ) { - matchedModel = { providerId, modelId }; - break; - } - } - if (matchedModel) break; - } + const matchedModel = findAllowedChatModel(providers, normalizedModelName); if (matchedModel) { chatSessionStore.updateConfigByConversation('qq', chatId, { preferredModel: `${matchedModel.providerId}:${matchedModel.modelId}`, }); await sender.sendText(chatId, `已切换模型: ${matchedModel.providerId}:${matchedModel.modelId}`); - } else if (normalizedModelName.includes(':')) { - // 强制设置格式正确的模型 + } else if (normalizedModelName.includes(':') || normalizedModelName.includes('/')) { + const parsedModel = parseChatModelReference(normalizedModelName); + if (!parsedModel) { + await sender.sendText(chatId, `未找到模型 "${normalizedModelName}"`); + return; + } + if (!isChatModelAllowed(parsedModel.providerId, parsedModel.modelId)) { + await sender.sendText(chatId, `模型 "${normalizedModelName}" 不在当前允许列表中`); + return; + } + chatSessionStore.updateConfigByConversation('qq', chatId, { - preferredModel: normalizedModelName, + preferredModel: `${parsedModel.providerId}:${parsedModel.modelId}`, }); - await sender.sendText(chatId, `已设置模型: ${normalizedModelName}`); + await sender.sendText(chatId, `已设置模型: ${parsedModel.providerId}:${parsedModel.modelId}`); } else { await sender.sendText(chatId, `未找到模型 "${normalizedModelName}"`); } @@ -733,73 +959,224 @@ export class QQHandler { return; } - const lines: string[] = ['📋 可用模型列表\n']; - let totalCount = 0; + const models = collectAllowedChatModels(providers); + const providerGroups = new Map }>(); - for (const provider of providers) { - const providerId = (provider as Record).id as string | undefined; - const providerName = (provider as Record).name || providerId || 'Unknown'; - const rawModels = (provider as Record).models; - - // models 可能是数组,也可能是对象(Map) - const models: Array<{ id: string; name?: string }> = []; - if (Array.isArray(rawModels)) { - for (const m of rawModels) { - if (m && typeof m === 'object') { - const mr = m as Record; - models.push({ - id: (mr.id as string) || '', - name: mr.name as string | undefined, - }); - } - } - } else if (rawModels && typeof rawModels === 'object') { - // SDK 返回的是对象 Map - const modelMap = rawModels as Record; - for (const [modelId, modelInfo] of Object.entries(modelMap)) { - if (modelInfo && typeof modelInfo === 'object') { - const mi = modelInfo as Record; - models.push({ - id: modelId, - name: (mi.name as string) || modelId, - }); - } - } + for (const model of models) { + if (!providerGroups.has(model.providerId)) { + providerGroups.set(model.providerId, { + providerName: model.providerName, + models: [], + }); } + providerGroups.get(model.providerId)!.models.push({ id: model.modelId, name: model.modelName }); + } + + const introMarkdownLines: string[] = [ + '# 可用模型列表', + '', + `使用 ${buildQQCmdInput('/model ', '/model <名称>')} 切换模型`, + '', + ]; + const introTextLines: string[] = [ + '可用模型列表', + '使用 /model <名称> 切换模型', + '', + ]; + const sections: Array<{ markdown: string; text: string }> = []; + let totalCount = 0; - if (models.length === 0) continue; - lines.push(`【${providerName}】`); + for (const [providerId, group] of providerGroups.entries()) { + if (group.models.length === 0) continue; - for (const model of models.slice(0, 10)) { + const providerMarkdownLines: string[] = [`## ${group.providerName}`]; + const providerTextLines: string[] = [`【${group.providerName}】`]; + + for (const model of group.models.slice(0, 10)) { const modelDisplay = model.name || model.id; - lines.push(` ${modelDisplay} (${providerId}:${model.id})`); + const modelKey = `${providerId}:${model.id}`; + providerMarkdownLines.push(`- ${buildQQCmdInput(modelKey, `${modelDisplay} (${modelKey})`)}`); + providerTextLines.push(`- ${modelDisplay} (${modelKey})`); totalCount++; } - if (models.length > 10) { - lines.push(` ... 共 ${models.length} 个模型`); + if (group.models.length > 10) { + providerMarkdownLines.push(`- _... 共 ${group.models.length} 个模型_`); + providerTextLines.push(`- ... 共 ${group.models.length} 个模型`); } - lines.push(''); + + sections.push({ + markdown: providerMarkdownLines.join('\n'), + text: providerTextLines.join('\n'), + }); } if (totalCount === 0) { - lines.push('暂无可用模型'); - } else { - lines.push(`共 ${totalCount} 个模型,使用 /model <名称> 切换`); + await sender.sendText(chatId, '暂无可用模型'); + return; } - let result = lines.join('\n'); - if (result.length > 3000) { - result = result.slice(0, 2900) + '\n\n... 列表过长,已截断'; - } + sections.push({ + markdown: `共 ${totalCount} 个模型,点击条目可自动填入模型 ID。`, + text: `共 ${totalCount} 个模型,使用 /model <名称> 切换`, + }); - await sender.sendText(chatId, result); + await this.sendQQPagedCard(chatId, sender, introMarkdownLines, introTextLines, sections); } catch (error) { console.error('[QQ] 获取模型列表失败:', error); await sender.sendText(chatId, '获取模型列表失败'); } } + private async handleConfigCommand( + command: ParsedCommand, + chatId: string, + sender: PlatformSender + ): Promise { + if (!command.configKey) { + await this.sendQQCard(chatId, sender, { + markdown: [ + '# 当前聊天配置', + '', + '- `/config session order` 查看当前会话排序模式', + '- `/config session order default` 使用默认排序', + '- `/config session order last_time` 按最后修改时间倒序', + '- `/config output onlyText true|false` 切换 QQ Markdown / 纯文本输出', + '- `/config session help_with_qc true|false` 控制 /help 后是否推送 /qc', + '- `/config session session_with_ctl true|false` 控制 /sessions 后是否推送 /session_ctl', + '- `/config session session_with_change true|false` 控制 /sessions 是否展示会话切换按钮', + ].join('\n'), + qqText: [ + '当前聊天配置', + '/config session order - 查看当前会话排序模式', + '/config session order default - 使用默认排序', + '/config session order last_time - 按最后修改时间倒序', + '/config output onlyText true|false - 切换 QQ Markdown / 纯文本输出', + '/config session help_with_qc true|false - 控制 /help 后是否推送 /qc', + '/config session session_with_ctl true|false - 控制 /sessions 后是否推送 /session_ctl', + '/config session session_with_change true|false - 控制 /sessions 是否展示会话切换按钮', + ].join('\n'), + }); + return; + } + + if (command.configScope === 'output' && command.configKey === 'only_text') { + const currentValue = this.isQQOnlyTextEnabled(chatId); + + if (!command.configValue) { + await this.sendQQCard(chatId, sender, { + markdown: [ + '# QQ 输出配置', + '', + `当前模式:**${currentValue ? '纯文本输出' : 'Markdown 输出'}**`, + '', + '- `true`:禁用 QQ Markdown,直接输出原始文本', + '- `false`:恢复 QQ Markdown 输出', + '', + '说明:当前版本电脑 QQ 的 Markdown 渲染可能不稳定,若出现内容被吞或显示异常,可切换到纯文本输出。', + ].join('\n'), + qqText: [ + 'QQ 输出配置', + `当前模式: ${currentValue ? '纯文本输出' : 'Markdown 输出'}`, + '- true: 禁用 QQ Markdown,直接输出原始文本', + '- false: 恢复 QQ Markdown 输出', + '说明: 当前版本电脑 QQ 的 Markdown 渲染可能不稳定,若出现内容被吞或显示异常,可切换到纯文本输出。', + ].join('\n'), + }); + return; + } + + if (command.configValue !== 'true' && command.configValue !== 'false') { + await sender.sendText(chatId, '配置 onlyText 仅支持 true 或 false。'); + return; + } + + const boolValue = command.configValue === 'true'; + chatSessionStore.updateConfigByConversation('qq', chatId, { + qqOutputOnlyText: boolValue, + }); + await sender.sendText(chatId, `QQ 输出模式已切换为:${boolValue ? '纯文本输出' : 'Markdown 输出'}`); + return; + } + + if (command.configScope !== 'session') { + await sender.sendText(chatId, '当前仅支持 /config session 与 /config output onlyText 配置'); + return; + } + + if (command.configKey === 'order') { + if (!command.configValue) { + const mode = this.getSessionOrderMode(chatId); + await this.sendQQCard(chatId, sender, { + markdown: [ + '# 会话排序配置', + '', + `当前模式:**${this.formatSessionOrderMode(mode)}**`, + '', + '可选值:', + '- `default` 默认排序', + '- `last_time` 按最后修改时间倒序', + ].join('\n'), + qqText: [ + '会话排序配置', + `当前模式: ${this.formatSessionOrderMode(mode)}`, + '可选值:', + '- default 默认排序', + '- last_time 按最后修改时间倒序', + ].join('\n'), + }); + return; + } + + if (command.configValue !== 'default' && command.configValue !== 'last_time') { + await sender.sendText(chatId, '不支持的排序模式。请使用 /config session order default 或 /config session order last_time。'); + return; + } + + chatSessionStore.updateConfigByConversation('qq', chatId, { + sessionOrderMode: command.configValue, + }); + await sender.sendText(chatId, `当前模式已切换为:${this.formatSessionOrderMode(command.configValue)}`); + return; + } + + if ( + command.configKey === 'help_with_qc' + || command.configKey === 'session_with_ctl' + || command.configKey === 'session_with_change' + ) { + const currentValue = command.configKey === 'help_with_qc' + ? chatSessionStore.getSessionByConversation('qq', chatId)?.helpWithQc === true + : command.configKey === 'session_with_ctl' + ? chatSessionStore.getSessionByConversation('qq', chatId)?.sessionWithCtl === true + : chatSessionStore.getSessionByConversation('qq', chatId)?.sessionWithChange === true; + + if (!command.configValue) { + await sender.sendText(chatId, `${command.configKey} 当前值: ${currentValue ? 'true' : 'false'}`); + return; + } + + if (command.configValue !== 'true' && command.configValue !== 'false') { + await sender.sendText(chatId, `配置 ${command.configKey} 仅支持 true 或 false`); + return; + } + + const boolValue = command.configValue === 'true'; + if (command.configKey === 'help_with_qc') { + chatSessionStore.updateConfigByConversation('qq', chatId, { helpWithQc: boolValue }); + } else if (command.configKey === 'session_with_ctl') { + chatSessionStore.updateConfigByConversation('qq', chatId, { sessionWithCtl: boolValue }); + } else { + chatSessionStore.updateConfigByConversation('qq', chatId, { sessionWithChange: boolValue }); + } + + await sender.sendText(chatId, `已将 ${command.configKey} 设置为 ${String(boolValue)}`); + return; + } + + await sender.sendText(chatId, '当前仅支持 /config session 下的会话排序与展示配置,以及 /config output onlyText'); + } + /** * 处理 agent 命令 */ @@ -1052,10 +1429,69 @@ export class QQHandler { const sessionData = chatSessionStore.getSessionByConversation('qq', chatId); const directory = sessionData?.resolvedDirectory; - const variant = promptEffort || config?.preferredEffort; + let variant = promptEffort || config?.preferredEffort; + + // 验证 variant 是否与当前模型兼容 + if (variant && providerId && modelId) { + try { + const providersPayload = await opencodeClient.getProviders(); + const providers = Array.isArray(providersPayload.providers) ? providersPayload.providers : []; + const providerLower = providerId.toLowerCase(); + const modelLower = modelId.toLowerCase(); + + for (const provider of providers) { + if (!provider || typeof provider !== 'object') continue; + const providerRecord = provider as Record; + const providerIdRaw = typeof providerRecord.id === 'string' ? providerRecord.id.trim() : ''; + if (!providerIdRaw || providerIdRaw.toLowerCase() !== providerLower) continue; + + const modelsRaw = providerRecord.models; + const modelList = Array.isArray(modelsRaw) + ? modelsRaw + : (modelsRaw && typeof modelsRaw === 'object' ? Object.values(modelsRaw) : []); + + for (const modelItem of modelList) { + if (!modelItem || typeof modelItem !== 'object') continue; + const modelRecord = modelItem as Record; + const modelIdRaw = typeof modelRecord.id === 'string' + ? modelRecord.id.trim() + : (typeof modelRecord.modelID === 'string' ? modelRecord.modelID.trim() : ''); + if (!modelIdRaw || modelIdRaw.toLowerCase() !== modelLower) continue; + + // 解析模型支持的 variants + const variants = modelRecord.variants; + if (variants && typeof variants === 'object' && !Array.isArray(variants)) { + const supportedVariants: EffortLevel[] = []; + for (const key of Object.keys(variants as Record)) { + const normalized = normalizeEffortLevel(key); + if (normalized && normalized !== 'none' && !supportedVariants.includes(normalized)) { + supportedVariants.push(normalized); + } + } + // 如果当前 variant 不在支持列表中,清除它 + if (supportedVariants.length > 0 && !supportedVariants.includes(variant)) { + variant = undefined; + } + } + break; + } + break; + } + } catch (error) { + console.debug('[QQ] 获取模型支持的 variants 失败,跳过验证:', error instanceof Error ? error.message : String(error)); + } + } + + // ── 非多模态主模型图片回退 ── + const dispatchParts = await preprocessVisionParts( + parts as VisionPart[], + { providerId, modelId, directory }, + 'QQ', + ) as OpencodePartInput[]; + await opencodeClient.sendMessagePartsAsync( sessionId, - parts, + dispatchParts, { providerId, modelId, @@ -1121,7 +1557,7 @@ export class QQHandler { }); const buffer = Buffer.from(response.data); - const contentType = response.headers['content-type'] || ''; + const contentType = (response.headers['content-type'] as string) || ''; // 确定文件扩展名 const extFromName = attachment.fileName ? extractExtension(attachment.fileName) : ''; @@ -1183,4 +1619,4 @@ export class QQHandler { } } -export const qqHandler = new QQHandler(); \ No newline at end of file +export const qqHandler = new QQHandler(); diff --git a/src/handlers/telegram.ts b/src/handlers/telegram.ts index e969fb2..a032d92 100644 --- a/src/handlers/telegram.ts +++ b/src/handlers/telegram.ts @@ -10,12 +10,12 @@ import path from 'path'; import { randomUUID } from 'crypto'; import { modelConfig, attachmentConfig } from '../config.js'; import { opencodeClient } from '../opencode/client.js'; +import { preprocessVisionParts, type VisionPart } from '../services/vision-ocr.js'; import { outputBuffer } from '../opencode/output-buffer.js'; import { chatSessionStore } from '../store/chat-session.js'; import { parseCommand, getHelpText, type ParsedCommand } from '../commands/parser.js'; import { DirectoryPolicy } from '../utils/directory-policy.js'; import { buildSessionTimestamp } from '../utils/session-title.js'; -import { shouldSkipGroupMessage } from '../utils/group-mention.js'; import type { PlatformMessageEvent, PlatformSender, PlatformAttachment } from '../platform/types.js'; import type { EffortLevel } from '../commands/effort.js'; import { normalizeEffortLevel, KNOWN_EFFORT_LEVELS } from '../commands/effort.js'; @@ -24,6 +24,12 @@ import { permissionHandler } from '../permissions/handler.js'; import { questionHandler, type PendingQuestion } from '../opencode/question-handler.js'; import { parseQuestionAnswerText } from '../opencode/question-parser.js'; import { telegramAdapter } from '../platform/adapters/telegram-adapter.js'; +import { + collectAllowedChatModels, + findAllowedChatModel, + isChatModelAllowed, + parseChatModelReference, +} from '../utils/chat-model-whitelist.js'; // 附件相关配置 const ATTACHMENT_BASE_DIR = path.resolve(process.cwd(), 'tmp', 'telegram-uploads'); @@ -120,7 +126,7 @@ function parsePermissionDecision(raw: string): PermissionDecision | null { export class TelegramHandler { private ensureStreamingBuffer(chatId: string, sessionId: string): void { - const key = `chat:${chatId}`; + const key = `chat:telegram:${chatId}`; const current = outputBuffer.get(key); if (current && current.status !== 'running') { outputBuffer.clear(key); @@ -180,10 +186,8 @@ export class TelegramHandler { event: PlatformMessageEvent, sender: PlatformSender ): Promise { - // 群聊 @ 提到检查 - if (shouldSkipGroupMessage(event)) { - return; - } + // 注意:群聊 @ 提到检查已在 telegram-adapter 中处理(通过 botUsername 正则过滤), + // 此处不再重复调用 shouldSkipGroupMessage,避免因 mentions 字段未填充导致群消息被误丢弃。 const { conversationId: chatId, content, senderId, attachments } = event; const trimmed = content.trim(); @@ -1082,8 +1086,27 @@ export class TelegramHandler { // 设置模型 const normalizedModelName = modelName.trim(); - chatSessionStore.updateConfigByConversation('telegram', chatId, { preferredModel: normalizedModelName }); - await sender.sendText(chatId, `✅ 已设置模型: ${normalizedModelName}`); + const matchedModel = findAllowedChatModel(providers, normalizedModelName); + if (matchedModel) { + const preferredModel = `${matchedModel.providerId}:${matchedModel.modelId}`; + chatSessionStore.updateConfigByConversation('telegram', chatId, { preferredModel }); + await sender.sendText(chatId, `✅ 已设置模型: ${preferredModel}`); + return; + } + + const parsedModel = parseChatModelReference(normalizedModelName); + if (!parsedModel) { + await sender.sendText(chatId, `❌ 未找到模型: ${normalizedModelName}`); + return; + } + if (!isChatModelAllowed(parsedModel.providerId, parsedModel.modelId)) { + await sender.sendText(chatId, `❌ 模型 "${normalizedModelName}" 不在当前允许列表中`); + return; + } + + const preferredModel = `${parsedModel.providerId}:${parsedModel.modelId}`; + chatSessionStore.updateConfigByConversation('telegram', chatId, { preferredModel }); + await sender.sendText(chatId, `✅ 已设置模型: ${preferredModel}`); } catch (error) { console.error('[Telegram] 设置模型失败:', error); await sender.sendText(chatId, '❌ 设置模型失败'); @@ -1107,49 +1130,29 @@ export class TelegramHandler { const lines: string[] = ['📋 **可用模型列表**\n']; let totalCount = 0; - for (const provider of providers) { - const providerId = (provider as Record).id as string | undefined; - const providerName = (provider as Record).name || providerId || 'Unknown'; - const rawModels = (provider as Record).models; - - // models 可能是数组,也可能是对象(Map) - const models: Array<{ id: string; name?: string }> = []; - if (Array.isArray(rawModels)) { - for (const m of rawModels) { - if (m && typeof m === 'object') { - const mr = m as Record; - models.push({ - id: (mr.id as string) || '', - name: mr.name as string | undefined, - }); - } - } - } else if (rawModels && typeof rawModels === 'object') { - // SDK 返回的是对象 Map - const modelMap = rawModels as Record; - for (const [modelId, modelInfo] of Object.entries(modelMap)) { - if (modelInfo && typeof modelInfo === 'object') { - const mi = modelInfo as Record; - models.push({ - id: modelId, - name: (mi.name as string) || modelId, - }); - } - } + const models = collectAllowedChatModels(providers); + const providerGroups = new Map }>(); + + for (const model of models) { + if (!providerGroups.has(model.providerId)) { + providerGroups.set(model.providerId, { providerName: model.providerName, models: [] }); } + providerGroups.get(model.providerId)!.models.push({ id: model.modelId, name: model.modelName }); + } - if (models.length === 0) continue; - lines.push(`**${providerName}**`); + for (const [providerId, group] of providerGroups.entries()) { + if (group.models.length === 0) continue; + lines.push(`**${group.providerName}**`); - for (const model of models.slice(0, 15)) { + for (const model of group.models.slice(0, 15)) { const modelDisplay = model.name || model.id; const modelKey = `${providerId}:${model.id}`; lines.push(` • ${modelDisplay} (\`${modelKey}\`)`); totalCount++; } - if (models.length > 15) { - lines.push(` _... 共 ${models.length} 个模型_`); + if (group.models.length > 15) { + lines.push(` _... 共 ${group.models.length} 个模型_`); } lines.push(''); } @@ -1479,7 +1482,7 @@ export class TelegramHandler { promptEffort?: EffortLevel, sender?: PlatformSender ): Promise { - const bufferKey = `chat:${chatId}`; + const bufferKey = `chat:telegram:${chatId}`; this.ensureStreamingBuffer(chatId, sessionId); if (!sender) { @@ -1536,10 +1539,69 @@ export class TelegramHandler { const directory = sessionData?.resolvedDirectory; // 异步触发 OpenCode 请求 - const variant = promptEffort || config?.preferredEffort; + let variant = promptEffort || config?.preferredEffort; + + // 验证 variant 是否与当前模型兼容 + if (variant && providerId && modelId) { + try { + const providersPayload = await opencodeClient.getProviders(); + const providers = Array.isArray(providersPayload.providers) ? providersPayload.providers : []; + const providerLower = providerId.toLowerCase(); + const modelLower = modelId.toLowerCase(); + + for (const provider of providers) { + if (!provider || typeof provider !== 'object') continue; + const providerRecord = provider as Record; + const providerIdRaw = typeof providerRecord.id === 'string' ? providerRecord.id.trim() : ''; + if (!providerIdRaw || providerIdRaw.toLowerCase() !== providerLower) continue; + + const modelsRaw = providerRecord.models; + const modelList = Array.isArray(modelsRaw) + ? modelsRaw + : (modelsRaw && typeof modelsRaw === 'object' ? Object.values(modelsRaw) : []); + + for (const modelItem of modelList) { + if (!modelItem || typeof modelItem !== 'object') continue; + const modelRecord = modelItem as Record; + const modelIdRaw = typeof modelRecord.id === 'string' + ? modelRecord.id.trim() + : (typeof modelRecord.modelID === 'string' ? modelRecord.modelID.trim() : ''); + if (!modelIdRaw || modelIdRaw.toLowerCase() !== modelLower) continue; + + // 解析模型支持的 variants + const variants = modelRecord.variants; + if (variants && typeof variants === 'object' && !Array.isArray(variants)) { + const supportedVariants: EffortLevel[] = []; + for (const key of Object.keys(variants as Record)) { + const normalized = normalizeEffortLevel(key); + if (normalized && normalized !== 'none' && !supportedVariants.includes(normalized)) { + supportedVariants.push(normalized); + } + } + // 如果当前 variant 不在支持列表中,清除它 + if (supportedVariants.length > 0 && !supportedVariants.includes(variant)) { + variant = undefined; + } + } + break; + } + break; + } + } catch (error) { + console.debug('[Telegram] 获取模型支持的 variants 失败,跳过验证:', error instanceof Error ? error.message : String(error)); + } + } + + // ── 非多模态主模型图片回退 ── + const dispatchParts = await preprocessVisionParts( + parts as VisionPart[], + { providerId, modelId, directory }, + 'Telegram', + ) as OpencodePartInput[]; + await opencodeClient.sendMessagePartsAsync( sessionId, - parts, + dispatchParts, { providerId, modelId, @@ -1640,4 +1702,4 @@ export class TelegramHandler { } } -export const telegramHandler = new TelegramHandler(); \ No newline at end of file +export const telegramHandler = new TelegramHandler(); diff --git a/src/handlers/wecom.ts b/src/handlers/wecom.ts index 5068af2..31a2163 100644 --- a/src/handlers/wecom.ts +++ b/src/handlers/wecom.ts @@ -9,6 +9,7 @@ import path from 'path'; import { randomUUID } from 'crypto'; import { modelConfig, attachmentConfig } from '../config.js'; import { opencodeClient } from '../opencode/client.js'; +import { preprocessVisionParts, type VisionPart } from '../services/vision-ocr.js'; import { outputBuffer } from '../opencode/output-buffer.js'; import { chatSessionStore } from '../store/chat-session.js'; import { parseCommand, type ParsedCommand } from '../commands/parser.js'; @@ -20,6 +21,12 @@ import { permissionHandler } from '../permissions/handler.js'; import { questionHandler, type PendingQuestion } from '../opencode/question-handler.js'; import { parseQuestionAnswerText } from '../opencode/question-parser.js'; import type { PlatformMessageEvent, PlatformSender, PlatformAttachment } from '../platform/types.js'; +import { + collectAllowedChatModels, + findAllowedChatModel, + isChatModelAllowed, + parseChatModelReference, +} from '../utils/chat-model-whitelist.js'; // 附件相关配置 const ATTACHMENT_BASE_DIR = path.resolve(process.cwd(), 'tmp', 'wecom-uploads'); @@ -482,8 +489,36 @@ export class WeComHandler { return; } - chatSessionStore.updateConfig(chatId, { preferredModel: modelName }); - await sender.sendText(chatId, `已切换模型: ${modelName}`); + try { + const providersResult = await opencodeClient.getProviders(); + const providers = Array.isArray(providersResult.providers) ? providersResult.providers : []; + const normalizedModelName = modelName.trim(); + const matchedModel = findAllowedChatModel(providers, normalizedModelName); + + if (matchedModel) { + const preferredModel = `${matchedModel.providerId}:${matchedModel.modelId}`; + chatSessionStore.updateConfigByConversation('wecom', chatId, { preferredModel }); + await sender.sendText(chatId, `已切换模型: ${preferredModel}`); + return; + } + + const parsedModel = parseChatModelReference(normalizedModelName); + if (!parsedModel) { + await sender.sendText(chatId, `未找到模型 "${normalizedModelName}"`); + return; + } + if (!isChatModelAllowed(parsedModel.providerId, parsedModel.modelId)) { + await sender.sendText(chatId, `模型 "${normalizedModelName}" 不在当前允许列表中`); + return; + } + + const preferredModel = `${parsedModel.providerId}:${parsedModel.modelId}`; + chatSessionStore.updateConfigByConversation('wecom', chatId, { preferredModel }); + await sender.sendText(chatId, `已切换模型: ${preferredModel}`); + } catch (error) { + console.error('[WeCom] 设置模型失败:', error); + await sender.sendText(chatId, '设置模型失败'); + } } /** @@ -497,35 +532,28 @@ export class WeComHandler { const lines: string[] = ['📋 可用模型列表\n']; let totalCount = 0; - for (const provider of providers) { - const providerId = (provider as Record).id as string | undefined; - const providerName = (provider as Record).name || providerId || 'Unknown'; - const rawModels = (provider as Record).models; - - const models: Array<{ id: string; name?: string }> = []; - if (Array.isArray(rawModels)) { - for (const m of rawModels) { - if (m && typeof m === 'object') { - const mr = m as Record; - models.push({ - id: (mr.id as string) || '', - name: mr.name as string | undefined, - }); - } - } + const models = collectAllowedChatModels(providers); + const providerGroups = new Map }>(); + + for (const model of models) { + if (!providerGroups.has(model.providerId)) { + providerGroups.set(model.providerId, { providerName: model.providerName, models: [] }); } + providerGroups.get(model.providerId)!.models.push({ id: model.modelId, name: model.modelName }); + } - if (models.length === 0) continue; - lines.push(`【${providerName}】`); + for (const [providerId, group] of providerGroups.entries()) { + if (group.models.length === 0) continue; + lines.push(`【${group.providerName}】`); - for (const model of models.slice(0, 10)) { + for (const model of group.models.slice(0, 10)) { const modelDisplay = model.name || model.id; lines.push(` ${modelDisplay} (${providerId}:${model.id})`); totalCount++; } - if (models.length > 10) { - lines.push(` ... 共 ${models.length} 个模型`); + if (group.models.length > 10) { + lines.push(` ... 共 ${group.models.length} 个模型`); } lines.push(''); } @@ -1304,10 +1332,69 @@ export class WeComHandler { let directory = sessionData?.resolvedDirectory; // 异步触发 OpenCode 请求 - const variant = promptEffort || config?.preferredEffort; + let variant = promptEffort || config?.preferredEffort; + + // 验证 variant 是否与当前模型兼容 + if (variant && providerId && modelId) { + try { + const providersPayload = await opencodeClient.getProviders(); + const providers = Array.isArray(providersPayload.providers) ? providersPayload.providers : []; + const providerLower = providerId.toLowerCase(); + const modelLower = modelId.toLowerCase(); + + for (const provider of providers) { + if (!provider || typeof provider !== 'object') continue; + const providerRecord = provider as Record; + const providerIdRaw = typeof providerRecord.id === 'string' ? providerRecord.id.trim() : ''; + if (!providerIdRaw || providerIdRaw.toLowerCase() !== providerLower) continue; + + const modelsRaw = providerRecord.models; + const modelList = Array.isArray(modelsRaw) + ? modelsRaw + : (modelsRaw && typeof modelsRaw === 'object' ? Object.values(modelsRaw) : []); + + for (const modelItem of modelList) { + if (!modelItem || typeof modelItem !== 'object') continue; + const modelRecord = modelItem as Record; + const modelIdRaw = typeof modelRecord.id === 'string' + ? modelRecord.id.trim() + : (typeof modelRecord.modelID === 'string' ? modelRecord.modelID.trim() : ''); + if (!modelIdRaw || modelIdRaw.toLowerCase() !== modelLower) continue; + + // 解析模型支持的 variants + const variants = modelRecord.variants; + if (variants && typeof variants === 'object' && !Array.isArray(variants)) { + const supportedVariants: EffortLevel[] = []; + for (const key of Object.keys(variants as Record)) { + const normalized = normalizeEffortLevel(key); + if (normalized && normalized !== 'none' && !supportedVariants.includes(normalized)) { + supportedVariants.push(normalized); + } + } + // 如果当前 variant 不在支持列表中,清除它 + if (supportedVariants.length > 0 && !supportedVariants.includes(variant)) { + variant = undefined; + } + } + break; + } + break; + } + } catch (error) { + console.debug('[企业微信] 获取模型支持的 variants 失败,跳过验证:', error instanceof Error ? error.message : String(error)); + } + } + + // ── 非多模态主模型图片回退 ── + const dispatchParts = await preprocessVisionParts( + parts as VisionPart[], + { providerId, modelId, directory }, + '企业微信', + ) as OpencodePartInput[]; + await opencodeClient.sendMessagePartsAsync( sessionId, - parts, + dispatchParts, { providerId, modelId, @@ -1443,4 +1530,4 @@ export class WeComHandler { } } -export const wecomHandler = new WeComHandler(); \ No newline at end of file +export const wecomHandler = new WeComHandler(); diff --git a/src/handlers/weixin.ts b/src/handlers/weixin.ts index 481749e..001da21 100644 --- a/src/handlers/weixin.ts +++ b/src/handlers/weixin.ts @@ -10,6 +10,7 @@ import { decodeWeixinChatId } from '../platform/adapters/weixin/weixin-ids.js'; import { configStore } from '../store/config-store.js'; import { modelConfig, attachmentConfig } from '../config.js'; import { opencodeClient } from '../opencode/client.js'; +import { preprocessVisionParts, type VisionPart } from '../services/vision-ocr.js'; import { outputBuffer } from '../opencode/output-buffer.js'; import { chatSessionStore } from '../store/chat-session.js'; import { parseCommand, type ParsedCommand } from '../commands/parser.js'; @@ -21,6 +22,12 @@ import { normalizeEffortLevel, KNOWN_EFFORT_LEVELS } from '../commands/effort.js import { permissionHandler } from '../permissions/handler.js'; import { questionHandler, type PendingQuestion } from '../opencode/question-handler.js'; import { parseQuestionAnswerText } from '../opencode/question-parser.js'; +import { + collectAllowedChatModels, + findAllowedChatModel, + isChatModelAllowed, + parseChatModelReference, +} from '../utils/chat-model-whitelist.js'; const WEIXIN_MESSAGE_LIMIT = 1800; const WEIXIN_HELP_TEXT = `📖 **微信 × OpenCode 机器人指南** @@ -710,8 +717,27 @@ export class WeixinHandler { } const normalizedModelName = modelName.trim(); - chatSessionStore.updateConfigByConversation('weixin', conversationId, { preferredModel: normalizedModelName }); - await sender?.sendText(conversationId, `✅ 已设置模型: ${normalizedModelName}`); + const matchedModel = findAllowedChatModel(providers, normalizedModelName); + if (matchedModel) { + const preferredModel = `${matchedModel.providerId}:${matchedModel.modelId}`; + chatSessionStore.updateConfigByConversation('weixin', conversationId, { preferredModel }); + await sender?.sendText(conversationId, `✅ 已设置模型: ${preferredModel}`); + return; + } + + const parsedModel = parseChatModelReference(normalizedModelName); + if (!parsedModel) { + await sender?.sendText(conversationId, `❌ 未找到模型: ${normalizedModelName}`); + return; + } + if (!isChatModelAllowed(parsedModel.providerId, parsedModel.modelId)) { + await sender?.sendText(conversationId, `❌ 模型 "${normalizedModelName}" 不在当前允许列表中`); + return; + } + + const preferredModel = `${parsedModel.providerId}:${parsedModel.modelId}`; + chatSessionStore.updateConfigByConversation('weixin', conversationId, { preferredModel }); + await sender?.sendText(conversationId, `✅ 已设置模型: ${preferredModel}`); } catch (error) { console.error('[Weixin] 设置模型失败:', error); await sender?.sendText(conversationId, '❌ 设置模型失败'); @@ -778,49 +804,29 @@ export class WeixinHandler { const lines: string[] = ['📋 **可用模型列表**\n']; let totalCount = 0; - for (const provider of providers) { - const providerId = (provider as Record).id as string | undefined; - const providerName = (provider as Record).name || providerId || 'Unknown'; - const rawModels = (provider as Record).models; - - // models 可能是数组,也可能是对象(Map) - const models: Array<{ id: string; name?: string }> = []; - if (Array.isArray(rawModels)) { - for (const m of rawModels) { - if (m && typeof m === 'object') { - const mr = m as Record; - models.push({ - id: (mr.id as string) || '', - name: mr.name as string | undefined, - }); - } - } - } else if (rawModels && typeof rawModels === 'object') { - // SDK 返回的是对象 Map - const modelMap = rawModels as Record; - for (const [modelId, modelInfo] of Object.entries(modelMap)) { - if (modelInfo && typeof modelInfo === 'object') { - const mi = modelInfo as Record; - models.push({ - id: modelId, - name: (mi.name as string) || modelId, - }); - } - } + const models = collectAllowedChatModels(providers); + const providerGroups = new Map }>(); + + for (const model of models) { + if (!providerGroups.has(model.providerId)) { + providerGroups.set(model.providerId, { providerName: model.providerName, models: [] }); } + providerGroups.get(model.providerId)!.models.push({ id: model.modelId, name: model.modelName }); + } - if (models.length === 0) continue; - lines.push(`**${providerName}**`); + for (const [providerId, group] of providerGroups.entries()) { + if (group.models.length === 0) continue; + lines.push(`**${group.providerName}**`); - for (const model of models.slice(0, 15)) { + for (const model of group.models.slice(0, 15)) { const modelDisplay = model.name || model.id; const modelKey = `${providerId}:${model.id}`; lines.push(` • ${modelDisplay} (\`${modelKey}\`)`); totalCount++; } - if (models.length > 15) { - lines.push(` _... 共 ${models.length} 个模型_`); + if (group.models.length > 15) { + lines.push(` _... 共 ${group.models.length} 个模型_`); } lines.push(''); } @@ -1164,13 +1170,72 @@ export class WeixinHandler { } } - const effectiveEffort = promptEffort || config?.preferredEffort; + let effectiveEffort = promptEffort || config?.preferredEffort; + + // 验证 variant 是否与当前模型兼容 + if (effectiveEffort && providerId && modelId) { + try { + const providersPayload = await opencodeClient.getProviders(); + const providers = Array.isArray(providersPayload.providers) ? providersPayload.providers : []; + const providerLower = providerId.toLowerCase(); + const modelLower = modelId.toLowerCase(); + + for (const provider of providers) { + if (!provider || typeof provider !== 'object') continue; + const providerRecord = provider as Record; + const providerIdRaw = typeof providerRecord.id === 'string' ? providerRecord.id.trim() : ''; + if (!providerIdRaw || providerIdRaw.toLowerCase() !== providerLower) continue; + + const modelsRaw = providerRecord.models; + const modelList = Array.isArray(modelsRaw) + ? modelsRaw + : (modelsRaw && typeof modelsRaw === 'object' ? Object.values(modelsRaw) : []); + + for (const modelItem of modelList) { + if (!modelItem || typeof modelItem !== 'object') continue; + const modelRecord = modelItem as Record; + const modelIdRaw = typeof modelRecord.id === 'string' + ? modelRecord.id.trim() + : (typeof modelRecord.modelID === 'string' ? modelRecord.modelID.trim() : ''); + if (!modelIdRaw || modelIdRaw.toLowerCase() !== modelLower) continue; + + // 解析模型支持的 variants + const variants = modelRecord.variants; + if (variants && typeof variants === 'object' && !Array.isArray(variants)) { + const supportedVariants: EffortLevel[] = []; + for (const key of Object.keys(variants as Record)) { + const normalized = normalizeEffortLevel(key); + if (normalized && normalized !== 'none' && !supportedVariants.includes(normalized)) { + supportedVariants.push(normalized); + } + } + // 如果当前 variant 不在支持列表中,清除它 + if (supportedVariants.length > 0 && !supportedVariants.includes(effectiveEffort)) { + effectiveEffort = undefined; + } + } + break; + } + break; + } + } catch (error) { + console.debug('[Weixin] 获取模型支持的 variants 失败,跳过验证:', error instanceof Error ? error.message : String(error)); + } + } + const sessionData = chatSessionStore.getSessionByConversation('weixin', chatId); const directory = sessionData?.resolvedDirectory; + // ── 非多模态主模型图片回退 ── + const dispatchParts = await preprocessVisionParts( + parts as VisionPart[], + { providerId, modelId, directory }, + 'Weixin', + ) as OpencodePartInput[]; + await opencodeClient.sendMessagePartsAsync( sessionId, - parts, + dispatchParts, { providerId, modelId, @@ -1240,4 +1305,4 @@ export class WeixinHandler { } } -export const weixinHandler = new WeixinHandler(); \ No newline at end of file +export const weixinHandler = new WeixinHandler(); diff --git a/src/handlers/whatsapp.ts b/src/handlers/whatsapp.ts index a8f40d0..0bfff4d 100644 --- a/src/handlers/whatsapp.ts +++ b/src/handlers/whatsapp.ts @@ -7,9 +7,11 @@ import { modelConfig, attachmentConfig } from '../config.js'; import { opencodeClient } from '../opencode/client.js'; +import { preprocessVisionParts, type VisionPart } from '../services/vision-ocr.js'; import { outputBuffer } from '../opencode/output-buffer.js'; import { chatSessionStore } from '../store/chat-session.js'; import { parseCommand } from '../commands/parser.js'; +import { normalizeEffortLevel, type EffortLevel } from '../commands/effort.js'; import { PlatformCommandHandler } from './platform-command.handler.js'; import { DirectoryPolicy } from '../utils/directory-policy.js'; import { buildSessionTimestamp } from '../utils/session-title.js'; @@ -18,7 +20,6 @@ import { permissionHandler } from '../permissions/handler.js'; import { questionHandler } from '../opencode/question-handler.js'; import { parseQuestionAnswerText } from '../opencode/question-parser.js'; import type { PlatformMessageEvent, PlatformSender } from '../platform/types.js'; -import type { EffortLevel } from '../commands/effort.js'; import type { PendingPermission } from '../permissions/handler.js'; type OpencodeFilePartInput = { type: 'file'; mime: string; url: string; filename?: string }; @@ -613,10 +614,69 @@ export class WhatsAppHandler { const sessionData = chatSessionStore.getSessionByConversation('whatsapp', chatId); const directory = sessionData?.resolvedDirectory; - const variant = promptEffort || config?.preferredEffort; + let variant = promptEffort || config?.preferredEffort; + + // 验证 variant 是否与当前模型兼容 + if (variant && providerId && modelId) { + try { + const providersPayload = await opencodeClient.getProviders(); + const providers = Array.isArray(providersPayload.providers) ? providersPayload.providers : []; + const providerLower = providerId.toLowerCase(); + const modelLower = modelId.toLowerCase(); + + for (const provider of providers) { + if (!provider || typeof provider !== 'object') continue; + const providerRecord = provider as Record; + const providerIdRaw = typeof providerRecord.id === 'string' ? providerRecord.id.trim() : ''; + if (!providerIdRaw || providerIdRaw.toLowerCase() !== providerLower) continue; + + const modelsRaw = providerRecord.models; + const modelList = Array.isArray(modelsRaw) + ? modelsRaw + : (modelsRaw && typeof modelsRaw === 'object' ? Object.values(modelsRaw) : []); + + for (const modelItem of modelList) { + if (!modelItem || typeof modelItem !== 'object') continue; + const modelRecord = modelItem as Record; + const modelIdRaw = typeof modelRecord.id === 'string' + ? modelRecord.id.trim() + : (typeof modelRecord.modelID === 'string' ? modelRecord.modelID.trim() : ''); + if (!modelIdRaw || modelIdRaw.toLowerCase() !== modelLower) continue; + + // 解析模型支持的 variants + const variants = modelRecord.variants; + if (variants && typeof variants === 'object' && !Array.isArray(variants)) { + const supportedVariants: EffortLevel[] = []; + for (const key of Object.keys(variants as Record)) { + const normalized = normalizeEffortLevel(key); + if (normalized && normalized !== 'none' && !supportedVariants.includes(normalized)) { + supportedVariants.push(normalized); + } + } + // 如果当前 variant 不在支持列表中,清除它 + if (supportedVariants.length > 0 && !supportedVariants.includes(variant)) { + variant = undefined; + } + } + break; + } + break; + } + } catch (error) { + console.debug('[WhatsApp] 获取模型支持的 variants 失败,跳过验证:', error instanceof Error ? error.message : String(error)); + } + } + + // ── 非多模态主模型图片回退 ── + const dispatchParts = await preprocessVisionParts( + parts as VisionPart[], + { providerId, modelId, directory }, + 'WhatsApp', + ) as OpencodePartInput[]; + await opencodeClient.sendMessagePartsAsync( sessionId, - parts, + dispatchParts, { providerId, modelId, diff --git a/src/index.ts b/src/index.ts index 16601c8..427801c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,12 @@ import { spawn } from 'node:child_process'; -import pkg from '../package.json' with { type: 'json' }; +import { VERSION } from './utils/version.js'; import { initLogger } from './utils/logger.js'; import { logStore } from './store/log-store.js'; import { createAdminServer } from './admin/admin-server.js'; import { feishuClient, type FeishuMessageEvent } from './feishu/client.js'; // 平台适配器动态加载,不再静态导入 import type { PlatformSender, PlatformAdapter } from './platform/types.js'; -import { loadAllConfigured, getSenderByPlatform, getCachedAdapter, getConfiguredPlatforms } from './platform/loader.js'; +import { loadAllConfigured, getSenderByPlatform, getCachedAdapter, getConfiguredPlatforms, clearCache } from './platform/loader.js'; import { opencodeClient, type PermissionRequestEvent } from './opencode/client.js'; import { streamStateManager, type ToolRuntimeState, type TimelineSegment, type StreamTimelineState } from './store/stream-state.js'; import { buildTelegramText, buildPortableUpdateText, buildPortableUpdatePayload } from './utils/text-builder.js'; @@ -513,7 +513,7 @@ async function main() { initLogger(logStore); console.log('╔════════════════════════════════════════════════╗'); - console.log('║ 飞书 × OpenCode 桥接服务 v' + pkg.version + ' ║'); + console.log('║ 飞书 × OpenCode 桥接服务 v' + VERSION + ' ║'); console.log('╚════════════════════════════════════════════════╝'); // 0. 动态加载已配置的平台适配器(避免全量加载 SDK) @@ -521,52 +521,116 @@ async function main() { console.log(`[Platform] 已配置的平台: ${configuredPlatforms.join(', ') || '无'}`); await loadAllConfigured(); - // 1. 如果启用了 OpenCode 自动启动,先清理旧进程并启动 - let opencodeChildProcess: import('node:child_process').ChildProcess | undefined; + // 1. 如果启用了 OpenCode 自动启动,通过 process-manager 幂等启动后台服务 if (opencodeConfig.autoStart) { try { - const { cleanupOpenCodeProcesses } = await import('./utils/process-cleanup.js'); - await cleanupOpenCodeProcesses(); + console.log('[Index] 正在启动 OpenCode serve(后台模式)...'); + const { spawnSync, spawn } = await import('node:child_process'); + const { fileURLToPath } = await import('node:url'); + const pathMod = await import('node:path'); + const isWindows = process.platform === 'win32'; - // 等待 3 秒确保 OpenCode 进程完全退出 - await new Promise(resolve => setTimeout(resolve, 3000)); + // 确定 process-manager 路径(兼容开发/打包两种环境) + // 开发模式检测(process.resourcesPath 是 Electron 特有属性) + const isDev = process.env.NODE_ENV === 'development' || !(process as any).resourcesPath; + let processManagerPath: string; + if ((process as any).resourcesPath && !isDev) { + // Electron 打包后:scripts 在 resources/app/scripts/ + processManagerPath = pathMod.join((process as any).resourcesPath, 'app', 'scripts', 'process-manager.mjs'); + } else { + // 开发环境:src/index.ts 位于项目根下的 src/;构建后:dist/index.js 位于项目根下的 dist/ + // 这两种布局到项目根的相对层级一致,因此统一回退一层即可定位 scripts/。 + const selfDir = pathMod.dirname(fileURLToPath(import.meta.url)); + processManagerPath = pathMod.resolve(selfDir, '../scripts/process-manager.mjs'); + } - const { spawn } = await import('node:child_process'); - // Windows 下需要 shell: true 才能正确执行带参数的命令 - const isWindows = process.platform === 'win32'; - const cmdParts = opencodeConfig.autoStartCmd.split(' '); - opencodeChildProcess = spawn(cmdParts[0], cmdParts.slice(1), { - stdio: 'ignore', - detached: true, - shell: isWindows, + // 检查 process-manager 是否存在 + console.log(`[Index] process-manager 路径: ${processManagerPath}`); + + // 使用 start-opencode(幂等:已在运行则跳过) + const startResult = spawnSync(process.execPath, [processManagerPath, 'start-opencode'], { + encoding: 'utf-8', windowsHide: isWindows, + timeout: 15000, + stdio: 'pipe', }); - opencodeChildProcess.on('error', (err) => { - console.error('[Index] OpenCode 子进程错误:', err); - }); + if (startResult.stdout?.trim()) { + console.log('[Index] OpenCode 启动输出:', startResult.stdout.trim()); + } + if (startResult.stderr?.trim()) { + console.warn('[Index] OpenCode 启动错误:', startResult.stderr.trim()); + } - opencodeChildProcess.on('exit', (code) => { - console.log(`[Index] OpenCode 子进程已退出,code=${code}`); - }); + if (startResult.status !== 0) { + console.error(`[Index] OpenCode 自动启动失败,退出码: ${startResult.status}(将继续启动 Bridge)`); + if (startResult.error) { + console.error('[Index] 错误详情:', startResult.error.message); + } + } else { + console.log('[Index] OpenCode serve 启动成功'); + } + + // 如果开启了前台模式,等待 opencode serve 的端口就绪后再弹出 attach 窗口(Windows 专用) + // 做法: + // 1. 轮询 TCP(最多 15s),等待 http://localhost: 可连接 + // 2. 通过 PowerShell 的 Start-Process 拉起一个新的可见 CMD 控制台跑 opencode attach + // —— 不能再用 `cmd /c start ... + windowsHide:true`:父进程无 console 时 + // CREATE_NO_WINDOW 会传染到 start,导致弹窗失败。 + // 核心约束:后台 opencode serve 由 process-manager 用 Start-Process -WindowStyle Hidden + // 启动且不能弹任何 CMD,本块只影响"前台 attach 窗口",与之相互独立。 + if (opencodeConfig.autoStartForeground && isWindows) { + void (async () => { + const { probeTcpPort } = await import('./reliability/process-guard.js'); + const host = opencodeConfig.host; + const port = opencodeConfig.port; + const attachUrl = `http://${host}:${port}`; + const deadline = Date.now() + 15000; + + let ready = false; + while (Date.now() < deadline) { + const probe = await probeTcpPort(host, port, 1000); + if (probe.isOpen) { + ready = true; + break; + } + await new Promise(r => setTimeout(r, 500)); + } + + if (!ready) { + console.warn(`[Index] OpenCode serve 端口未就绪(${attachUrl}),跳过前台 attach 窗口`); + return; + } - console.log(`[Index] OpenCode 已自动启动,PID=${opencodeChildProcess.pid}`); + try { + spawn( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `Start-Process cmd -ArgumentList '/k opencode attach ${attachUrl}'`, + ], + { + detached: true, + stdio: 'ignore', + windowsHide: true, + } + ).unref(); + console.log(`[Index] OpenCode 前台窗口已拉起(${attachUrl})`); + } catch (err) { + console.warn('[Index] 拉起 OpenCode 前台窗口失败:', err); + } + })(); + } } catch (error) { console.warn('[Index] 启动 OpenCode 失败:', error); } } - // 注册进程退出时的清理逻辑,确保子进程不会变成孤儿进程 + // 注册进程退出时的清理逻辑(后台 opencode serve 由 process-manager 独立管理,不在此清理) const cleanupChildProcess = () => { - if (opencodeChildProcess && opencodeChildProcess.pid) { - try { - // 由于子进程是 detached,需要显式终止 - process.kill(opencodeChildProcess.pid, 'SIGTERM'); - console.log(`[Index] 已发送 SIGTERM 到 OpenCode 子进程 (PID=${opencodeChildProcess.pid})`); - } catch { - // 进程可能已经退出,忽略错误 - } - } + // 已迁移到 process-manager 管理,此处保留钩子供将来扩展 }; // 监听主进程退出事件 @@ -602,18 +666,24 @@ async function main() { } // 2. 先启动 Admin Server(确保管理面板可用,即使 OpenCode 未运行) - if (!process.env.BRIDGE_SPAWNED_BY_ADMIN) { - const adminPort = parseInt(process.env.ADMIN_PORT ?? '4098', 10); - const adminPassword = process.env.ADMIN_PASSWORD ?? ''; + // 若 TUI 配置 WEB_ADMIN_DISABLED=true 或运行时 env BRIDGE_DISABLE_ADMIN=1, + // 则跳过 admin server 启动 —— 平台适配器仍会正常工作(仅 web 不可用)。 + const { configStore: _cs } = await import('./store/config-store.js'); + const _adminDisabledByCfg = (_cs.get().WEB_ADMIN_DISABLED ?? '') === 'true'; + const _adminDisabledByEnv = process.env.BRIDGE_DISABLE_ADMIN === '1'; + const adminDisabled = _adminDisabledByCfg || _adminDisabledByEnv; + if (!process.env.BRIDGE_SPAWNED_BY_ADMIN && !adminDisabled) { + const adminPort = parseInt(process.env.ADMIN_PORT ?? _cs.get().ADMIN_PORT ?? '4098', 10); const adminServer = createAdminServer({ port: adminPort, - password: adminPassword, cronManager: undefined, // cronManager 在后面初始化 startedAt: new Date(), - version: pkg.version, + version: VERSION, }); adminServer.start(); console.log(`[Admin] 管理面板已启动: http://localhost:${adminPort}`); + } else if (adminDisabled) { + console.log('[Admin] Web 管理面板已通过配置禁用(接入平台仍正常运行)'); } // 3. 连接 OpenCode(失败不退出,允许用户在管理面板中诊断) @@ -1221,6 +1291,57 @@ async function main() { streamStateManager.setErrorNotice(sessionID, ''); }; + const FEISHU_RENDER_DEDUPE_WINDOW_MS = 15_000; + const feishuRecentRenderCache = new Map(); + const qqProgressState = new Map(); + + const pruneFeishuRecentRenderCache = (): void => { + const now = Date.now(); + for (const [key, value] of feishuRecentRenderCache.entries()) { + if (now - value.updatedAt > FEISHU_RENDER_DEDUPE_WINDOW_MS) { + feishuRecentRenderCache.delete(key); + } + } + }; + + const getQQProgressState = (bufferKey: string): { sentThinking: string; lastThinkingChunk: string; finalSent: boolean } => { + let state = qqProgressState.get(bufferKey); + if (!state) { + state = { + sentThinking: '', + lastThinkingChunk: '', + finalSent: false, + }; + qqProgressState.set(bufferKey, state); + } + return state; + }; + + const getIncrementalSuffix = (fullText: string, sentText: string): string => { + if (!fullText) { + return ''; + } + if (!sentText) { + return fullText; + } + if (fullText.startsWith(sentText)) { + return fullText.slice(sentText.length); + } + if (sentText.startsWith(fullText)) { + return ''; + } + return fullText; + }; + const formatProviderError = (raw: unknown): string => { if (!raw || typeof raw !== 'object') { return '模型执行失败'; @@ -1388,6 +1509,58 @@ async function main() { showThinking: false, }; + if (platform === 'qq') { + const sender = getSenderByPlatform(platform); + if (!sender) { + console.error('[outputBuffer] 无法获取 QQ sender'); + return; + } + + const onlyText = chatSessionStore.getSessionByConversation('qq', conversationId)?.qqOutputOnlyText === true; + const progress = getQQProgressState(buffer.key); + + const thinkingDelta = getIncrementalSuffix(current.thinking, progress.sentThinking); + const normalizedThinkingDelta = thinkingDelta.trim(); + + if (normalizedThinkingDelta && normalizedThinkingDelta !== progress.lastThinkingChunk) { + const safeThinkingDelta = normalizedThinkingDelta.replace(/```/g, '` ` `'); + const thinkingPayload = onlyText + ? `思考过程:\n${normalizedThinkingDelta}` + : `**思考过程**\n\`\`\`text\n${safeThinkingDelta}\n\`\`\``; + await sender.sendCard(conversationId, onlyText + ? { qqText: thinkingPayload, forcePlainText: true } + : { markdown: thinkingPayload, qqText: thinkingPayload }); + progress.sentThinking = current.thinking; + progress.lastThinkingChunk = normalizedThinkingDelta; + } else if (current.thinking.length > progress.sentThinking.length) { + progress.sentThinking = current.thinking; + } + + if (buffer.status !== 'running' && !progress.finalSent) { + const finalCardData: StreamCardData = { + ...cardData, + thinking: '', + segments: (cardData.segments ?? []).filter(segment => segment.type !== 'reasoning'), + }; + const finalPayload = buildPortableUpdatePayload(finalCardData, conversationId, 'qq'); + await sender.sendCard( + conversationId, + onlyText + ? { qqText: finalPayload.qqText, forcePlainText: true } + : { markdown: finalPayload.markdown, qqText: finalPayload.qqText } + ); + progress.finalSent = true; + } + + if (buffer.status !== 'running') { + qqProgressState.delete(buffer.key); + streamStateManager.clear(buffer.key); + clearPartSnapshotsForSession(buffer.sessionId); + outputBuffer.clear(buffer.key); + } + return; + } + if (platform !== 'feishu') { const sender = getSenderByPlatform(platform); if (!sender) { @@ -1395,22 +1568,27 @@ async function main() { return; } const payload = buildPortableUpdatePayload(cardData, conversationId, platform); + const qqOnlyText = platform === 'qq' + && chatSessionStore.getSessionByConversation('qq', conversationId)?.qqOutputOnlyText === true; const nextMessageIds: string[] = []; const existingMessageId = existingMessageIds[0]; + const outboundPayload = qqOnlyText + ? { qqText: payload.qqText, forcePlainText: true } + : payload; if (existingMessageId) { - const updated = await sender.updateCard(existingMessageId, payload); + const updated = await sender.updateCard(existingMessageId, outboundPayload); if (updated) { nextMessageIds.push(existingMessageId); } else { - const replacementMessageId = await sender.sendCard(conversationId, payload); + const replacementMessageId = await sender.sendCard(conversationId, outboundPayload); if (replacementMessageId) { void sender.deleteMessage(existingMessageId).catch(() => undefined); nextMessageIds.push(replacementMessageId); } } } else { - const newMessageId = await sender.sendCard(conversationId, payload); + const newMessageId = await sender.sendCard(conversationId, outboundPayload); if (newMessageId) { nextMessageIds.push(newMessageId); } @@ -1449,6 +1627,39 @@ async function main() { } ); + pruneFeishuRecentRenderCache(); + const renderSignature = JSON.stringify(cards); + const cachedRender = feishuRecentRenderCache.get(buffer.key); + + if ( + existingMessageIds.length === 0 && + cachedRender && + cachedRender.messageIds.length > 0 && + Date.now() - cachedRender.updatedAt <= FEISHU_RENDER_DEDUPE_WINDOW_MS + ) { + existingMessageIds = [...cachedRender.messageIds]; + outputBuffer.setMessageId(buffer.key, existingMessageIds[0]); + streamStateManager.setCardMessageIds(buffer.key, existingMessageIds); + } + + if ( + cachedRender && + cachedRender.status === status && + cachedRender.signature === renderSignature && + cachedRender.messageIds.length > 0 && + Date.now() - cachedRender.updatedAt <= FEISHU_RENDER_DEDUPE_WINDOW_MS + ) { + outputBuffer.setMessageId(buffer.key, cachedRender.messageIds[0]); + streamStateManager.setCardMessageIds(buffer.key, [...cachedRender.messageIds]); + + if (buffer.status !== 'running') { + streamStateManager.clear(buffer.key); + clearPartSnapshotsForSession(buffer.sessionId); + outputBuffer.clear(buffer.key); + } + return; + } + const nextMessageIds: string[] = []; const feishuAdapter = getCachedAdapter('feishu'); @@ -1467,14 +1678,8 @@ async function main() { nextMessageIds.push(existingMessageId); continue; } - - const replacementMessageId = await sender.sendCard(conversationId, card); - if (replacementMessageId) { - void sender.deleteMessage(existingMessageId).catch(() => undefined); - nextMessageIds.push(replacementMessageId); - } else { - nextMessageIds.push(existingMessageId); - } + console.warn(`[outputBuffer] 飞书卡片更新失败,保留原卡避免重复发卡: buffer=${buffer.key}, msgId=${existingMessageId}`); + nextMessageIds.push(existingMessageId); continue; } @@ -1495,6 +1700,12 @@ async function main() { if (nextMessageIds.length > 0) { outputBuffer.setMessageId(buffer.key, nextMessageIds[0]); streamStateManager.setCardMessageIds(buffer.key, nextMessageIds); + feishuRecentRenderCache.set(buffer.key, { + status, + signature: renderSignature, + messageIds: [...nextMessageIds], + updatedAt: Date.now(), + }); } else { streamStateManager.setCardMessageIds(buffer.key, []); } @@ -1522,15 +1733,17 @@ async function main() { const reliabilityLifecycle = bootstrapReliabilityLifecycle(); // 4. 监听飞书消息(通过路由器分发) - feishuClient.on('message', async (event) => { + const onFeishuMessage = async (event: FeishuMessageEvent) => { await reliabilityLifecycle.onInboundMessage(); await rootRouter.onMessage(event); - }); + }; + feishuClient.on('message', onFeishuMessage); - feishuClient.on('chatUnavailable', (chatId: string) => { + const onFeishuChatUnavailable = (chatId: string) => { console.warn(`[Index] 检测到不可用群聊,移除会话绑定: ${chatId}`); chatSessionStore.removeSession(chatId); - }); + }; + feishuClient.on('chatUnavailable', onFeishuChatUnavailable); // 5. 监听飞书卡片动作(通过路由器分发) feishuClient.setCardActionHandler(async (event) => { @@ -1836,7 +2049,7 @@ async function main() { await lifecycleHandler.cleanUpOnStart(); console.log('✅ 服务已就绪'); - + // 优雅退出处理 let shuttingDown = false; const gracefulShutdown = async (signal: string) => { @@ -1845,23 +2058,14 @@ async function main() { } shuttingDown = true; + // 内嵌模式下(Admin 进程内 Bridge 重启)不能让整个进程退出, + // 否则 Admin HTTP 服务一并被杀,导致"立即重启"实际只关闭不启动。 + const isEmbeddedStop = signal === 'EMBEDDED_STOP'; + console.log(`\n[${signal}] 正在关闭服务...`); - // 1. 优先终止 OpenCode 子进程(如果由 Bridge 启动) - if (opencodeChildProcess) { - try { - console.log('[Shutdown] 正在终止 OpenCode 子进程...'); - opencodeChildProcess.kill('SIGTERM'); - // 等待子进程退出 - await new Promise(resolve => setTimeout(resolve, 2000)); - if (!opencodeChildProcess.killed) { - opencodeChildProcess.kill('SIGKILL'); - console.log('[Shutdown] 已强制终止 OpenCode 子进程'); - } - } catch (e) { - console.error('[Shutdown] 终止 OpenCode 子进程失败:', e); - } - } + // 1. OpenCode serve 由 process-manager 独立管理,Bridge 关闭时不自动终止它 + // (如需完全关闭,可通过 Web 面板"终止服务"或 stop.mjs --with-opencode) // 2. 停止 reliability 调度和救援资源 try { @@ -1915,6 +2119,34 @@ async function main() { console.error('[System] 清理资源失败:', e); } + // 7. 清理本轮 main() 注册到 feishuClient 的 EventEmitter 监听,避免 embedded restart 叠加 + try { + feishuClient.off('message', onFeishuMessage); + feishuClient.off('chatUnavailable', onFeishuChatUnavailable); + } catch (e) { + console.error('[飞书] 清理事件监听失败:', e); + } + + // 8. 清理平台适配器缓存,避免重启后复用旧实例导致 callback 累积 + try { + clearCache(); + } catch (e) { + console.error('[PlatformLoader] 清理适配器缓存失败:', e); + } + + if (isEmbeddedStop) { + // 内嵌模式:不退出进程,只清理本次 main() 注册的信号监听器, + // 并重置 runningInstance,保证下一次 startBridge() 会真正重新启动。 + try { + process.off('SIGINT', sigintHandler); + process.off('SIGTERM', sigtermHandler); + process.off('SIGUSR2', sigusr2Handler); + } catch { /* ignore */ } + runningInstance = null; + console.log('✅ 服务已安全关闭(内嵌模式,保留 Admin 进程)'); + return; + } + // 延迟退出以确保所有清理完成 setTimeout(() => { console.log('✅ 服务已安全关闭'); @@ -1922,15 +2154,13 @@ async function main() { }, 500); }; - process.on('SIGINT', () => { - void gracefulShutdown('SIGINT'); - }); - process.on('SIGTERM', () => { - void gracefulShutdown('SIGTERM'); - }); - process.on('SIGUSR2', () => { - void gracefulShutdown('SIGUSR2'); - }); // nodemon 重启信号 + const sigintHandler = () => { void gracefulShutdown('SIGINT'); }; + const sigtermHandler = () => { void gracefulShutdown('SIGTERM'); }; + const sigusr2Handler = () => { void gracefulShutdown('SIGUSR2'); }; // nodemon 重启信号 + + process.on('SIGINT', sigintHandler); + process.on('SIGTERM', sigtermHandler); + process.on('SIGUSR2', sigusr2Handler); // 返回停止函数,供进程合并模式下使用 return { @@ -1957,7 +2187,11 @@ export async function stopBridge(): Promise { } } -if (process.env.VITEST !== 'true' && process.env.BRIDGE_EMBEDDED_MODE !== '1') { +if ( + process.env.VITEST !== 'true' && + process.env.BRIDGE_EMBEDDED_MODE !== '1' && + process.env.BRIDGE_CLI_MODE !== '1' +) { main().catch(error => { console.error('Fatal Error:', error); process.exit(1); diff --git a/src/opencode/client.ts b/src/opencode/client.ts index 36fa994..92b1f1f 100644 --- a/src/opencode/client.ts +++ b/src/opencode/client.ts @@ -1,8 +1,12 @@ import { createOpencodeClient, type OpencodeClient as SdkOpencodeClient } from '@opencode-ai/sdk'; import type { Session, Message, Part, Project } from '@opencode-ai/sdk'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import { opencodeConfig, modelConfig } from '../config.js'; import { EventEmitter } from 'events'; +const LOCAL_UPLOAD_DIR = path.resolve(process.cwd(), 'data', 'uploads'); + // 权限请求事件类型 export interface PermissionRequestEvent { sessionId: string; @@ -330,6 +334,53 @@ function appendAuthHint(message: string, statusCode?: number): string { return `${message};${buildAuthEnvHint()}`; } +function extractLocalUploadFilename(rawUrl: string): string | null { + const trimmed = rawUrl.trim(); + if (!trimmed) { + return null; + } + + let pathname = trimmed; + if (/^https?:\/\//i.test(trimmed)) { + try { + pathname = new URL(trimmed).pathname; + } catch { + return null; + } + } + + if (!pathname.startsWith('/uploads/')) { + return null; + } + + const filename = path.basename(pathname); + return filename && filename !== '.' && filename !== '..' ? filename : null; +} + +async function inlineLocalUploadParts( + parts: Array<{ type: 'text'; text: string } | { type: 'file'; mime: string; url: string; filename?: string }> +): Promise> { + return Promise.all(parts.map(async part => { + if (part.type !== 'file') { + return part; + } + + const filename = extractLocalUploadFilename(part.url); + if (!filename) { + return part; + } + + const filePath = path.join(LOCAL_UPLOAD_DIR, filename); + const buffer = await fs.readFile(filePath); + const mime = part.mime || 'application/octet-stream'; + + return { + ...part, + url: `data:${mime};base64,${buffer.toString('base64')}`, + }; + })); +} + class OpencodeClientWrapper extends EventEmitter { private client: SdkOpencodeClient | null = null; private eventAbortController: AbortController | null = null; @@ -338,6 +389,7 @@ class OpencodeClientWrapper extends EventEmitter { private eventListeningEnabled = false; private eventStreamActive = false; private directoryEventStreams: Map = new Map(); + private knownSessionDirectories: Set = new Set(); // 防止并发调用 ensureDirectoryEventStream 对同一目录建立多条 SSE 连接 private pendingDirectoryStreams: Map> = new Map(); @@ -740,6 +792,14 @@ class OpencodeClientWrapper extends EventEmitter { return normalized.length > 0 ? normalized : undefined; } + private rememberDirectory(directory?: string): string | undefined { + const normalized = this.normalizeDirectory(directory); + if (normalized) { + this.knownSessionDirectories.add(normalized); + } + return normalized; + } + private buildPermissionDirectoryCandidates(options?: PermissionResponseOptions): Array { const candidates: Array = []; const seen = new Set(); @@ -824,6 +884,7 @@ class OpencodeClientWrapper extends EventEmitter { ): Promise<{ info: Message; parts: Part[] }> { const client = this.getClient(); const model = this.resolveModelOption(options); + const resolvedParts = await inlineLocalUploadParts(parts); if (options?.directory) { void this.ensureDirectoryEventStream(options.directory); @@ -832,7 +893,7 @@ class OpencodeClientWrapper extends EventEmitter { const response = await client.session.prompt({ path: { id: sessionId }, body: { - parts, + parts: resolvedParts, // ...(messageId ? { messageID: messageId } : {}), // 已注释:避免传递飞书 MessageID 导致 Opencode 无法处理 ...(options?.agent ? { agent: options.agent } : {}), ...(model ? { model } : {}), @@ -897,6 +958,7 @@ class OpencodeClientWrapper extends EventEmitter { ): Promise { this.getClient(); const model = this.resolveModelOption(options); + const resolvedParts = await inlineLocalUploadParts(parts); if (options?.directory) { void this.ensureDirectoryEventStream(options.directory); @@ -907,7 +969,7 @@ class OpencodeClientWrapper extends EventEmitter { method: 'POST', headers: withOpencodeAuthorizationHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ - parts, + parts: resolvedParts, ...(options?.agent ? { agent: options.agent } : {}), ...(model ? { model } : {}), ...(options?.variant ? { variant: options.variant } : {}), @@ -1135,11 +1197,15 @@ class OpencodeClientWrapper extends EventEmitter { // 获取会话列表(可按目录过滤) async listSessions(options?: SessionQueryOptions): Promise { const client = this.getClient(); - const directory = this.normalizeDirectory(options?.directory); + const directory = this.rememberDirectory(options?.directory); const result = await client.session.list( directory ? { query: { directory } } : undefined ); - return Array.isArray(result.data) ? result.data : []; + const sessions = Array.isArray(result.data) ? result.data : []; + for (const session of sessions) { + this.rememberDirectory(session.directory); + } + return sessions; } // 跨工作区聚合会话列表 @@ -1147,6 +1213,7 @@ class OpencodeClientWrapper extends EventEmitter { const merged = new Map(); const upsertSessions = (sessions: Session[]): void => { for (const session of sessions) { + this.rememberDirectory(session.directory); const existing = merged.get(session.id); if (!existing) { merged.set(session.id, session); @@ -1172,15 +1239,24 @@ class OpencodeClientWrapper extends EventEmitter { const projects = await this.listProjects(); const seenDirectories = new Set(); for (const project of projects) { - const normalized = this.normalizeDirectory(project.worktree); + const normalized = this.rememberDirectory(project.worktree); if (!normalized || seenDirectories.has(normalized)) { continue; } seenDirectories.add(normalized); directories.push(normalized); } + + for (const knownDirectory of this.knownSessionDirectories) { + if (seenDirectories.has(knownDirectory)) { + continue; + } + seenDirectories.add(knownDirectory); + directories.push(knownDirectory); + } } catch (error) { console.warn('[OpenCode] 获取项目列表失败:', error); + directories = Array.from(this.knownSessionDirectories); } const sessionGroups = await Promise.all( @@ -1239,7 +1315,7 @@ class OpencodeClientWrapper extends EventEmitter { return null; } - const directory = this.normalizeDirectory(options?.directory); + const directory = this.rememberDirectory(options?.directory); const result = await client.session.get({ path: { id: normalizedSessionId }, ...(directory ? { query: { directory } } : {}), @@ -1258,6 +1334,10 @@ class OpencodeClientWrapper extends EventEmitter { throw new Error(appendAuthHint(message, statusCode)); } + if (result.data?.directory) { + this.rememberDirectory(result.data.directory); + } + return result.data || null; } @@ -1277,7 +1357,7 @@ class OpencodeClientWrapper extends EventEmitter { const directories: string[] = []; const seenDirectories = new Set(); for (const project of projects) { - const normalized = this.normalizeDirectory(project.worktree); + const normalized = this.rememberDirectory(project.worktree); if (!normalized || seenDirectories.has(normalized)) { continue; } @@ -1285,6 +1365,14 @@ class OpencodeClientWrapper extends EventEmitter { directories.push(normalized); } + for (const knownDirectory of this.knownSessionDirectories) { + if (seenDirectories.has(knownDirectory)) { + continue; + } + seenDirectories.add(knownDirectory); + directories.push(knownDirectory); + } + for (const directory of directories) { const found = await this.getSessionById(normalizedSessionId, { directory }); if (found) { @@ -1298,7 +1386,7 @@ class OpencodeClientWrapper extends EventEmitter { // 创建新会话 async createSession(title?: string, directory?: string): Promise { const client = this.getClient(); - const normalizedDir = this.normalizeDirectory(directory); + const normalizedDir = this.rememberDirectory(directory); if (normalizedDir) { void this.ensureDirectoryEventStream(normalizedDir); } @@ -1306,6 +1394,9 @@ class OpencodeClientWrapper extends EventEmitter { body: { title: title || '新对话' }, ...(normalizedDir ? { query: { directory: normalizedDir } } : {}), }); + if (result.data?.directory) { + this.rememberDirectory(result.data.directory); + } return result.data!; } @@ -1367,6 +1458,27 @@ class OpencodeClientWrapper extends EventEmitter { return result.data || []; } + async getSessionLastActivityTime(sessionId: string): Promise { + const messages = await this.getSessionMessages(sessionId); + let latest = 0; + + for (const item of messages) { + const info = item?.info as Record | undefined; + const time = info?.time; + if (!time || typeof time !== 'object' || Array.isArray(time)) { + continue; + } + + for (const value of Object.values(time as Record)) { + if (typeof value === 'number' && Number.isFinite(value) && value > latest) { + latest = value; + } + } + } + + return latest; + } + // 获取配置(含模型列表) async getProviders(): Promise<{ providers: Array<{ id: string; name: string; models: Array<{ id: string; name: string }> }>; @@ -1380,6 +1492,148 @@ class OpencodeClientWrapper extends EventEmitter { }; } + /** + * 获取 opencode 提供的 providers 完整数据(含 capabilities) + * + * 与 {@link getProviders} 的区别:后者为了兼容历史调用点做了强类型窄化, + * 丢掉了 `capabilities.input.image` 等关键字段。本方法返回 SDK 原始 shape, + * 供能力嗅探 / vision 模型枚举等场景使用。 + */ + async getProvidersFull(): Promise<{ + providers: Array>; + default: Record; + }> { + const client = this.getClient(); + const result = await client.config.providers(); + const data = (result.data || {}) as Record; + const providers = Array.isArray(data.providers) ? (data.providers as Array>) : []; + const defaultMap = (data.default && typeof data.default === 'object' && !Array.isArray(data.default) + ? data.default + : {}) as Record; + return { providers, default: defaultMap }; + } + + /** + * 查询单个 model 的 capabilities。 + * + * @returns `{ input, attachment, ... }` 结构;若 provider/model 未找到或数据缺失,返回 `null` + * —— 调用方应据此采用"乐观"或"悲观"策略。 + */ + async getModelCapabilities( + providerId: string, + modelId: string, + ): Promise<{ + input?: { text?: boolean; image?: boolean; audio?: boolean; video?: boolean; pdf?: boolean }; + output?: { text?: boolean; image?: boolean; audio?: boolean; video?: boolean; pdf?: boolean }; + attachment?: boolean; + } | null> { + if (!providerId?.trim() || !modelId?.trim()) return null; + + let full: Awaited>; + try { + full = await this.getProvidersFull(); + } catch (error) { + console.debug('[OpenCode] getModelCapabilities: 拉取 providers 失败', error instanceof Error ? error.message : error); + return null; + } + + const providerLower = providerId.trim().toLowerCase(); + const modelLower = modelId.trim().toLowerCase(); + + for (const provider of full.providers) { + if (!provider || typeof provider !== 'object') continue; + const pid = typeof provider.id === 'string' ? provider.id.trim().toLowerCase() : ''; + if (pid !== providerLower) continue; + + const modelsRaw = provider.models; + const modelList: Array> = Array.isArray(modelsRaw) + ? (modelsRaw as Array>) + : modelsRaw && typeof modelsRaw === 'object' + ? (Object.values(modelsRaw as Record) as Array>) + : []; + + for (const model of modelList) { + if (!model || typeof model !== 'object') continue; + const mid = typeof model.id === 'string' + ? model.id.trim().toLowerCase() + : typeof (model as Record).modelID === 'string' + ? ((model as Record).modelID as string).trim().toLowerCase() + : ''; + if (mid !== modelLower) continue; + + const caps = model.capabilities; + if (!caps || typeof caps !== 'object') return null; + return caps as { + input?: { text?: boolean; image?: boolean; audio?: boolean; video?: boolean; pdf?: boolean }; + output?: { text?: boolean; image?: boolean; audio?: boolean; video?: boolean; pdf?: boolean }; + attachment?: boolean; + }; + } + return null; // provider 匹配,但 model 未找到 + } + return null; // provider 未找到 + } + + /** + * 枚举所有支持 image 输入的 model。 + * + * 供 Web UI 的 VISION_OCR_MODEL 下拉选择器使用。 + */ + async listVisionModels(): Promise> { + let full: Awaited>; + try { + full = await this.getProvidersFull(); + } catch (error) { + console.warn('[OpenCode] listVisionModels: 拉取 providers 失败', error instanceof Error ? error.message : error); + return []; + } + + const result: Array<{ providerID: string; providerName: string; modelID: string; modelName: string }> = []; + + for (const provider of full.providers) { + if (!provider || typeof provider !== 'object') continue; + const providerID = typeof provider.id === 'string' ? provider.id.trim() : ''; + if (!providerID) continue; + const providerName = typeof provider.name === 'string' && provider.name.trim() + ? provider.name.trim() + : providerID; + + const modelsRaw = provider.models; + const modelList: Array> = Array.isArray(modelsRaw) + ? (modelsRaw as Array>) + : modelsRaw && typeof modelsRaw === 'object' + ? (Object.values(modelsRaw as Record) as Array>) + : []; + + for (const model of modelList) { + if (!model || typeof model !== 'object') continue; + const modelID = typeof model.id === 'string' + ? model.id.trim() + : typeof (model as Record).modelID === 'string' + ? ((model as Record).modelID as string).trim() + : ''; + if (!modelID) continue; + + const caps = (model.capabilities || {}) as Record; + const input = (caps.input || {}) as Record; + if (input.image !== true) continue; + + const modelName = typeof model.name === 'string' && model.name.trim() + ? model.name.trim() + : modelID; + + result.push({ providerID, providerName, modelID, modelName }); + } + } + + return result; + } + // 获取完整配置 async getConfig(): Promise { const client = this.getClient(); diff --git a/src/platform/adapters/dingtalk/dingtalk-adapter.ts b/src/platform/adapters/dingtalk/dingtalk-adapter.ts index cdf2c46..3d1c140 100644 --- a/src/platform/adapters/dingtalk/dingtalk-adapter.ts +++ b/src/platform/adapters/dingtalk/dingtalk-adapter.ts @@ -87,6 +87,8 @@ export class DingtalkAdapter implements PlatformAdapter { this.connections.clear(); this.abortControllers.clear(); this.isActive = false; + this.messageCallbacks.length = 0; + this.actionCallbacks.length = 0; console.log('[钉钉] 已停止'); } @@ -272,4 +274,4 @@ export class DingtalkAdapter implements PlatformAdapter { } // 单例导出 -export const dingtalkAdapter = new DingtalkAdapter(); \ No newline at end of file +export const dingtalkAdapter = new DingtalkAdapter(); diff --git a/src/platform/adapters/discord-adapter.ts b/src/platform/adapters/discord-adapter.ts index fc86649..5ba5a58 100644 --- a/src/platform/adapters/discord-adapter.ts +++ b/src/platform/adapters/discord-adapter.ts @@ -445,13 +445,16 @@ export class DiscordAdapter implements PlatformAdapter { } stop(): void { - if (!this.client) { - return; + if (this.client) { + this.client.destroy(); + this.client = null; } - this.client.destroy(); - this.client = null; this.isActive = false; this.messageConversationMap.clear(); + this.messageCallbacks.length = 0; + this.interactionCallbacks.length = 0; + this.actionCallbacks.length = 0; + this.messageRecalledCallbacks.length = 0; console.log('[Discord] 适配器已停止'); } diff --git a/src/platform/adapters/qq-adapter.ts b/src/platform/adapters/qq-adapter.ts index 78323b1..8c095f0 100644 --- a/src/platform/adapters/qq-adapter.ts +++ b/src/platform/adapters/qq-adapter.ts @@ -7,7 +7,7 @@ */ import WebSocket from 'ws'; -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; import type { PlatformAdapter, PlatformMessageEvent, @@ -83,27 +83,13 @@ type QQCardPayload = { content?: string; text?: string; markdown?: string; + forcePlainText?: boolean; }; // ────────────────────────────────────────────── // 工具函数 // ────────────────────────────────────────────── -function removeMarkdownFormatting(text: string): string { - if (!text) return ''; - return text - .replace(/\*\*(.*?)\*\*/g, '$1') - .replace(/(? { + const matches = value.match(/(^|\n)(```[^\n]*)/g); + if (!matches || matches.length === 0) return null; + return matches[matches.length - 1].replace(/^\n/, ''); + }; + + const fenceCount = (value: string): number => (value.match(/```/g) || []).length; + + const pushCurrent = (): void => { + if (!current.trim()) return; + let chunk = current; + if (fenceCount(chunk) % 2 === 1) { + openFence = getLastFenceLine(chunk) || '```'; + chunk = `${chunk}\n\`\`\``; + } else { + openFence = null; + } + chunks.push(chunk); + current = openFence ? `${openFence}\n` : ''; + }; + + for (const line of lines) { + const candidate = current + ? `${current}${current.endsWith('\n') ? '' : '\n'}${line}` + : line; + if (candidate.length <= safeLimit) { + current = candidate; + continue; + } + + if (current) { + pushCurrent(); + } + + if (line.length <= safeLimit) { + current = line; + continue; + } + + const pieces = splitText(line, safeLimit); + for (let i = 0; i < pieces.length - 1; i += 1) { + current = pieces[i]; + pushCurrent(); + } + current = pieces[pieces.length - 1] || ''; + } + + if (current.trim()) { + pushCurrent(); + } + + return chunks; +} + // ────────────────────────────────────────────── // QQ 官方 API 客户端 (WebSocket 方式) // ────────────────────────────────────────────── @@ -187,6 +237,49 @@ class QQOfficialClient { private readonly secret: string, ) {} + private resetAccessToken(): void { + this.accessToken = null; + this.accessTokenExpiresAt = 0; + this.accessTokenPromise = null; + } + + private isAccessTokenExpiredError(error: unknown): boolean { + if (!(error instanceof AxiosError)) { + return false; + } + + const responseData = error.response?.data as { + code?: number | string; + err_code?: number | string; + message?: string; + } | undefined; + + const errorCode = responseData?.err_code ?? responseData?.code; + const normalizedCode = typeof errorCode === 'string' ? Number(errorCode) : errorCode; + if (normalizedCode === 11244) { + return true; + } + + const message = responseData?.message?.toLowerCase(); + return typeof message === 'string' && message.includes('token not exist or expire'); + } + + private async postMessage( + endpoint: string, + requestData: Record, + accessToken: string, + ): Promise { + const response = await axios.post(endpoint, requestData, { + headers: { + 'Authorization': `QQBot ${accessToken}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, + }); + + return response.data?.id || response.data?.msg_id || null; + } + private async fetchAccessToken(): Promise { console.log('[QQ Official] 获取 Access Token...'); const response = await axios({ @@ -501,9 +594,11 @@ class QQOfficialClient { } async sendMessage(chatId: string, text: string, msgId?: string): Promise { + return this.sendMarkdownMessage(chatId, text, msgId); + } + + async sendPlainTextMessage(chatId: string, text: string, msgId?: string): Promise { try { - const content = removeMarkdownFormatting(text); - const accessToken = await this.getValidAccessToken(); const isGroup = chatId.startsWith('group_'); const targetId = chatId.replace(/^(group_|c2c_)/, ''); @@ -512,7 +607,7 @@ class QQOfficialClient { : `${QQ_API_BASE}/v2/users/${targetId}/messages`; const requestData: Record = { - content, + content: text, msg_type: 0, }; @@ -520,17 +615,60 @@ class QQOfficialClient { requestData.msg_id = msgId; } - const response = await axios.post(endpoint, requestData, { - headers: { - 'Authorization': `QQBot ${accessToken}`, - 'Content-Type': 'application/json', + try { + const accessToken = await this.getValidAccessToken(); + return await this.postMessage(endpoint, requestData, accessToken); + } catch (error) { + if (!this.isAccessTokenExpiredError(error)) { + throw error; + } + + console.warn('[QQ Official] 发送纯文本消息时 Access Token 已失效,刷新后重试一次'); + this.resetAccessToken(); + const refreshedAccessToken = await this.getValidAccessToken(); + return await this.postMessage(endpoint, requestData, refreshedAccessToken); + } + } catch (error) { + console.error('[QQ Official] 发送纯文本消息失败:', error); + return null; + } + } + + async sendMarkdownMessage(chatId: string, markdown: string, msgId?: string): Promise { + try { + const isGroup = chatId.startsWith('group_'); + const targetId = chatId.replace(/^(group_|c2c_)/, ''); + + const endpoint = isGroup + ? `${QQ_API_BASE}/v2/groups/${targetId}/messages` + : `${QQ_API_BASE}/v2/users/${targetId}/messages`; + + const requestData: Record = { + markdown: { + content: markdown, }, - timeout: 30000, - }); + msg_type: 2, + }; + + if (isGroup && msgId) { + requestData.msg_id = msgId; + } + + try { + const accessToken = await this.getValidAccessToken(); + return await this.postMessage(endpoint, requestData, accessToken); + } catch (error) { + if (!this.isAccessTokenExpiredError(error)) { + throw error; + } - return response.data?.id || response.data?.msg_id || null; + console.warn('[QQ Official] 发送 Markdown 消息时 Access Token 已失效,刷新后重试一次'); + this.resetAccessToken(); + const refreshedAccessToken = await this.getValidAccessToken(); + return await this.postMessage(endpoint, requestData, refreshedAccessToken); + } } catch (error) { - console.error('[QQ Official] 发送消息失败:', error); + console.error('[QQ Official] 发送 Markdown 消息失败:', error); return null; } } @@ -862,12 +1000,16 @@ class QQSender implements PlatformSender { ) {} async sendText(conversationId: string, text: string): Promise { - const chunks = splitText(text, QQ_MESSAGE_LIMIT); + const chunks = this.protocol === 'official' + ? splitMarkdownText(text, QQ_MESSAGE_LIMIT) + : splitText(text, QQ_MESSAGE_LIMIT); if (chunks.length === 0) return null; let firstMessageId: string | null = null; for (const chunk of chunks) { - const messageId = await this.adapter.sendRawMessage(conversationId, chunk); + const messageId = this.protocol === 'official' + ? await this.adapter.sendRawMarkdownMessage(conversationId, chunk) + : await this.adapter.sendRawMessage(conversationId, chunk); if (messageId && !firstMessageId) { firstMessageId = messageId; } @@ -880,6 +1022,32 @@ class QQSender implements PlatformSender { async sendCard(conversationId: string, card: object): Promise { const payload = card as QQCardPayload; + if (this.protocol === 'official' && payload.forcePlainText) { + const content = payload.qqText || payload.text || payload.content || payload.markdown || JSON.stringify(card); + const chunks = splitText(content, QQ_MESSAGE_LIMIT); + if (chunks.length === 0) return null; + + let firstMessageId: string | null = null; + for (const chunk of chunks) { + const messageId = await this.adapter.sendRawPlainTextMessage(conversationId, chunk); + if (messageId && !firstMessageId) { + firstMessageId = messageId; + } + if (messageId) { + this.adapter.rememberMessageConversation(messageId, conversationId); + } + } + return firstMessageId; + } + + if (this.protocol === 'official' && payload.markdown) { + const messageId = await this.adapter.sendRawMarkdownMessage(conversationId, payload.markdown); + if (messageId) { + this.adapter.rememberMessageConversation(messageId, conversationId); + } + return messageId; + } + const content = payload.qqText || payload.text || payload.markdown || payload.content || JSON.stringify(card); return this.sendText(conversationId, content); } @@ -1017,6 +1185,8 @@ export class QQAdapter implements PlatformAdapter { } this.isActive = false; this.messageConversationMap.clear(); + this.messageCallbacks.length = 0; + this.actionCallbacks.length = 0; console.log('[QQ] 适配器已停止'); } @@ -1053,7 +1223,7 @@ export class QQAdapter implements PlatformAdapter { async sendRawMessage(conversationId: string, text: string): Promise { if (qqConfig.protocol === 'official' && this.officialClient) { - return this.officialClient.sendMessage(conversationId, text); + return this.officialClient.sendMarkdownMessage(conversationId, text); } if (qqConfig.protocol === 'onebot' && this.onebotClient) { @@ -1077,6 +1247,22 @@ export class QQAdapter implements PlatformAdapter { return null; } + async sendRawMarkdownMessage(conversationId: string, markdown: string): Promise { + if (qqConfig.protocol === 'official' && this.officialClient) { + return this.officialClient.sendMarkdownMessage(conversationId, markdown); + } + + return this.sendRawMessage(conversationId, markdown); + } + + async sendRawPlainTextMessage(conversationId: string, text: string): Promise { + if (qqConfig.protocol === 'official' && this.officialClient) { + return this.officialClient.sendPlainTextMessage(conversationId, text); + } + + return this.sendRawMessage(conversationId, text); + } + async deleteMessage(messageId: string): Promise { if (qqConfig.protocol === 'onebot' && this.onebotClient) { try { @@ -1104,4 +1290,4 @@ export class QQAdapter implements PlatformAdapter { } } -export const qqAdapter = new QQAdapter(); \ No newline at end of file +export const qqAdapter = new QQAdapter(); diff --git a/src/platform/adapters/telegram-adapter.ts b/src/platform/adapters/telegram-adapter.ts index 3319b03..fca3074 100644 --- a/src/platform/adapters/telegram-adapter.ts +++ b/src/platform/adapters/telegram-adapter.ts @@ -6,16 +6,16 @@ * 支持附件下载:photo、document、video、audio */ -import type { Bot, InlineKeyboard, Context } from 'grammy'; +import type { File as TelegramFile } from '@grammyjs/types'; +import type { Bot, Context, InlineKeyboard } from 'grammy'; +import { telegramConfig } from '../../config.js'; import type { - PlatformAdapter, - PlatformSender, - PlatformMessageEvent, PlatformActionEvent, + PlatformAdapter, PlatformAttachment, + PlatformMessageEvent, + PlatformSender, } from '../types.js'; -import { telegramConfig } from '../../config.js'; -import type { File as TelegramFile } from '@grammyjs/types'; // 动态导入缓存:仅在启用时加载 grammy type GrammyModule = typeof import('grammy'); @@ -290,6 +290,8 @@ export class TelegramAdapter implements PlatformAdapter { this.bot.stop(); this.bot = null; this.isActive = false; + this.messageCallbacks.length = 0; + this.actionCallbacks.length = 0; this.messageConversationMap.clear(); console.log('[Telegram] 适配器已停止'); } @@ -568,4 +570,4 @@ export class TelegramAdapter implements PlatformAdapter { } // 单例导出 -export const telegramAdapter = new TelegramAdapter(); \ No newline at end of file +export const telegramAdapter = new TelegramAdapter(); diff --git a/src/platform/adapters/wecom-adapter.ts b/src/platform/adapters/wecom-adapter.ts index 34be4f0..25343ad 100644 --- a/src/platform/adapters/wecom-adapter.ts +++ b/src/platform/adapters/wecom-adapter.ts @@ -244,9 +244,11 @@ export class WeComAdapter implements PlatformAdapter { if (this.wsClient) { this.wsClient.disconnect(); this.wsClient = null; - this.isActive = false; - console.log('[企业微信] 适配器已停止'); } + this.isActive = false; + this.messageCallbacks.length = 0; + this.actionCallbacks.length = 0; + console.log('[企业微信] 适配器已停止'); } getSender(): PlatformSender { diff --git a/src/platform/adapters/weixin-adapter.ts b/src/platform/adapters/weixin-adapter.ts index 8b6ec52..a793224 100644 --- a/src/platform/adapters/weixin-adapter.ts +++ b/src/platform/adapters/weixin-adapter.ts @@ -183,6 +183,8 @@ export class WeixinAdapter implements PlatformAdapter { pausedAccounts.clear(); this.isActive = false; + this.messageCallbacks.length = 0; + this.actionCallbacks.length = 0; console.log('[Weixin] Stopped'); } @@ -514,4 +516,4 @@ export class WeixinAdapter implements PlatformAdapter { } // 单例导出 -export const weixinAdapter = new WeixinAdapter(); \ No newline at end of file +export const weixinAdapter = new WeixinAdapter(); diff --git a/src/platform/adapters/whatsapp-adapter.ts b/src/platform/adapters/whatsapp-adapter.ts index fac99af..c2ee182 100644 --- a/src/platform/adapters/whatsapp-adapter.ts +++ b/src/platform/adapters/whatsapp-adapter.ts @@ -660,6 +660,8 @@ export class WhatsAppAdapter implements PlatformAdapter { this.qrCodeDataUrl = null; this.connectionStatus = 'disconnected'; this.messageConversationMap.clear(); + this.messageCallbacks.length = 0; + this.actionCallbacks.length = 0; console.log('[WhatsApp] 适配器已停止'); } @@ -1017,4 +1019,4 @@ export class WhatsAppAdapter implements PlatformAdapter { } // 单例导出 -export const whatsappAdapter = new WhatsAppAdapter(); \ No newline at end of file +export const whatsappAdapter = new WhatsAppAdapter(); diff --git a/src/reliability/audit-log.ts b/src/reliability/audit-log.ts index e353b94..3f33399 100644 --- a/src/reliability/audit-log.ts +++ b/src/reliability/audit-log.ts @@ -157,64 +157,39 @@ function buildDateRotatedPath(basePath: string, date: Date = new Date()): string return path.join(dir, `${base}.${dateStr}${ext}`); } -/** - * 原子写入文件(先写临时文件,再 rename) - * @param filePath 目标文件路径 - * @param content 文件内容 - */ -async function atomicWriteFile(filePath: string, content: string): Promise { - const dir = path.dirname(filePath); - const tmpFile = `${filePath}.${process.pid}.tmp`; - - try { - // 确保目录存在 - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - // 写入临时文件 - await fs.promises.writeFile(tmpFile, content, 'utf-8'); - - // 原子重命名 - await fs.promises.rename(tmpFile, filePath); - } catch (error) { - // 清理临时文件(如果存在) - try { - await fs.promises.unlink(tmpFile); - } catch { - // 忽略清理错误 - } - throw error; - } -} +const appendQueueByFile = new Map>(); /** - * 追加写入 JSONL 日志(使用原子写入模式) + * 追加写入 JSONL 日志(单进程串行 append) * @param filePath 日志文件路径 * @param line JSON 字符串行 */ async function appendJsonLine(filePath: string, line: string): Promise { const dir = path.dirname(filePath); + const previous = appendQueueByFile.get(filePath) ?? Promise.resolve(); - try { - // 确保目录存在 - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } + const current = previous + .catch(() => undefined) + .then(async () => { + try { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } - // 读取现有内容(如果存在) - let existingContent = ''; - if (fs.existsSync(filePath)) { - existingContent = await fs.promises.readFile(filePath, 'utf-8'); - } + await fs.promises.appendFile(filePath, `${line}\n`, 'utf-8'); + } catch (error) { + throw new Error(`写入审计日志失败:${error instanceof Error ? error.message : String(error)}`); + } + }); - // 拼接新内容 - const newContent = existingContent ? `${existingContent}${line}\n` : `${line}\n`; + appendQueueByFile.set(filePath, current); - // 原子写入(先写 tmp 再 rename) - await atomicWriteFile(filePath, newContent); - } catch (error) { - throw new Error(`写入审计日志失败:${error instanceof Error ? error.message : String(error)}`); + try { + await current; + } finally { + if (appendQueueByFile.get(filePath) === current) { + appendQueueByFile.delete(filePath); + } } } diff --git a/src/reliability/opencode-restart.ts b/src/reliability/opencode-restart.ts index a8edb73..3475d31 100644 --- a/src/reliability/opencode-restart.ts +++ b/src/reliability/opencode-restart.ts @@ -1,5 +1,7 @@ import fs from 'node:fs/promises'; -import { spawn } from 'node:child_process'; +import fsSync from 'node:fs'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; import { opencodeConfig, reliabilityConfig } from '../config.js'; import { probeOpenCodeHealth } from './opencode-probe.js'; import { checkOpenCodeSingleInstance, type ProcessGuardResult } from './process-guard.js'; @@ -184,17 +186,37 @@ function isNoSuchProcessError(error: unknown): boolean { return code === 'ESRCH'; } -async function defaultStartProcess(): Promise { +async function defaultStartProcess(pidFilePath = './logs/opencode.pid'): Promise { await new Promise((resolve, reject) => { try { const isWindows = process.platform === 'win32'; - const child = spawn('opencode', [], { - detached: true, - stdio: 'ignore', - shell: isWindows, - windowsHide: isWindows, - }); - child.unref(); + + let pid: number | null = null; + + if (isWindows) { + // Windows: 走 PowerShell Start-Process -WindowStyle Hidden, + // 避免 Node 的 CREATE_NO_WINDOW 导致孙进程 opencode-windows-x64\bin\opencode.exe 弹黑窗。 + pid = startOpencodeWindowsHidden(); + } else { + const child = spawn('opencode', ['serve'], { + detached: true, + stdio: 'ignore', + }); + child.unref(); + pid = child.pid ?? null; + } + + // 写入 PID 文件,供 process-guard 和 kill-opencode 使用 + if (pid) { + try { + const dir = path.dirname(pidFilePath); + fsSync.mkdirSync(dir, { recursive: true }); + fsSync.writeFileSync(pidFilePath, String(pid), 'utf-8'); + } catch { + // PID 文件写入失败不影响启动 + } + } + setTimeout(() => resolve(), 500); } catch (error) { reject(error); @@ -202,6 +224,90 @@ async function defaultStartProcess(): Promise { }); } +/** + * Windows 专用:通过 PowerShell Start-Process -WindowStyle Hidden 启动 opencode serve。 + * + * 为什么不用 Node spawn({ windowsHide: true })? + * - windowsHide 对应 CREATE_NO_WINDOW:node 进程完全不分配 console。 + * - 但 opencode-ai 的 JS 入口会再 spawn 平台二进制 opencode-windows-x64\bin\opencode.exe; + * 父进程没 console,Windows 会给这个孙进程**重新分配一个可见的黑窗**。 + * - PS 的 -WindowStyle Hidden 对应 STARTF_USESHOWWINDOW + SW_HIDE:分配 console 但隐藏, + * 孙进程继承这个隐藏 console,不弹窗。 + * + * 返回真实 node.exe / opencode.exe 的 PID(通过 -PassThru 取得)。 + */ +function startOpencodeWindowsHidden(): number | null { + // 1. 定位 opencode JS 入口(优先)或可执行文件 + let filePath: string | null = null; + let argList: string[] = []; + + try { + const npmRoot = spawnSync('npm', ['root', '-g'], { + encoding: 'utf-8', + windowsHide: true, + shell: true, + timeout: 8000, + }); + if (!npmRoot.error && npmRoot.status === 0) { + const globalRoot = (npmRoot.stdout as string).trim(); + const candidates = [ + path.join(globalRoot, 'opencode-ai', 'bin', 'opencode'), + path.join(globalRoot, '@opencode-ai', 'opencode', 'bin', 'opencode'), + path.join(globalRoot, 'opencode', 'bin', 'opencode'), + ]; + for (const candidate of candidates) { + if (fsSync.existsSync(candidate)) { + filePath = process.execPath; + argList = [candidate, 'serve']; + break; + } + } + } + } catch { + // ignore + } + + // 2. 回退:让 PS 自己从 PATH 里找 opencode(通常是 opencode.cmd → node.exe script) + if (!filePath) { + filePath = 'opencode'; + argList = ['serve']; + } + + return invokeHiddenPowershell(filePath, argList); +} + +function invokeHiddenPowershell(filePath: string, argList: string[]): number | null { + const psEscape = (s: string): string => String(s).replace(/'/g, "''"); + const argListLiteral = argList.length === 0 + ? '@()' + : argList.map(a => `'${psEscape(a)}'`).join(','); + + const psCommand = [ + `$ErrorActionPreference='Stop'`, + `$p = Start-Process -WindowStyle Hidden -FilePath '${psEscape(filePath)}' -ArgumentList ${argListLiteral} -PassThru`, + `Write-Output $p.Id`, + ].join('; '); + + const result = spawnSync('powershell.exe', [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', 'Bypass', + '-WindowStyle', 'Hidden', + '-Command', psCommand, + ], { + encoding: 'utf-8', + windowsHide: true, + timeout: 15000, + }); + + if (result.error || result.status !== 0) { + return null; + } + const pidStr = String(result.stdout || '').trim().split(/\s+/).pop() ?? ''; + const pid = Number.parseInt(pidStr, 10); + return Number.isFinite(pid) && pid > 0 ? pid : null; +} + async function defaultSleep(ms: number): Promise { await new Promise(resolve => { setTimeout(resolve, ms); diff --git a/src/services/resources/agents/manager.ts b/src/services/resources/agents/manager.ts new file mode 100644 index 0000000..444de17 --- /dev/null +++ b/src/services/resources/agents/manager.ts @@ -0,0 +1,470 @@ +/** + * Agent 配置管理器 + * + * 职责: + * 1. 启动时扫描 project + user 两层 agents 目录,读取 .json + * 2. 提供 CRUD(list / get / create / update / delete / enable / disable) + * 3. 通过 chokidar 监听两层目录的变更,去抖 200ms 后增量重载并 emit resource:changed + * 4. 导出为 OpenCode 兼容格式(用于同步到 opencode 配置) + * + * 覆盖语义:项目级与用户级同名时,项目级 wins;两个版本都保留在 records 中。 + */ + +import path from 'node:path'; +import fs from 'node:fs/promises'; +import chokidar, { type FSWatcher } from 'chokidar'; + +import { + assertValidResourceName, + ensureResourceDir, + getResourceDir, + getResourceDirs, +} from '../paths.js'; +import { emitResourceChange } from '../events.js'; +import type { ResourceScope } from '../types.js'; +import { + DEBOUNCE_MS, + DEFAULT_ORDER, + FILE_STABILITY_THRESHOLD_MS, + FILE_POLL_INTERVAL_MS, +} from '../constants.js'; +import type { + AgentConfig, + AgentInput, + AgentSummary, + AgentRecord, +} from './types.js'; + +// Re-export types for CLI use +export type { AgentSummary }; + +/** 内存记录:name -> record */ +type AgentRecords = Map; + +/** 注册表状态 */ +interface AgentRegistryState { + /** project 层记录 */ + projectRecords: AgentRecords; + /** user 层记录 */ + userRecords: AgentRecords; + /** chokidar watcher */ + watcher?: FSWatcher; + /** 是否已释放 */ + disposed: boolean; +} + +/** + * 解析单个 Agent 配置文件 + */ +async function loadAgentFile( + filePath: string, + scope: 'project' | 'user' +): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const raw = JSON.parse(content) as unknown; + + // 基础校验 + if (typeof raw !== 'object' || raw === null) { + return { kind: 'error', error: '配置不是有效的 JSON 对象', scope }; + } + + const config = raw as AgentConfig; + + // 校验必填字段 + if (typeof config.name !== 'string' || !config.name) { + return { kind: 'error', error: 'name 字段缺失或不是字符串', scope }; + } + if (typeof config.enabled !== 'boolean') { + return { kind: 'error', error: 'enabled 字段必须是布尔值', scope }; + } + if (typeof config.order !== 'number') { + return { kind: 'error', error: 'order 字段必须是数字', scope }; + } + + // 校验 mode(如果存在) + if (config.mode && !['primary', 'subagent', 'all'].includes(config.mode)) { + return { kind: 'error', error: `mode 必须是 primary/subagent/all 之一`, scope }; + } + + // 校验 tools(如果存在) + if (config.tools && typeof config.tools !== 'object') { + return { kind: 'error', error: 'tools 字段必须是对象', scope }; + } + + // 校验 name 是否与文件名一致 + const expectedName = path.basename(filePath, '.json'); + if (config.name !== expectedName) { + return { + kind: 'error', + error: `配置 name "${config.name}" 与文件名 "${expectedName}" 不一致`, + scope, + }; + } + + return { kind: 'ok', config, scope }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { kind: 'error', error: `解析失败: ${msg}`, scope }; + } +} + +/** + * 扫描单个 scope 目录,返回 records + */ +async function scanAgentsInScope( + scope: 'project' | 'user' +): Promise { + const dir = getResourceDir('agents', scope); + const records: AgentRecords = new Map(); + + try { + const files = await fs.readdir(dir, { withFileTypes: true }); + for (const ent of files) { + if (!ent.isFile() || !ent.name.endsWith('.json')) { + continue; + } + + const filePath = path.join(dir, ent.name); + const record = await loadAgentFile(filePath, scope); + const name = path.basename(ent.name, '.json'); + records.set(name, record); + } + } catch (err) { + // 目录不存在或无权限,返回空 + } + + return records; +} + +/** + * Agent Registry 类 + */ +export class AgentRegistry { + private state: AgentRegistryState = { + projectRecords: new Map(), + userRecords: new Map(), + disposed: false, + }; + + private reloadTimeout?: ReturnType; + + /** + * 初始化:扫描两层目录 + 启动 watcher + */ + async init(): Promise { + if (this.state.disposed) { + throw new Error('AgentRegistry 已释放,不可重新初始化'); + } + + // 扫描两层目录 + await this.reload(); + + // 启动 watcher + const dirs = getResourceDirs('agents'); + const watchPaths = [dirs.project]; + try { + await fs.access(dirs.user); + watchPaths.push(dirs.user); + } catch { + // user 目录不存在,只监听 project + } + + this.state.watcher = chokidar + .watch(watchPaths, { + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: FILE_STABILITY_THRESHOLD_MS, pollInterval: FILE_POLL_INTERVAL_MS }, + }) + .on('all', (event, filePath) => { + this.scheduleReload(); + }); + + console.log('[Agents] Registry 已就绪,监听:', watchPaths.join(', ')); + } + + /** + * 释放 watcher + */ + async dispose(): Promise { + this.state.disposed = true; + if (this.reloadTimeout) { + clearTimeout(this.reloadTimeout); + } + await this.state.watcher?.close(); + console.log('[Agents] Registry 已释放'); + } + + /** + * 调度延迟重载(去抖) + */ + private scheduleReload(): void { + if (this.state.disposed) return; + if (this.reloadTimeout) clearTimeout(this.reloadTimeout); + this.reloadTimeout = setTimeout(() => { + this.reload().catch((err) => { + console.error('[Agents] 热载失败:', err); + }); + }, DEBOUNCE_MS); + } + + /** + * 全量重载 + */ + private async reload(): Promise { + // 扫描两层 + this.state.projectRecords = await scanAgentsInScope('project'); + this.state.userRecords = await scanAgentsInScope('user'); + + emitResourceChange('agents', 'reload'); + console.log('[Agents] 热载完成'); + } + + /** + * 列出所有 agent(合并两层,项目级 shadow 用户级) + * 返回所有条目(包括被遮蔽的用户层条目),便于 UI 展示完整状态 + */ + list(): AgentSummary[] { + const result: AgentSummary[] = []; + const allNames = new Set(); + + // 收集所有 name + for (const name of this.state.projectRecords.keys()) allNames.add(name); + for (const name of this.state.userRecords.keys()) allNames.add(name); + + // 找出项目级 name 集合,用于判定 user 是否被 shadow + const projectNames = new Set(this.state.projectRecords.keys()); + + for (const name of allNames) { + // 遍历两层 scope,分别生成摘要 + for (const scope of ['project', 'user'] as const) { + const record = scope === 'project' + ? this.state.projectRecords.get(name) + : this.state.userRecords.get(name); + + if (!record) continue; + + if (record.kind === 'ok') { + const cfg = record.config; + result.push({ + name: cfg.name, + scope: record.scope, + description: cfg.description, + mode: cfg.mode, + enabled: cfg.enabled, + order: cfg.order, + valid: true, + shadowed: record.scope === 'user' && projectNames.has(name), + }); + } else { + result.push({ + name, + scope: record.scope, + enabled: false, + order: DEFAULT_ORDER, + valid: false, + error: record.error, + shadowed: false, + }); + } + } + } + + // 按 order 排序 + return result.sort((a, b) => a.order - b.order); + } + + /** + * 获取单个 agent 完整配置(winning 或指定 scope) + */ + get( + name: string, + scope?: ResourceScope + ): AgentConfig | null { + if (scope === 'user') { + const record = this.state.userRecords.get(name); + if (record?.kind === 'ok') return record.config; + return null; + } + if (scope === 'project') { + const record = this.state.projectRecords.get(name); + if (record?.kind === 'ok') return record.config; + return null; + } + // 默认 winning + const projRecord = this.state.projectRecords.get(name); + const userRecord = this.state.userRecords.get(name); + const record = projRecord || userRecord; + if (record?.kind === 'ok') return record.config; + return null; + } + + /** + * 创建新 agent + */ + async create(name: string, input: AgentInput, scope: ResourceScope = 'project'): Promise { + assertValidResourceName(name); + + // 检查是否已存在 + const existing = this.get(name, scope); + if (existing) { + throw new Error(`Agent "${name}" 已存在(${scope} 层)`); + } + + // 计算 order + const currentMax = Math.max( + 0, + ...this.list().map((a) => a.order) + ); + const order = input.order ?? currentMax + 10; + + const config: AgentConfig = { + ...input, + name, + order, + }; + + // 写入文件 + const dir = getResourceDir('agents', scope); + await ensureResourceDir('agents', scope); + const filePath = path.join(dir, `${name}.json`); + await fs.writeFile(filePath, JSON.stringify(config, null, 2), 'utf-8'); + + // 触发重载 + await this.reload(); + + emitResourceChange('agents', 'add', { name, scope }); + + console.log(`[Agents] 创建 agent "${name}" 于 ${scope} 层`); + return config; + } + + /** + * 更新 agent + */ + async update( + name: string, + input: Partial, + scope: ResourceScope = 'project' + ): Promise { + const existing = this.get(name, scope); + if (!existing) { + throw new Error(`Agent "${name}" 不存在(${scope} 层)`); + } + + const config: AgentConfig = { + ...existing, + ...input, + name, // 确保 name 不变 + }; + + // 写入文件 + const dir = getResourceDir('agents', scope); + const filePath = path.join(dir, `${name}.json`); + await fs.writeFile(filePath, JSON.stringify(config, null, 2), 'utf-8'); + + // 触发重载 + await this.reload(); + + emitResourceChange('agents', 'update', { name, scope }); + + console.log(`[Agents] 更新 agent "${name}" 于 ${scope} 层`); + return config; + } + + /** + * 删除 agent + */ + async delete(name: string, scope: ResourceScope = 'project'): Promise { + const existing = this.get(name, scope); + if (!existing) { + throw new Error(`Agent "${name}" 不存在(${scope} 层)`); + } + + const dir = getResourceDir('agents', scope); + const filePath = path.join(dir, `${name}.json`); + await fs.unlink(filePath); + + // 触发重载 + await this.reload(); + + emitResourceChange('agents', 'remove', { name, scope }); + + console.log(`[Agents] 删除 agent "${name}" 于 ${scope} 层`); + } + + /** + * 启用/禁用 agent + */ + async toggle(name: string, enabled: boolean, scope?: ResourceScope): Promise { + // 如果未指定 scope,从 winning 推断 + let targetScope: ResourceScope = scope ?? 'project'; + if (!scope) { + const projRecord = this.state.projectRecords.get(name); + const userRecord = this.state.userRecords.get(name); + if (projRecord) targetScope = 'project'; + else if (userRecord) targetScope = 'user'; + else throw new Error(`Agent "${name}" 不存在`); + } + + const config = await this.update(name, { enabled }, targetScope); + return config; + } + + /** + * 导出为 OpenCode 兼容格式(用于同步到 opencode 配置) + * 返回 Record + */ + exportForOpenCode(): Record; + }> { + const result: Record; + }> = {}; + + // 只导出 winning 且 enabled 的 agent + for (const [name, projRecord] of this.state.projectRecords) { + if (projRecord.kind === 'ok' && projRecord.config.enabled) { + result[name] = { + description: projRecord.config.description, + mode: projRecord.config.mode, + prompt: projRecord.config.prompt, + tools: projRecord.config.tools, + }; + } + } + + // 导出 user 层独有的 agent(未被 project 遮蔽) + for (const [name, userRecord] of this.state.userRecords) { + if (this.state.projectRecords.has(name)) continue; // 被 project 遮蔽,跳过 + if (userRecord.kind === 'ok' && userRecord.config.enabled) { + result[name] = { + description: userRecord.config.description, + mode: userRecord.config.mode, + prompt: userRecord.config.prompt, + tools: userRecord.config.tools, + }; + } + } + + return result; + } +} + +// 单例 +let globalAgentRegistry: AgentRegistry | null = null; + +/** + * 获取全局 Agent registry 单例 + */ +export function getAgentRegistry(): AgentRegistry { + if (!globalAgentRegistry) { + globalAgentRegistry = new AgentRegistry(); + } + return globalAgentRegistry; +} diff --git a/src/services/resources/agents/migration.ts b/src/services/resources/agents/migration.ts new file mode 100644 index 0000000..ee4c291 --- /dev/null +++ b/src/services/resources/agents/migration.ts @@ -0,0 +1,198 @@ +/** + * Agent Migration Script + * + * 职责: + * 1. 从 OpenCode 配置中读取现有 agent 定义 + * 2. 检查 data/agents/ 目录是否为空 + * 3. 如果为空,将 OpenCode agent 配置导入为 JSON 文件 + * 4. 记录迁移结果日志 + * + * 设计原则: + * - 仅在首次启动时执行一次(data/agents/ 为空时) + * - 不删除 OpenCode 配置(只读) + * - 保留现有 agent 数据 + * - 清晰的日志输出 + */ + +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { opencodeClient, type OpencodeAgentConfig } from '../../../opencode/client.js'; +import { getResourceDir, ensureResourceDir } from '../paths.js'; +import type { AgentConfig } from './types.js'; + +interface MigrationResult { + success: boolean; + migratedCount: number; + skippedCount: number; + errors: string[]; +} + +/** + * 将 OpenCode agent 配置转换为新的 AgentConfig 格式 + */ +function convertOpencodeAgentToAgentConfig( + name: string, + opencodeConfig: OpencodeAgentConfig, + order: number +): AgentConfig { + return { + name, + description: opencodeConfig.description, + mode: opencodeConfig.mode, + prompt: opencodeConfig.prompt, + tools: opencodeConfig.tools, + enabled: true, // 默认启用 + order, + }; +} + +/** + * 检查 agents 目录是否为空(或不存在) + */ +async function isAgentsDirectoryEmpty(): Promise { + const agentsDir = getResourceDir('agents', 'project'); + + try { + const files = await fs.readdir(agentsDir, { withFileTypes: true }); + // 检查是否有 .json 文件 + for (const ent of files) { + if (ent.isFile() && ent.name.endsWith('.json')) { + return false; // 有文件,非空 + } + } + return true; // 无 JSON 文件,视为空 + } catch { + // 目录不存在,视为空 + return true; + } +} + +/** + * 从 OpenCode 配置中读取 agent 定义 + */ +async function fetchAgentsFromOpenCode(): Promise> { + try { + const config = await opencodeClient.getConfig(); + const agentMap = config.agent || {}; + + const result = new Map(); + for (const [name, agentConfig] of Object.entries(agentMap)) { + if (agentConfig && typeof agentConfig === 'object') { + result.set(name, agentConfig as OpencodeAgentConfig); + } + } + + return result; + } catch (error) { + console.error('[Migration] 从 OpenCode 读取 agent 配置失败:', error); + return new Map(); + } +} + +/** + * 过滤内置 agent(不需要迁移的) + */ +function shouldMigrateAgent(name: string): boolean { + // 跳过内置 agent + const internalAgents = new Set([ + 'build', + 'default', + 'plan', + 'general', + 'explore', + 'compaction', + 'title', + 'summary', + ]); + + if (internalAgents.has(name)) { + return false; + } + + return true; +} + +/** + * 执行迁移 + */ +export async function migrateAgentsFromOpenCode(): Promise { + const result: MigrationResult = { + success: true, + migratedCount: 0, + skippedCount: 0, + errors: [], + }; + + try { + // 1. 检查是否需要迁移 + const isEmpty = await isAgentsDirectoryEmpty(); + if (!isEmpty) { + console.log('[Migration] data/agents/ 目录非空,跳过迁移'); + result.skippedCount = 0; + return result; + } + + console.log('[Migration] 开始从 OpenCode 迁移 agent 配置...'); + + // 2. 从 OpenCode 读取配置 + const opencodeAgents = await fetchAgentsFromOpenCode(); + if (opencodeAgents.size === 0) { + console.log('[Migration] OpenCode 中无 agent 配置,跳过迁移'); + return result; + } + + console.log(`[Migration] 从 OpenCode 读取到 ${opencodeAgents.size} 个 agent 配置`); + + // 3. 确保 agents 目录存在 + await ensureResourceDir('agents', 'project'); + const agentsDir = getResourceDir('agents', 'project'); + + // 4. 迁移符合条件的 agent + let order = 100; + for (const [name, opencodeConfig] of opencodeAgents.entries()) { + if (!shouldMigrateAgent(name)) { + console.log(`[Migration] 跳过内置 agent: ${name}`); + result.skippedCount++; + continue; + } + + try { + const agentConfig = convertOpencodeAgentToAgentConfig(name, opencodeConfig, order); + const filePath = path.join(agentsDir, `${name}.json`); + const content = JSON.stringify(agentConfig, null, 2); + await fs.writeFile(filePath, content, 'utf-8'); + + console.log(`[Migration] ✅ 已迁移 agent: ${name}`); + result.migratedCount++; + order += 10; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + const errorMsg = `迁移 agent "${name}" 失败: ${msg}`; + console.error(`[Migration] ❌ ${errorMsg}`); + result.errors.push(errorMsg); + result.success = false; + } + } + + // 5. 打印总结 + console.log( + `[Migration] 迁移完成: 成功 ${result.migratedCount} 个, 跳过 ${result.skippedCount} 个, 失败 ${result.errors.length} 个` + ); + + if (result.errors.length > 0) { + console.error('[Migration] 迁移错误详情:'); + for (const error of result.errors) { + console.error(` - ${error}`); + } + } + + return result; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + const errorMsg = `迁移过程出错: ${msg}`; + console.error(`[Migration] ❌ ${errorMsg}`); + result.errors.push(errorMsg); + result.success = false; + return result; + } +} diff --git a/src/services/resources/agents/slash.ts b/src/services/resources/agents/slash.ts new file mode 100644 index 0000000..5489af6 --- /dev/null +++ b/src/services/resources/agents/slash.ts @@ -0,0 +1,69 @@ +/** + * Agent 配置映射为 Slash 命令 + * + * 职责: + * 1. 读取 enabled agents + * 2. 将每个 agent 映射成 `/agent:` 格式的 slash 命令 + * 3. 提供 `listSlashCommands()` 方法供 chat-meta.ts 调用 + * + * 设计说明: + * - Agent 命令格式:`/agent:` + * - 只返回 enabled 的 agents + * - 与 opencode 的 agent 概念对齐 + */ + +import { getAgentRegistry } from './manager.js'; +import type { AgentSummary } from './types.js'; + +/** Agent Slash 命令项 */ +export interface AgentSlashCommand { + /** 命令名称(格式:/agent:) */ + name: string; + /** Agent 名称 */ + agent: string; + /** 描述(可选) */ + description?: string; +} + +/** + * 列出所有 Agent slash 命令 + * + * @returns Agent slash 命令列表 + */ +export function listSlashCommands(): AgentSlashCommand[] { + const registry = getAgentRegistry(); + const agents = registry.list(); + + // 只返回 enabled 的 agents + const enabledAgents = agents.filter(a => a.enabled && a.valid); + + return enabledAgents.map(agent => ({ + name: `/agent:${agent.name}`, + agent: agent.name, + description: agent.description, + })); +} + +/** + * 为 chat-meta.ts 提供的命令格式化函数 + * + * 将 Agent slash 命令转换为 OpencodeCommandInfo 格式 + * + * @param commands - Agent slash 命令列表 + * @returns OpencodeCommandInfo 格式的命令列表 + */ +export function toCommandItems(commands: AgentSlashCommand[]): Array<{ + name: string; + description?: string; + source: 'agent'; + template: string; + hints: string[]; +}> { + return commands.map(cmd => ({ + name: cmd.name, + description: cmd.description, + source: 'agent' as const, + template: cmd.name, + hints: [], + })); +} diff --git a/src/services/resources/agents/types.ts b/src/services/resources/agents/types.ts new file mode 100644 index 0000000..09a5549 --- /dev/null +++ b/src/services/resources/agents/types.ts @@ -0,0 +1,93 @@ +/** + * Agent 配置类型定义 + * + * 存储格式: + * data/agents/.json 单个 agent 配置 + * + * 与 OpenCode OpencodeAgentConfig 对齐,支持独立文件存储便于热载与版本控制。 + */ + +/** Agent 模式 */ +export type AgentMode = 'primary' | 'subagent' | 'all'; + +/** 基础配置(所有字段可选,与 OpenCode 兼容) */ +export interface AgentConfig { + /** 唯一标识符(必须与文件名 .json 一致) */ + name: string; + /** 人类可读描述 */ + description?: string; + /** Agent 模式:primary=主模型独立思考,subagent=子代理受限工具,all=两者混合 */ + mode?: AgentMode; + /** 系统提示词(可选,覆盖默认行为) */ + prompt?: string; + /** 工具权限映射(工具名 -> 是否启用) */ + tools?: Record; + /** 是否启用(本地管理字段,OpenCode 无此字段) */ + enabled: boolean; + /** 显示顺序(数值越小越靠前,仅用于 UI 排序) */ + order: number; + /** 模型配置(可选,指定使用的模型) */ + model?: { + provider?: string; + model?: string; + }; +} + +/** 公开的 agent 摘要(list 用) */ +export interface AgentSummary { + name: string; + scope: 'project' | 'user'; + description?: string; + mode?: AgentMode; + enabled: boolean; + order: number; + /** 配置是否有效(JSON 格式、必填字段) */ + valid: boolean; + /** 解析错误信息(valid=false 时有值) */ + error?: string; + /** 被同名项目级 agent 遮蔽时为 true(仅 user 层条目可能为 true) */ + shadowed: boolean; +} + +/** 加载结果(内部使用) */ +export type AgentRecord = + | { kind: 'ok'; config: AgentConfig; scope: 'project' | 'user' } + | { kind: 'error'; error: string; scope: 'project' | 'user' }; + +/** 创建/更新时的输入(不需要 name,从路径推导;order 可选) */ +export type AgentInput = Omit & { + order?: number; +}; + +/** 热载变更事件类型 */ +export type AgentChangeEvent = + | { type: 'add'; name: string; scope: 'project' | 'user' } + | { type: 'update'; name: string; scope: 'project' | 'user' } + | { type: 'delete'; name: string; scope: 'project' | 'user' } + | { type: 'reload'; reason: 'dir-scan' }; + +/** 工具权限默认值(默认启用的工具列表) */ +export const DEFAULT_ENABLED_TOOLS = [ + 'read', + 'write', + 'edit', + 'bash', + 'list', + 'glob', + 'grep', +] as const; + +/** 所有支持的工具名称 */ +export const SUPPORTED_TOOLS = [ + 'bash', + 'read', + 'write', + 'edit', + 'list', + 'glob', + 'grep', + 'webfetch', + 'task', + 'todowrite', + 'todoread', +] as const; diff --git a/src/services/resources/constants.ts b/src/services/resources/constants.ts new file mode 100644 index 0000000..263f6db --- /dev/null +++ b/src/services/resources/constants.ts @@ -0,0 +1,29 @@ +/** + * Resource Management 常量定义 + * + * 集中管理各 manager 共享的常量,避免硬编码和魔法值。 + */ + +/** 文件监控去抖时间(毫秒)- 文件变更后延迟多久触发重载 */ +export const DEBOUNCE_MS = 200; + +/** chokidar awaitWriteFinish 稳定性阈值(毫秒) */ +export const FILE_STABILITY_THRESHOLD_MS = 200; + +/** chokidar awaitWriteFinish 轮询间隔(毫秒) */ +export const FILE_POLL_INTERVAL_MS = 100; + +/** 默认 order 值(新建资源时使用) */ +export const DEFAULT_ORDER = 1000; + +/** Provider 模型缓存刷新间隔(毫秒) - 30分钟 */ +export const PROVIDER_REFRESH_INTERVAL_MS = 30 * 60 * 1000; + +/** SSE keepalive 间隔(毫秒) - 30秒 */ +export const SSE_KEEPALIVE_INTERVAL_MS = 30000; + +/** 资源名称最大长度 */ +export const MAX_RESOURCE_NAME_LENGTH = 64; + +/** 资源名称允许的字符正则 */ +export const RESOURCE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; diff --git a/src/services/resources/events.ts b/src/services/resources/events.ts new file mode 100644 index 0000000..9aa2300 --- /dev/null +++ b/src/services/resources/events.ts @@ -0,0 +1,50 @@ +/** + * 资源变更事件总线 + * + * 所有 manager(skill/mcp/agent/provider)在增删改或热载完成后调用 emitResourceChange()。 + * 订阅方: + * 1. /api/resources/events 的 SSE 端点(推送给 Web 前端,触发 slash cache 失效与列表刷新) + * 2. src/admin/routes/chat-meta.ts 的 listCommands 缓存层 + * 3. opencode 配置桥(MCP 变更时重启 opencode 端 mcp client)—— 后续 step 接入 + * + * 设计原则: + * - 单进程内的轻量 EventEmitter;不持久化、不跨进程。跨进程同步靠 SSE。 + * - 事件聚合:高频写入(如批量编辑)用 200ms 去抖合并成一次 reload,由各 manager 自行控制。 + */ + +import { EventEmitter } from 'node:events'; +import type { ResourceChangeEvent, ResourceKind, ResourceScope } from './types.js'; + +const emitter = new EventEmitter(); +emitter.setMaxListeners(64); + +const EVENT = 'resource:changed'; + +/** 订阅资源变更事件。返回一个取消函数。 */ +export function onResourceChange( + listener: (event: ResourceChangeEvent) => void, +): () => void { + emitter.on(EVENT, listener); + return () => emitter.off(EVENT, listener); +} + +/** 发布一次资源变更事件。 */ +export function emitResourceChange( + kind: ResourceKind, + action: ResourceChangeEvent['action'], + options: { name?: string | null; scope?: ResourceScope } = {}, +): void { + const event: ResourceChangeEvent = { + kind, + action, + name: options.name ?? null, + scope: options.scope, + at: Date.now(), + }; + emitter.emit(EVENT, event); +} + +/** 测试与拆卸:移除全部监听器。 */ +export function clearResourceListeners(): void { + emitter.removeAllListeners(EVENT); +} diff --git a/src/services/resources/index.ts b/src/services/resources/index.ts new file mode 100644 index 0000000..d1b8bb5 --- /dev/null +++ b/src/services/resources/index.ts @@ -0,0 +1,84 @@ +/** + * 资源系统启动入口 + * + * 由 admin-server 在启动时调用 initResourceSystem(),完成: + * 1. 确保 ./data/{skills,mcp,agents,providers} 目录存在 + * 2. 初始化各 manager 的内存缓存(懒加载,首次请求时再扫盘) + * 3. 启动 chokidar 文件监听(在后续 step 中由各 manager 自行启动) + * 4. 执行 agent 迁移(如果 data/agents/ 为空) + * + * 当前 step 1 仅完成目录骨架与公共导出。Skill/MCP/Agent/Provider manager + * 在后续 step 中按序接入 init()/dispose()。 + */ + +import { ensureAllProjectDirs, getProjectDataRoot, getUserDataRoot } from './paths.js'; +import { skillRegistry } from './skills/registry.js'; +import { getMCPRegistry } from './mcp/manager.js'; +import { getAgentRegistry } from './agents/manager.js'; +import { getProviderRegistry } from './providers/manager.js'; +import { migrateAgentsFromOpenCode } from './agents/migration.js'; + +export * from './types.js'; +export * from './paths.js'; +export * from './events.js'; +export { skillRegistry } from './skills/registry.js'; +export { getMCPRegistry } from './mcp/manager.js'; +export { getAgentRegistry } from './agents/manager.js'; +export { getProviderRegistry } from './providers/manager.js'; +export type { SkillSummary, SkillSlashCommand } from './skills/registry.js'; +export type { ParsedSkill, SkillFrontmatter } from './skills/loader.js'; +export type { MCPServerConfig, MCPServerSummary, MCPInput } from './mcp/types.js'; +export type { AgentConfig, AgentSummary, AgentInput } from './agents/types.js'; +export type { ProviderConfig, ProviderSummary, ModelInfo } from './providers/types.js'; + +let initialized = false; + +/** 启动资源系统。多次调用安全(幂等)。 */ +export async function initResourceSystem(): Promise { + if (initialized) return; + initialized = true; + + ensureAllProjectDirs(); + + // 仅打印一次启动横幅,便于排查 + // 用 console 而非 logger 是因为本模块在多种入口(admin/cli/test)下都会被加载 + // eslint-disable-next-line no-console + console.log( + `[Resources] 资源系统已就绪 project=${getProjectDataRoot()} user=${getUserDataRoot()}`, + ); + + await skillRegistry.init(); + await getMCPRegistry().init(); + await getAgentRegistry().init(); + + // Agent 迁移:在 agent registry 初始化后执行 + // 仅在 data/agents/ 为空时执行一次 + // eslint-disable-next-line no-console + console.log('[Resources] 检查 agent 迁移...'); + const migrationResult = await migrateAgentsFromOpenCode(); + if (migrationResult.migratedCount > 0) { + // eslint-disable-next-line no-console + console.log( + `[Resources] Agent 迁移完成: 成功 ${migrationResult.migratedCount} 个, 跳过 ${migrationResult.skippedCount} 个` + ); + } + + // Provider manager 在后台初始化,不阻塞启动 + getProviderRegistry().init().catch((err) => { + console.error('[Resources] Provider manager 初始化失败:', err); + }); +} + +/** 关闭资源系统(关闭 chokidar 监听等)。多次调用安全。 */ +export async function disposeResourceSystem(): Promise { + if (!initialized) return; + initialized = false; + await skillRegistry.dispose(); + await getMCPRegistry().dispose(); + await getAgentRegistry().dispose(); + await getProviderRegistry().dispose(); +} + +export function isResourceSystemInitialized(): boolean { + return initialized; +} diff --git a/src/services/resources/mcp/manager.ts b/src/services/resources/mcp/manager.ts new file mode 100644 index 0000000..2e094a9 --- /dev/null +++ b/src/services/resources/mcp/manager.ts @@ -0,0 +1,625 @@ +/** + * MCP Server 配置管理器 + * + * 职责: + * 1. 启动时扫描 project + user 两层 mcp 目录,读取 .json 和 _index.json + * 2. 提供 CRUD(list / get / create / update / delete / enable / disable) + * 3. 通过 chokidar 监听两层目录的变更,去抖 200ms 后增量重载并 emit resource:changed + * 4. 维护 _index.json(启用列表 + 顺序),每次变更自动同步 + * + * 覆盖语义:项目级与用户级同名时,项目级 wins;两个版本都保留在 records 中。 + */ + +import path from 'node:path'; +import fs from 'node:fs/promises'; +import chokidar, { type FSWatcher } from 'chokidar'; + +import { + assertValidResourceName, + ensureResourceDir, + getResourceDir, + getResourceDirs, +} from '../paths.js'; +import { emitResourceChange } from '../events.js'; +import type { ResourceScope } from '../types.js'; +import { + DEBOUNCE_MS, + DEFAULT_ORDER, + FILE_STABILITY_THRESHOLD_MS, + FILE_POLL_INTERVAL_MS, +} from '../constants.js'; +import type { + LegacyMCPIndexContent, + MCPIndexContent, + MCPIndexEntry, + MCPInput, + MCPServerConfig, + MCPServerConfigBase, + MCPServerSummary, + MCPServerRecord, + MCPChangeEvent, +} from './types.js'; + +// Re-export types for CLI use +export type { MCPServerSummary }; + +const INDEX_FILENAME = '_index.json'; + +/** 内存记录:name -> record */ +type MCPServerRecords = Map; + +/** 注册表状态 */ +interface MCPRegistryState { + /** project 层记录 */ + projectRecords: MCPServerRecords; + /** user 层记录 */ + userRecords: MCPServerRecords; + /** 索引内容 */ + index: MCPIndexContent; + /** chokidar watcher */ + watcher?: FSWatcher; + /** 是否已释放 */ + disposed: boolean; +} + +/** 默认索引内容 */ +const DEFAULT_INDEX: MCPIndexContent = { enabled: [], disabled: [] }; + +/** + * 检测索引格式是否为新版 + */ +function isNewIndexFormat(parsed: unknown): parsed is MCPIndexContent { + if (typeof parsed !== 'object' || parsed === null) return false; + const index = parsed as Record; + const enabled = index.enabled; + if (!Array.isArray(enabled)) return false; + // 检查第一个元素是否为对象(新格式)或字符串(旧格式) + if (enabled.length === 0) return false; // 空数组无法判断,按旧格式处理 + const first = enabled[0]; + return typeof first === 'object' && first !== null && 'name' in first; +} + +/** + * 将旧版索引转换为新版格式 + */ +function migrateLegacyIndex(legacy: LegacyMCPIndexContent): MCPIndexContent { + const now = new Date().toISOString(); + return { + enabled: legacy.enabled.map((name, order) => ({ name, order: order * 10, updatedAt: now })), + disabled: legacy.disabled?.map((name, order) => ({ name, order: order * 10, updatedAt: now })), + }; +} + +/** + * 从新版索引中提取名称列表(用于兼容现有逻辑) + */ +function extractNames(entries: MCPIndexEntry[]): string[] { + return entries.map((e) => e.name); +} + +/** + * 解析单个 MCP server 配置文件 + */ +async function loadServerFile( + filePath: string, + scope: 'project' | 'user' +): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const raw = JSON.parse(content) as unknown; + + // 基础校验 + if (typeof raw !== 'object' || raw === null) { + return { kind: 'error', error: '配置不是有效的 JSON 对象', scope }; + } + + const config = raw as MCPServerConfig; + + // 校验必填字段 + if (typeof config.name !== 'string' || !config.name) { + return { kind: 'error', error: 'name 字段缺失或不是字符串', scope }; + } + if (typeof config.enabled !== 'boolean') { + return { kind: 'error', error: 'enabled 字段必须是布尔值', scope }; + } + if (typeof config.order !== 'number') { + return { kind: 'error', error: 'order 字段必须是数字', scope }; + } + + // 校验 transport + if (!['stdio', 'sse', 'http'].includes(config.transport)) { + return { kind: 'error', error: `transport 必须是 stdio/sse/http 之一`, scope }; + } + + // transport 特定校验 + if (config.transport === 'stdio') { + if (typeof config.command !== 'string' || !config.command) { + return { kind: 'error', error: 'stdio 传输需要 command 字段', scope }; + } + } else { + if (typeof config.url !== 'string' || !config.url) { + return { kind: 'error', error: `${config.transport} 传输需要 url 字段`, scope }; + } + } + + // 校验 name 是否与文件名一致 + const expectedName = path.basename(filePath, '.json'); + if (config.name !== expectedName) { + return { + kind: 'error', + error: `配置 name "${config.name}" 与文件名 "${expectedName}" 不一致`, + scope, + }; + } + + return { kind: 'ok', config, scope }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { kind: 'error', error: `解析失败: ${msg}`, scope }; + } +} + +/** + * 加载索引文件 _index.json + */ +async function loadIndex(dir: string): Promise { + const indexPath = path.join(dir, INDEX_FILENAME); + try { + const content = await fs.readFile(indexPath, 'utf-8'); + const parsed = JSON.parse(content) as unknown; + if ( + typeof parsed === 'object' && + parsed !== null && + Array.isArray((parsed as Record).enabled) + ) { + // 检测是否为新版格式 + if (isNewIndexFormat(parsed)) { + return parsed as MCPIndexContent; + } + // 旧版格式,迁移 + console.log('[MCP] 检测到旧版索引格式,自动迁移'); + return migrateLegacyIndex(parsed as LegacyMCPIndexContent); + } + return DEFAULT_INDEX; + } catch { + // 文件不存在或解析失败,返回默认 + return DEFAULT_INDEX; + } +} + +/** + * 保存索引文件 + */ +async function saveIndex(dir: string, index: MCPIndexContent): Promise { + const indexPath = path.join(dir, INDEX_FILENAME); + await fs.writeFile(indexPath, JSON.stringify(index, null, 2), 'utf-8'); +} + +/** + * 扫描单个 scope 目录,返回 records + */ +async function scanMCPServersInScope( + scope: 'project' | 'user' +): Promise { + const dir = getResourceDir('mcp', scope); + const records: MCPServerRecords = new Map(); + + try { + const files = await fs.readdir(dir, { withFileTypes: true }); + for (const ent of files) { + if (!ent.isFile() || !ent.name.endsWith('.json') || ent.name === INDEX_FILENAME) { + continue; + } + + const filePath = path.join(dir, ent.name); + const record = await loadServerFile(filePath, scope); + const name = path.basename(ent.name, '.json'); + records.set(name, record); + } + } catch (err) { + // 目录不存在或无权限,返回空 + } + + return records; +} + +/** + * MCP Server 注册表类 + */ +export class MCPRegistry { + private state: MCPRegistryState = { + projectRecords: new Map(), + userRecords: new Map(), + index: DEFAULT_INDEX, + disposed: false, + }; + + private reloadTimeout?: ReturnType; + + /** + * 初始化:扫描两层目录 + 启动 watcher + */ + async init(): Promise { + if (this.state.disposed) { + throw new Error('MCPRegistry 已释放,不可重新初始化'); + } + + // 扫描两层目录 + await this.reload(); + + // 启动 watcher + const dirs = getResourceDirs('mcp'); + const watchPaths = [dirs.project]; + try { + await fs.access(dirs.user); + watchPaths.push(dirs.user); + } catch { + // user 目录不存在,只监听 project + } + + this.state.watcher = chokidar + .watch(watchPaths, { + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: FILE_STABILITY_THRESHOLD_MS, pollInterval: FILE_POLL_INTERVAL_MS }, + }) + .on('all', (event, filePath) => { + this.scheduleReload(); + }); + + console.log('[MCP] Registry 已就绪,监听:', watchPaths.join(', ')); + } + + /** + * 释放 watcher + */ + async dispose(): Promise { + this.state.disposed = true; + if (this.reloadTimeout) { + clearTimeout(this.reloadTimeout); + } + await this.state.watcher?.close(); + console.log('[MCP] Registry 已释放'); + } + + /** + * 调度延迟重载(去抖) + */ + private scheduleReload(): void { + if (this.state.disposed) return; + if (this.reloadTimeout) clearTimeout(this.reloadTimeout); + this.reloadTimeout = setTimeout(() => { + this.reload().catch((err) => { + console.error('[MCP] 热载失败:', err); + }); + }, DEBOUNCE_MS); + } + + /** + * 全量重载 + */ + private async reload(): Promise { + // 扫描两层 + this.state.projectRecords = await scanMCPServersInScope('project'); + this.state.userRecords = await scanMCPServersInScope('user'); + + // 加载索引(project 层优先,user 层作为回退) + const dirs = getResourceDirs('mcp'); + let index = await loadIndex(dirs.project); + + // 如果 project 层索引是空的,尝试从 user 层加载 + if (index.enabled.length === 0 && !index.disabled) { + const userIndex = await loadIndex(dirs.user); + if (userIndex.enabled.length > 0) { + index = userIndex; + } + } + + this.state.index = index; + emitResourceChange('mcp', 'reload'); + console.log('[MCP] 热载完成'); + } + + /** + * 列出所有 server(合并两层,项目级 shadow 用户级) + * 返回所有条目(包括被遮蔽的用户层条目),便于 UI 展示完整状态 + */ + list(): MCPServerSummary[] { + const result: MCPServerSummary[] = []; + const allNames = new Set(); + + // 收集所有 name + for (const name of this.state.projectRecords.keys()) allNames.add(name); + for (const name of this.state.userRecords.keys()) allNames.add(name); + + // 找出项目级 name 集合,用于判定 user 是否被 shadow + const projectNames = new Set(this.state.projectRecords.keys()); + + for (const name of allNames) { + // 遍历两层 scope,分别生成摘要(这样被遮蔽的 user 条目也会出现在列表中) + for (const scope of ['project', 'user'] as const) { + const record = scope === 'project' + ? this.state.projectRecords.get(name) + : this.state.userRecords.get(name); + + if (!record) continue; + + if (record.kind === 'ok') { + const cfg = record.config; + result.push({ + name: cfg.name, + scope: record.scope, + transport: cfg.transport, + description: cfg.description, + enabled: cfg.enabled, + order: cfg.order, + valid: true, + shadowed: record.scope === 'user' && projectNames.has(name), + }); + } else { + result.push({ + name, + scope: record.scope, + transport: 'stdio', // 占位 + enabled: false, + order: DEFAULT_ORDER, + valid: false, + error: record.error, + shadowed: false, + }); + } + } + } + + // 按 order 排序 + return result.sort((a, b) => a.order - b.order); + } + + /** + * 获取单个 server 完整配置(winning 或指定 scope) + */ + get( + name: string, + scope?: ResourceScope + ): MCPServerConfig | null { + if (scope === 'user') { + const record = this.state.userRecords.get(name); + if (record?.kind === 'ok') return record.config; + return null; + } + if (scope === 'project') { + const record = this.state.projectRecords.get(name); + if (record?.kind === 'ok') return record.config; + return null; + } + // 默认 winning + const projRecord = this.state.projectRecords.get(name); + const userRecord = this.state.userRecords.get(name); + const record = projRecord || userRecord; + if (record?.kind === 'ok') return record.config; + return null; + } + + /** + * 创建新 server + */ + async create(name: string, input: MCPInput, scope: ResourceScope = 'project'): Promise { + assertValidResourceName(name); + + // 检查是否已存在 + const existing = this.get(name, scope); + if (existing) { + throw new Error(`MCP server "${name}" 已存在(${scope} 层)`); + } + + // 计算 order + const currentMax = Math.max( + 0, + ...this.list().map((s) => s.order) + ); + const order = input.order ?? currentMax + 10; + + const config: MCPServerConfig = { + ...input, + name, + order, + } as MCPServerConfig; + + // 写入文件 + const dir = getResourceDir('mcp', scope); + await ensureResourceDir('mcp', scope); + const filePath = path.join(dir, `${name}.json`); + await fs.writeFile(filePath, JSON.stringify(config, null, 2), 'utf-8'); + + // 更新索引 + const now = new Date().toISOString(); + const newIndex = { ...this.state.index }; + const entry: MCPIndexEntry = { name, order, updatedAt: now }; + if (config.enabled) { + newIndex.enabled.push(entry); + } else { + (newIndex.disabled ??= []).push(entry); + } + this.state.index = newIndex; + await this.syncIndex(scope, newIndex); + + // 触发重载 + await this.reload(); + + emitResourceChange('mcp', 'add', { name, scope }); + + console.log(`[MCP] 创建 server "${name}" 于 ${scope} 层`); + return config; + } + + /** + * 更新 server + */ + async update( + name: string, + input: Partial, + scope: ResourceScope = 'project' + ): Promise { + const existing = this.get(name, scope); + if (!existing) { + throw new Error(`MCP server "${name}" 不存在(${scope} 层)`); + } + + const config: MCPServerConfig = { + ...existing, + ...input, + name, // 确保 name 不变 + } as MCPServerConfig; + + // 写入文件 + const dir = getResourceDir('mcp', scope); + const filePath = path.join(dir, `${name}.json`); + await fs.writeFile(filePath, JSON.stringify(config, null, 2), 'utf-8'); + + // 如果 enabled 状态变化,更新索引 + if (input.enabled !== undefined && input.enabled !== existing.enabled) { + const newIndex = { ...this.state.index }; + this.updateIndexForServerInIndex(newIndex, name, config.enabled); + this.state.index = newIndex; + await this.syncIndex(scope, newIndex); + } + + // 触发重载 + await this.reload(); + + emitResourceChange('mcp', 'update', { name, scope }); + + console.log(`[MCP] 更新 server "${name}" 于 ${scope} 层`); + return config; + } + + /** + * 删除 server + */ + async delete(name: string, scope: ResourceScope = 'project'): Promise { + const existing = this.get(name, scope); + if (!existing) { + throw new Error(`MCP server "${name}" 不存在(${scope} 层)`); + } + + const dir = getResourceDir('mcp', scope); + const filePath = path.join(dir, `${name}.json`); + await fs.unlink(filePath); + + // 从索引中移除 + const newIndex = { ...this.state.index }; + this.removeFromIndexInIndex(newIndex, name); + this.state.index = newIndex; + await this.syncIndex(scope, newIndex); + + // 触发重载 + await this.reload(); + + emitResourceChange('mcp', 'remove', { name, scope }); + + console.log(`[MCP] 删除 server "${name}" 于 ${scope} 层`); + } + + /** + * 启用/禁用 server + */ + async toggle(name: string, enabled: boolean, scope?: ResourceScope): Promise { + // 如果未指定 scope,从 winning 推断 + let targetScope: ResourceScope = scope ?? 'project'; + if (!scope) { + const projRecord = this.state.projectRecords.get(name); + const userRecord = this.state.userRecords.get(name); + if (projRecord) targetScope = 'project'; + else if (userRecord) targetScope = 'user'; + else throw new Error(`MCP server "${name}" 不存在`); + } + + const config = await this.update(name, { enabled }, targetScope); + return config; + } + + /** + * 更新索引中的单个 server 状态 + */ + private updateIndexForServerInIndex(index: MCPIndexContent, name: string, enabled: boolean): void { + const now = new Date().toISOString(); + // 从两列表中移除 + index.enabled = index.enabled.filter((e) => e.name !== name); + index.disabled = (index.disabled ?? []).filter((e) => e.name !== name); + + // 查找现有条目以保留 order + const existingEntry = [...this.state.index.enabled, ...(this.state.index.disabled ?? [])] + .find((e) => e.name === name); + const order = existingEntry?.order ?? Math.max(...index.enabled.map((e) => e.order), 0) + 10; + + // 加入对应列表 + const entry: MCPIndexEntry = { name, order, updatedAt: now }; + if (enabled) { + index.enabled.push(entry); + } else { + index.disabled!.push(entry); + } + } + + /** + * 从索引中完全移除 + */ + private removeFromIndexInIndex(index: MCPIndexContent, name: string): void { + index.enabled = index.enabled.filter((e) => e.name !== name); + index.disabled = (index.disabled ?? []).filter((e) => e.name !== name); + } + + /** + * 同步索引到磁盘(写入指定 scope) + */ + private async syncIndex(scope: ResourceScope, index: MCPIndexContent): Promise { + const dir = getResourceDir('mcp', scope); + await saveIndex(dir, index); + } + + /** + * 导出为 opencode 兼容的配置格式(用于启动 MCP server) + * 遍历所有 enabled 的 server,生成 opencode MCP 配置格式 + */ + exportForOpenCode(): Record { + const result: Record = {}; + const servers = this.list(); + + for (const server of servers) { + if (!server.enabled || server.shadowed || !server.valid) continue; + + const config = this.get(server.name); + if (!config) continue; + + const serverConfig: Record = { + transport: config.transport, + }; + + if (config.transport === 'stdio') { + serverConfig.command = config.command; + if (config.args) serverConfig.args = config.args; + if (config.cwd) serverConfig.cwd = config.cwd; + if (config.env) serverConfig.env = config.env; + } else if (config.transport === 'sse' || config.transport === 'http') { + serverConfig.url = config.url; + if (config.headers) serverConfig.headers = config.headers; + } + + result[server.name] = serverConfig; + } + + return result; + } +} + +// 单例 +let globalMCPRegistry: MCPRegistry | null = null; + +/** + * 获取全局 MCP registry 单例 + */ +export function getMCPRegistry(): MCPRegistry { + if (!globalMCPRegistry) { + globalMCPRegistry = new MCPRegistry(); + } + return globalMCPRegistry; +} diff --git a/src/services/resources/mcp/slash.ts b/src/services/resources/mcp/slash.ts new file mode 100644 index 0000000..42b24df --- /dev/null +++ b/src/services/resources/mcp/slash.ts @@ -0,0 +1,121 @@ +/** + * MCP Server Prompts 协议映射为 Slash 命令 + * + * 职责: + * 1. 读取 enabled MCP servers 的 prompts 协议(通过 opencode client) + * 2. 将每个 prompt 映射成 `/mcp::` 格式的 slash 命令 + * 3. 提供 `listSlashCommands()` 函数供 chat-meta.ts 调用 + * + * 设计说明: + * - MCP prompts 是 MCP 协议的一部分,需要通过 opencode client 获取 + * - 由于 prompts 内容可能较多,这里只返回命令列表,不包含 prompt 详细内容 + * - 命令格式:`/mcp::` + */ + +import type { OpencodeCommandInfo } from '../../../opencode/client.js'; +import { opencodeClient } from '../../../opencode/client.js'; + +/** MCP Slash 命令项 */ +export interface MCPSlashCommand { + /** 命令名称(格式:/mcp::) */ + name: string; + /** MCP 服务器名称 */ + server: string; + /** Prompt 名称 */ + prompt: string; + /** 描述(可选) */ + description?: string; + /** 参数提示(可选) */ + args?: string[]; +} + +/** + * 从 opencode client 获取 MCP prompts 并映射为 slash 命令 + * + * @param opencodeCommands - 从 opencode client 获取的命令列表(包含 mcp 类型的命令) + * @returns MCP slash 命令列表 + */ +export function mapMCPCommandsToSlash( + opencodeCommands: OpencodeCommandInfo[] +): MCPSlashCommand[] { + const result: MCPSlashCommand[] = []; + + for (const cmd of opencodeCommands) { + // 检查是否为 MCP 命令(source === 'mcp') + if (cmd.source !== 'mcp') { + continue; + } + + // opencode 返回的 MCP 命令格式可能是 `/mcp::` + // 我们需要解析出 server 和 prompt 名称 + const match = cmd.name.match(/^\/?mcp:([^:]+):(.+)$/); + if (!match) { + continue; + } + + const [, server, prompt] = match; + + result.push({ + name: cmd.name.startsWith('/') ? cmd.name : `/${cmd.name}`, + server, + prompt, + description: cmd.description, + args: cmd.hints, + }); + } + + return result; +} + +/** + * 列出所有 MCP slash 命令 + * + * 实现步骤: + * 1. 调用 opencodeClient.getCommands() 获取所有命令(包括 MCP prompts) + * 2. 过滤出 source === 'mcp' 的命令 + * 3. 将这些命令映射为 MCPSlashCommand 格式 + * + * 注意: + * - 此函数需要在 opencode client 可用后调用 + * - opencode client 会自动从启用的 MCP servers 获取 prompts + * - 返回的命令格式为 /mcp:: + * + * @returns MCP slash 命令列表 + */ +export async function listSlashCommands(): Promise { + try { + // 从 opencode client 获取所有命令 + const commands = await opencodeClient.getCommands(); + + // 过滤并映射 MCP 命令 + return mapMCPCommandsToSlash(commands); + } catch (error) { + console.error('[MCP Slash] 获取命令列表失败:', error); + // 失败时返回空数组 + return []; + } +} + +/** + * 为 chat-meta.ts 提供的命令格式化函数 + * + * 将 MCP slash 命令转换为 OpencodeCommandInfo 格式 + * + * @param commands - MCP slash 命令列表 + * @returns OpencodeCommandInfo 格式的命令列表 + */ +export function toCommandItems(commands: MCPSlashCommand[]): Array<{ + name: string; + description?: string; + source: 'mcp'; + template: string; + hints: string[]; +}> { + return commands.map(cmd => ({ + name: cmd.name, + description: cmd.description, + source: 'mcp' as const, + template: cmd.name, + hints: cmd.args || [], + })); +} diff --git a/src/services/resources/mcp/types.ts b/src/services/resources/mcp/types.ts new file mode 100644 index 0000000..4159591 --- /dev/null +++ b/src/services/resources/mcp/types.ts @@ -0,0 +1,117 @@ +/** + * MCP Server 配置类型定义 + * + * 存储格式: + * data/mcp/.json 单个 server 配置 + * data/mcp/_index.json 启用列表 + 顺序 + */ + +/** MCP 传输协议类型 */ +export type MCPTransport = 'stdio' | 'sse' | 'http'; + +/** 基础配置(所有 transport 共有) */ +export interface MCPServerConfigBase { + /** 唯一标识符(必须与文件名 .json 一致) */ + name: string; + /** 人类可读描述 */ + description?: string; + /** 是否启用 */ + enabled: boolean; + /** 显示顺序(数值越小越靠前,仅用于 UI 排序) */ + order: number; + /** 传输协议 */ + transport: MCPTransport; +} + +/** stdio 传输配置 */ +export interface MCPStdioConfig extends MCPServerConfigBase { + transport: 'stdio'; + /** 启动命令(如 'npx', 'node', '/path/to/server') */ + command: string; + /** 命令参数数组 */ + args?: string[]; + /** 工作目录(可选,默认当前目录) */ + cwd?: string; + /** 环境变量(可选,key-value 对) */ + env?: Record; +} + +/** sse 传输配置 */ +export interface MCPSSEConfig extends MCPServerConfigBase { + transport: 'sse'; + /** SSE 端点 URL */ + url: string; + /** 可选的请求头(如 Authorization) */ + headers?: Record; +} + +/** http 传输配置 */ +export interface MCPHTTPConfig extends MCPServerConfigBase { + transport: 'http'; + /** HTTP 端点 URL */ + url: string; + /** 可选的请求头 */ + headers?: Record; +} + +/** 联合类型:任一 transport 的完整配置 */ +export type MCPServerConfig = MCPStdioConfig | MCPSSEConfig | MCPHTTPConfig; + +/** 索引中单个 server 的元数据 */ +export interface MCPIndexEntry { + /** server 名称 */ + name: string; + /** 显示顺序(数值越小越靠前) */ + order: number; + /** 最后更新时间(ISO 8601 字符串) */ + updatedAt: string; +} + +/** 旧版索引格式(向后兼容) */ +export interface LegacyMCPIndexContent { + /** 启用的 server 名称列表(按显示顺序) */ + enabled: string[]; + /** 禁用的 server 名称列表(按显示顺序,可选) */ + disabled?: string[]; +} + +/** _index.json 索引文件内容(新格式) */ +export interface MCPIndexContent { + /** 启用的 server 列表(含顺序和更新时间) */ + enabled: MCPIndexEntry[]; + /** 禁用的 server 列表(含顺序和更新时间,可选) */ + disabled?: MCPIndexEntry[]; +} + +/** 公开的 server 摘要(list 用) */ +export interface MCPServerSummary { + name: string; + scope: 'project' | 'user'; + transport: MCPTransport; + description?: string; + enabled: boolean; + order: number; + /** 配置是否有效(JSON 格式、必填字段) */ + valid: boolean; + /** 解析错误信息(valid=false 时有值) */ + error?: string; + /** 被同名项目级 server 遮蔽时为 true(仅 user 层条目可能为 true) */ + shadowed: boolean; +} + +/** 加载结果(内部使用) */ +export type MCPServerRecord = + | { kind: 'ok'; config: MCPServerConfig; scope: 'project' | 'user' } + | { kind: 'error'; error: string; scope: 'project' | 'user' }; + +/** 创建/更新时的输入(不需要 name,从路径推导;order 可选) */ +export type MCPInput = Omit & { + order?: number; +}; + +/** 热载变更事件类型 */ +export type MCPChangeEvent = + | { type: 'add'; name: string; scope: 'project' | 'user' } + | { type: 'update'; name: string; scope: 'project' | 'user' } + | { type: 'delete'; name: string; scope: 'project' | 'user' } + | { type: 'reload'; reason: 'index-changed' | 'dir-scan' }; diff --git a/src/services/resources/paths.ts b/src/services/resources/paths.ts new file mode 100644 index 0000000..fe62819 --- /dev/null +++ b/src/services/resources/paths.ts @@ -0,0 +1,101 @@ +/** + * 资源路径解析器 + * + * 提供两层路径: + * - 项目级: /data// (高优先级,启动时检测) + * - 用户级: ~/.opencode-bridge//(低优先级,跨项目共享) + * + * 列表 / 读取时按 “项目级覆盖用户级”合并;写入时默认落在项目级,前端可显式选择 scope。 + * + * Electron 打包后 process.cwd() 不可靠,因此项目根目录通过环境变量 OPENCODE_BRIDGE_DATA_ROOT + * 显式指定优先;否则回退到 process.cwd(),并保证目录存在。 + */ + +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs'; +import type { ResourceKind, ResourceScope } from './types.js'; + +/** 资源 kind 与目录名的映射。 */ +const KIND_DIR: Record = { + skill: 'skills', + mcp: 'mcp', + agents: 'agents', + provider: 'providers', +}; + +/** 解析项目级数据根目录(绝对路径)。 */ +export function getProjectDataRoot(): string { + const fromEnv = process.env.OPENCODE_BRIDGE_DATA_ROOT?.trim(); + if (fromEnv) return path.resolve(fromEnv); + return path.resolve(process.cwd(), 'data'); +} + +/** 解析用户级数据根目录(绝对路径)。 */ +export function getUserDataRoot(): string { + const fromEnv = process.env.OPENCODE_BRIDGE_USER_DATA_ROOT?.trim(); + if (fromEnv) return path.resolve(fromEnv); + return path.resolve(os.homedir(), '.opencode-bridge'); +} + +/** 给定 scope 的资源目录绝对路径(含 kind 子目录)。 */ +export function getResourceDir(kind: ResourceKind, scope: ResourceScope): string { + const root = scope === 'project' ? getProjectDataRoot() : getUserDataRoot(); + return path.join(root, KIND_DIR[kind]); +} + +/** 同时返回 project + user 两个目录(用于扫描合并)。 */ +export function getResourceDirs(kind: ResourceKind): Record { + return { + project: getResourceDir(kind, 'project'), + user: getResourceDir(kind, 'user'), + }; +} + +/** 确保资源目录存在;返回是否新建。 */ +export function ensureResourceDir(kind: ResourceKind, scope: ResourceScope): boolean { + const dir = getResourceDir(kind, scope); + if (fs.existsSync(dir)) return false; + fs.mkdirSync(dir, { recursive: true }); + return true; +} + +/** 启动时一次性确保所有 kind 的项目级目录存在(用户级目录按需创建,避免污染 HOME)。 */ +export function ensureAllProjectDirs(): void { + const projectDirs: ResourceKind[] = ['skill', 'mcp', 'agents', 'provider']; + projectDirs.forEach((kind) => { + ensureResourceDir(kind, 'project'); + }); +} + +/** + * 解析单个资源的 scope:若项目级文件/目录存在则 project,否则若用户级存在则 user,否则 null。 + * 用于按 name 读单个资源时定位它住在哪一层。 + * + * @param entry 相对路径(文件名或目录名),如 "my-skill" 或 "github.json" + */ +export function locateResource( + kind: ResourceKind, + entry: string, +): { scope: ResourceScope; absPath: string } | null { + const dirs = getResourceDirs(kind); + const projectPath = path.join(dirs.project, entry); + if (fs.existsSync(projectPath)) return { scope: 'project', absPath: projectPath }; + const userPath = path.join(dirs.user, entry); + if (fs.existsSync(userPath)) return { scope: 'user', absPath: userPath }; + return null; +} + +/** 资源名安全校验:仅允许 a-z 0-9 - _,长度 1-64。 */ +export function isValidResourceName(name: string): boolean { + return /^[a-zA-Z0-9_-]{1,64}$/.test(name); +} + +/** 抛错版校验,便于路由层使用。 */ +export function assertValidResourceName(name: string): void { + if (!isValidResourceName(name)) { + throw new Error( + `Invalid resource name: ${JSON.stringify(name)} (allowed: a-zA-Z0-9_-, length 1-64)`, + ); + } +} diff --git a/src/services/resources/providers/manager.ts b/src/services/resources/providers/manager.ts new file mode 100644 index 0000000..6d6db23 --- /dev/null +++ b/src/services/resources/providers/manager.ts @@ -0,0 +1,392 @@ +/** + * Provider 管理器 + * + * 职责: + * 1. 读写 ~/.local/share/opencode/auth.json(仅 type=api 的增删改,OAuth 只读) + * 2. 缓存 opencode models 输出(按 provider 分组) + * 3. 提供 list / get / setKey / removeKey / refreshModels 接口 + * + * 注意: + * - OAuth 类型的 provider 只能通过 opencode providers login 命令在终端登录 + * - Web 端只能管理 type=api 的 provider + */ + +import fs from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { + emitResourceChange, +} from '../events.js'; +import { PROVIDER_REFRESH_INTERVAL_MS } from '../constants.js'; +import type { + ApiProviderConfig, + ModelsCache, + ModelInfo, + OpenCodeAuthConfig, + ProviderConfig, + ProviderSummary, +} from './types.js'; + +// Re-export types for CLI use +export type { ProviderSummary, ModelInfo }; +import { + getOpenCodeAuthPath, + isProviderEditable, + PROVIDER_DISPLAY_NAMES, +} from './types.js'; + +/** 注册表状态 */ +interface ProviderRegistryState { + /** auth.json 内容缓存 */ + authConfig: OpenCodeAuthConfig; + /** 模型列表缓存 */ + modelsCache: ModelsCache; + /** 是否已初始化 */ + initialized: boolean; + /** 是否已释放 */ + disposed: boolean; +} + +/** + * 读取 auth.json 文件 + */ +async function readAuthConfig(): Promise { + const authPath = getOpenCodeAuthPath(); + try { + const content = await fs.readFile(authPath, 'utf-8'); + const parsed = JSON.parse(content) as OpenCodeAuthConfig; + return parsed || {}; + } catch (err) { + // 文件不存在或解析失败,返回空对象 + return {}; + } +} + +/** + * 写入 auth.json 文件(原子性写入) + */ +async function writeAuthConfig(config: OpenCodeAuthConfig): Promise { + const authPath = getOpenCodeAuthPath(); + const tempPath = authPath + '.tmp'; + const content = JSON.stringify(config, null, 2); + + // 原子性写入:先写临时文件,再重命名 + await fs.writeFile(tempPath, content, 'utf-8'); + await fs.rename(tempPath, authPath); +} + +/** + * 执行 opencode models 命令并解析输出 + */ +async function fetchModelsFromOpenCode(): Promise> { + return new Promise((resolve) => { + const models = new Map(); + const isWindows = process.platform === 'win32'; + const command = isWindows ? 'opencode models' : 'opencode'; + const args = isWindows ? [] : ['models']; + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: isWindows, + windowsHide: isWindows, + }); + let settled = false; + + let stdout = ''; + let stderr = ''; + + const finish = (result: Map): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeoutId); + resolve(result); + }; + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('error', (error) => { + console.error(`[Providers] 启动 ${command} models 失败:`, error instanceof Error ? error.message : String(error)); + finish(new Map()); + }); + + child.on('close', (code) => { + if (code !== 0) { + console.error('[Providers] opencode models 失败:', stderr || `exit code ${code}`); + // 即使失败也返回空缓存,不阻塞启动 + finish(new Map()); + return; + } + + // 解析输出:每行格式为 "provider/model" + for (const line of stdout.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const slashIndex = trimmed.indexOf('/'); + if (slashIndex === -1) { + // 没有 / 的行,归入 unknown provider + const unknown = models.get('unknown') || []; + unknown.push(trimmed); + models.set('unknown', unknown); + continue; + } + + const providerId = trimmed.slice(0, slashIndex); + const modelId = trimmed.slice(slashIndex + 1); + + const providerModels = models.get(providerId) || []; + providerModels.push(modelId); + models.set(providerId, providerModels); + } + + finish(models); + }); + + // 30 秒超时 + const timeoutId = setTimeout(() => { + child.kill(); + console.error('[Providers] opencode models 超时'); + finish(models); + }, 30000); + }); +} + +/** + * Provider Registry 类 + */ +export class ProviderRegistry { + private state: ProviderRegistryState = { + authConfig: {}, + modelsCache: new Map(), + initialized: false, + disposed: false, + }; + private refreshInterval: NodeJS.Timeout | null = null; + + /** + * 初始化:读取 auth.json + 缓存 models + */ + async init(): Promise { + if (this.state.disposed) { + throw new Error('ProviderRegistry 已释放,不可重新初始化'); + } + if (this.state.initialized) { + return; // 幂等 + } + + // 读取 auth.json + this.state.authConfig = await readAuthConfig(); + + // 缓存 models(后台执行,不阻塞初始化) + this.refreshModels().catch((err) => { + console.error('[Providers] 缓存 models 失败:', err); + }); + + // 设置定期刷新(每30分钟) + this.startPeriodicRefresh(); + + this.state.initialized = true; + console.log('[Providers] Registry 已就绪'); + } + + /** + * 启动定期刷新 + */ + private startPeriodicRefresh(): void { + if (this.refreshInterval) { + return; + } + + this.refreshInterval = setInterval(() => { + if (!this.state.disposed) { + this.refreshModels().catch((err) => { + console.error('[Providers] 定期刷新 models 失败:', err); + }); + } + }, PROVIDER_REFRESH_INTERVAL_MS); + + console.log(`[Providers] 已设置定期刷新,间隔 ${PROVIDER_REFRESH_INTERVAL_MS / 60000} 分钟`); + } + + /** + * 释放 + */ + async dispose(): Promise { + this.state.disposed = true; + this.state.modelsCache.clear(); + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + console.log('[Providers] Registry 已释放'); + } + + /** + * 列出所有 provider(摘要信息) + */ + list(): ProviderSummary[] { + const result: ProviderSummary[] = []; + + for (const [providerId, config] of Object.entries(this.state.authConfig)) { + const configured = config.type === 'api' + ? !!config.key + : !!config.access; + + result.push({ + providerId, + type: config.type, + configured, + editable: isProviderEditable(config), + displayName: PROVIDER_DISPLAY_NAMES[providerId], + }); + } + + // 按 providerId 排序 + return result.sort((a, b) => a.providerId.localeCompare(b.providerId)); + } + + /** + * 获取单个 provider 配置 + */ + get(providerId: string): ProviderConfig | null { + return this.state.authConfig[providerId] || null; + } + + /** + * 设置 API Key(仅适用于 type=api 的 provider) + */ + async setKey(providerId: string, apiKey: string): Promise { + if (this.state.disposed) { + throw new Error('ProviderRegistry 已释放'); + } + + const existing = this.state.authConfig[providerId]; + + // 如果已存在且是 OAuth 类型,拒绝覆盖 + if (existing && existing.type === 'oauth') { + throw new Error(`Provider "${providerId}" 是 OAuth 类型,无法设置 API Key`); + } + + // 更新配置,保留未知字段 + let newConfig: ApiProviderConfig; + if (existing && existing.type === 'api') { + // 合并现有配置,保留未知字段 + newConfig = { + ...existing, + type: 'api', + key: apiKey, + }; + } else { + // 创建新配置 + newConfig = { + type: 'api', + key: apiKey, + }; + } + + this.state.authConfig[providerId] = newConfig; + await writeAuthConfig(this.state.authConfig); + + emitResourceChange('provider', 'update', { name: providerId }); + + console.log(`[Providers] 已设置 provider "${providerId}" 的 API Key`); + } + + /** + * 删除 provider 配置 + */ + async removeKey(providerId: string): Promise { + if (this.state.disposed) { + throw new Error('ProviderRegistry 已释放'); + } + + const existing = this.state.authConfig[providerId]; + if (!existing) { + throw new Error(`Provider "${providerId}" 不存在`); + } + + // OAuth 类型不允许删除(建议用户通过 opencode providers logout 删除) + if (existing.type === 'oauth') { + throw new Error(`Provider "${providerId}" 是 OAuth 类型,请通过命令行删除:opencode providers logout ${providerId}`); + } + + delete this.state.authConfig[providerId]; + await writeAuthConfig(this.state.authConfig); + + emitResourceChange('provider', 'remove', { name: providerId }); + + console.log(`[Providers] 已删除 provider "${providerId}"`); + } + + /** + * 刷新模型缓存(重新执行 opencode models) + */ + async refreshModels(): Promise { + if (this.state.disposed) { + return; + } + + console.log('[Providers] 正在刷新模型列表...'); + const newCache = await fetchModelsFromOpenCode(); + this.state.modelsCache = newCache; + + const totalModels = Array.from(newCache.values()).reduce((sum, arr) => sum + arr.length, 0); + console.log(`[Providers] 模型列表已刷新,共 ${newCache.size} 个 provider、${totalModels} 个模型`); + + emitResourceChange('provider', 'reload'); + } + + /** + * 获取指定 provider 的模型列表 + */ + getModels(providerId: string): string[] { + return this.state.modelsCache.get(providerId) || []; + } + + /** + * 获取所有模型信息(扁平化列表) + */ + getAllModels(): ModelInfo[] { + const result: ModelInfo[] = []; + + for (const [providerId, modelIds] of this.state.modelsCache.entries()) { + for (const modelId of modelIds) { + result.push({ + providerId, + modelId, + fullName: `${providerId}/${modelId}`, + }); + } + } + + return result; + } + + /** + * 检查 provider 是否已配置 + */ + isConfigured(providerId: string): boolean { + const config = this.state.authConfig[providerId]; + if (!config) return false; + return config.type === 'api' ? !!config.key : !!config.access; + } +} + +// 单例 +let globalProviderRegistry: ProviderRegistry | null = null; + +/** + * 获取全局 Provider registry 单例 + */ +export function getProviderRegistry(): ProviderRegistry { + if (!globalProviderRegistry) { + globalProviderRegistry = new ProviderRegistry(); + } + return globalProviderRegistry; +} diff --git a/src/services/resources/providers/types.ts b/src/services/resources/providers/types.ts new file mode 100644 index 0000000..a3a2dd7 --- /dev/null +++ b/src/services/resources/providers/types.ts @@ -0,0 +1,106 @@ +/** + * Provider 配置类型定义 + * + * 管理模型供应商的 API Key 配置: + * - 读写 ~/.local/share/opencode/auth.json(仅 type=api 的增删改,OAuth 只读) + * - 缓存 opencode models 命令输出(按 provider 分组) + * - 提供列表、获取、设置、删除 API Key 的功能 + */ + +import path from 'node:path'; +import os from 'node:os'; + +/** Provider 认证类型 */ +export type ProviderAuthType = 'api' | 'oauth'; + +/** API 类型 Provider 配置(可读写) */ +export interface ApiProviderConfig { + type: 'api'; + key: string; + [key: string]: unknown; // 允许未知字段 +} + +/** OAuth 类型 Provider 配置(只读) */ +export interface OAuthProviderConfig { + type: 'oauth'; + access: string; + refresh: string; + expires: number; + accountId?: string; + [key: string]: unknown; // 允许未知字段 +} + +/** Provider 配置联合类型 */ +export type ProviderConfig = ApiProviderConfig | OAuthProviderConfig; + +/** Provider 信息摘要 */ +export interface ProviderSummary { + providerId: string; + type: ProviderAuthType; + /** 是否已配置(type=api 有 key,type=oauth 有 access token) */ + configured: boolean; + /** 是否可编辑(仅 type=api 可编辑) */ + editable: boolean; + /** 供应商显示名称(从 opename 映射,可选) */ + displayName?: string; +} + +/** 模型信息 */ +export interface ModelInfo { + providerId: string; + modelId: string; + /** 完整模型标识符(provider/model) */ + fullName: string; +} + +/** 模型缓存(按 provider 分组) */ +export type ModelsCache = Map; + +/** OpenCode auth.json 文件内容 */ +export type OpenCodeAuthConfig = Record; + +/** + * 解析 ~/.local/share/opencode/auth.json 路径 + * 可通过环境变量 OPENCODE_AUTH_PATH 覆盖(用于测试) + */ +export function getOpenCodeAuthPath(): string { + const fromEnv = process.env.OPENCODE_AUTH_PATH?.trim(); + if (fromEnv) return path.resolve(fromEnv); + const homeDir = os.homedir(); + return path.join(homeDir, '.local', 'share', 'opencode', 'auth.json'); +} + +/** + * 常见 Provider ID 到显示名称的映射 + */ +export const PROVIDER_DISPLAY_NAMES: Record = { + 'openai': 'OpenAI', + 'anthropic': 'Anthropic', + 'google': 'Google', + 'custom-gemini': 'Custom Gemini', + 'nvidia': 'NVIDIA', + 'deepseek': 'DeepSeek', + 'zhipuai': '智谱 AI', + 'minimax': 'MiniMax', + 'moonshot': 'Moonshot', + 'antigravity': 'Antigravity', +}; + +/** + * 内置 Provider 列表(opencode 原生支持的供应商) + * 这些供应商可以通过 opencode providers login 进行 OAuth 登录 + */ +export const BUILTIN_PROVIDERS: string[] = [ + 'openai', + 'anthropic', + 'google', + 'nvidia', + 'deepseek', +]; + +/** + * 判断 provider 是否可编辑(仅 type=api 可编辑) + */ +export function isProviderEditable(config: ProviderConfig): boolean { + return config.type === 'api'; +} diff --git a/src/services/resources/skills/loader.ts b/src/services/resources/skills/loader.ts new file mode 100644 index 0000000..e18de26 --- /dev/null +++ b/src/services/resources/skills/loader.ts @@ -0,0 +1,259 @@ +/** + * Skill 文件加载器(无状态、纯函数) + * + * 负责把磁盘上单个 skill 目录解析成内存对象;不持有任何缓存或监听器。 + * 缓存与热载由 registry.ts 负责。 + * + * Skill 目录结构: + * data/skills// + * SKILL.md (必需,YAML frontmatter + Markdown 正文) + * scripts/ (可选,附属脚本) + * assets/ (可选,附属资源) + * + * SKILL.md frontmatter 字段: + * name string 必需,必须与目录名一致(防止重命名后引用失效) + * description string 必需,slash 命令补全/列表用文案 + * version string 可选 + * allowed-tools string[] 可选,注入时传给模型作为工具白名单 + * enabled boolean 可选,缺省 true;false 时不参与 slash 注入 + */ + +import path from 'node:path'; +import fs from 'node:fs'; +import matter from 'gray-matter'; + +import { isValidResourceName } from '../paths.js'; +import type { ResourceScope } from '../types.js'; + +/** 解析后的 skill frontmatter(已校验必需字段)。 */ +export interface SkillFrontmatter { + name: string; + description: string; + version?: string; + /** YAML 中可能写作 allowed-tools 或 allowedTools,统一在解析时归一化。 */ + allowedTools?: string[]; + enabled: boolean; + /** 其它未识别字段原样保留,便于将来扩展。 */ + extra: Record; +} + +/** 一个 skill 的完整内存表示。 */ +export interface ParsedSkill { + /** 资源名(= 目录名 = frontmatter.name)。 */ + name: string; + /** 项目级 / 用户级。 */ + scope: ResourceScope; + /** skill 目录绝对路径。 */ + dir: string; + /** SKILL.md 绝对路径。 */ + filePath: string; + /** SKILL.md 原始内容(含 frontmatter,便于编辑器回填)。 */ + raw: string; + /** 解析后的 frontmatter。 */ + frontmatter: SkillFrontmatter; + /** Markdown 正文(去掉 frontmatter 后)。 */ + body: string; + /** 同目录下的脚本相对路径列表(相对 dir)。 */ + scripts: string[]; + /** 同目录下的附件相对路径列表(相对 dir)。 */ + assets: string[]; + /** 文件 mtime(毫秒),用于热载去抖与状态展示。 */ + mtimeMs: number; +} + +/** 加载失败时的错误对象。 */ +export interface SkillLoadError { + name: string; + scope: ResourceScope; + dir: string; + message: string; +} + +/** parseSkillDir 的返回类型:成功或失败。 */ +export type SkillLoadResult = + | { ok: true; skill: ParsedSkill } + | { ok: false; error: SkillLoadError }; + +/** 列举目录下文件(一层),过滤掉点文件;目录不存在时返回空数组。 */ +function listDirShallow(absDir: string): string[] { + if (!fs.existsSync(absDir)) return []; + try { + return fs + .readdirSync(absDir, { withFileTypes: true }) + .filter((d) => !d.name.startsWith('.')) + .map((d) => d.name); + } catch { + return []; + } +} + +/** 把 frontmatter 原始数据归一化为 SkillFrontmatter;缺失或类型错误时抛错。 */ +function normalizeFrontmatter( + raw: Record, + expectedName: string, +): SkillFrontmatter { + const name = raw.name; + if (typeof name !== 'string' || !isValidResourceName(name)) { + throw new Error( + `frontmatter.name 必须为合法资源名(a-zA-Z0-9_-, 1-64), 实际: ${JSON.stringify(name)}`, + ); + } + if (name !== expectedName) { + throw new Error( + `frontmatter.name (${name}) 与目录名 (${expectedName}) 不一致;请保持一致以避免引用错位`, + ); + } + const description = raw.description; + if (typeof description !== 'string' || description.trim() === '') { + throw new Error('frontmatter.description 必需且不能为空'); + } + + const version = typeof raw.version === 'string' ? raw.version : undefined; + + // allowed-tools 与 allowedTools 二者皆可 + const allowedRaw = raw['allowed-tools'] ?? raw['allowedTools']; + let allowedTools: string[] | undefined; + if (allowedRaw !== undefined) { + if (!Array.isArray(allowedRaw) || allowedRaw.some((v) => typeof v !== 'string')) { + throw new Error('frontmatter.allowed-tools 必须为字符串数组'); + } + allowedTools = allowedRaw as string[]; + } + + const enabled = raw.enabled === undefined ? true : raw.enabled === true; + + // 收集未识别字段 + const known = new Set(['name', 'description', 'version', 'allowed-tools', 'allowedTools', 'enabled']); + const extra: Record = {}; + for (const [k, v] of Object.entries(raw)) { + if (!known.has(k)) extra[k] = v; + } + + return { name, description, version, allowedTools, enabled, extra }; +} + +/** + * 解析单个 skill 目录。 + * 失败返回 ok:false 而非抛错——批量扫描时上层需要继续处理其它 skill。 + */ +export function parseSkillDir( + dir: string, + scope: ResourceScope, + expectedName?: string, +): SkillLoadResult { + const name = expectedName ?? path.basename(dir); + const filePath = path.join(dir, 'SKILL.md'); + try { + if (!fs.existsSync(filePath)) { + return { + ok: false, + error: { name, scope, dir, message: '缺少 SKILL.md' }, + }; + } + const raw = fs.readFileSync(filePath, 'utf-8'); + const stat = fs.statSync(filePath); + + const parsed = matter(raw); + const frontmatter = normalizeFrontmatter( + (parsed.data ?? {}) as Record, + name, + ); + + const scriptsDir = path.join(dir, 'scripts'); + const assetsDir = path.join(dir, 'assets'); + + return { + ok: true, + skill: { + name, + scope, + dir, + filePath, + raw, + frontmatter, + body: parsed.content ?? '', + scripts: listDirShallow(scriptsDir), + assets: listDirShallow(assetsDir), + mtimeMs: stat.mtimeMs, + }, + }; + } catch (e) { + return { + ok: false, + error: { + name, + scope, + dir, + message: e instanceof Error ? e.message : String(e), + }, + }; + } +} + +/** + * 扫描指定 scope 下的所有 skill 目录。 + * 返回成功项与错误项的并集,由 registry 决定如何呈现给用户。 + */ +export function scanSkillsInScope( + rootDir: string, + scope: ResourceScope, +): { skills: ParsedSkill[]; errors: SkillLoadError[] } { + const skills: ParsedSkill[] = []; + const errors: SkillLoadError[] = []; + + if (!fs.existsSync(rootDir)) return { skills, errors }; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(rootDir, { withFileTypes: true }); + } catch (e) { + errors.push({ + name: '', + scope, + dir: rootDir, + message: `扫描 skills 根目录失败: ${e instanceof Error ? e.message : String(e)}`, + }); + return { skills, errors }; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.')) continue; + if (!isValidResourceName(entry.name)) { + errors.push({ + name: entry.name, + scope, + dir: path.join(rootDir, entry.name), + message: `目录名不合法(仅允许 a-zA-Z0-9_-, 1-64 长度)`, + }); + continue; + } + + const result = parseSkillDir(path.join(rootDir, entry.name), scope, entry.name); + if (result.ok) skills.push(result.skill); + else errors.push(result.error); + } + + return { skills, errors }; +} + +/** + * 序列化 SkillFrontmatter 回写到 SKILL.md。 + * 保留 body 不变;frontmatter 字段以稳定顺序输出,便于 diff。 + */ +export function serializeSkillMarkdown( + frontmatter: SkillFrontmatter, + body: string, +): string { + const ordered: Record = { + name: frontmatter.name, + description: frontmatter.description, + }; + if (frontmatter.version !== undefined) ordered.version = frontmatter.version; + if (frontmatter.allowedTools !== undefined) ordered['allowed-tools'] = frontmatter.allowedTools; + // enabled 仅在显式 false 时写入,保持默认文件干净 + if (frontmatter.enabled === false) ordered.enabled = false; + for (const [k, v] of Object.entries(frontmatter.extra)) ordered[k] = v; + + return matter.stringify(body, ordered); +} diff --git a/src/services/resources/skills/registry.ts b/src/services/resources/skills/registry.ts new file mode 100644 index 0000000..7d445ae --- /dev/null +++ b/src/services/resources/skills/registry.ts @@ -0,0 +1,372 @@ +/** + * Skill 注册表(有状态、带热载) + * + * 职责: + * 1. 启动时扫描 project + user 两层 skills 目录,构建内存索引 + * 2. 提供 CRUD(list / get / create / update / delete / toggle) + * 3. 通过 chokidar 监听两层目录的变更,去抖 200ms 后增量重载并 emit resource:changed + * 4. 暴露 listSkillSlashCommands() 供 chat-meta 列入 / 命令补全 + * + * 覆盖语义:项目级与用户级同名时,项目级 wins;两个版本都保留在 records 中, + * getSkill(name) 默认返回 winning(project),getSkill(name, 'user') 可显式取被遮蔽的那份。 + */ + +import path from 'node:path'; +import fs from 'node:fs'; +import chokidar, { type FSWatcher } from 'chokidar'; + +import { + assertValidResourceName, + ensureResourceDir, + getResourceDir, + getResourceDirs, +} from '../paths.js'; +import { emitResourceChange } from '../events.js'; +import type { ResourceScope } from '../types.js'; +import { + parseSkillDir, + scanSkillsInScope, + serializeSkillMarkdown, + type ParsedSkill, + type SkillFrontmatter, + type SkillLoadError, +} from './loader.js'; + +/** 内部记录:可能是已加载的 skill,或加载失败的占位。 */ +type SkillRecord = + | { kind: 'ok'; skill: ParsedSkill } + | { kind: 'error'; error: SkillLoadError }; + +/** 公开的 skill 摘要(list 用),不含完整 markdown,避免大量 IO。 */ +export interface SkillSummary { + name: string; + scope: ResourceScope; + status: 'loaded' | 'disabled' | 'error'; + description?: string; + version?: string; + enabled: boolean; + allowedTools?: string[]; + /** 被同名项目级 skill 遮蔽时为 true(仅 user 层条目可能为 true)。 */ + shadowed: boolean; + error?: string; + lastReloadAt?: string; + scriptsCount: number; + assetsCount: number; +} + +/** Slash 命令补全条目。 */ +export interface SkillSlashCommand { + /** 形如 "/skill:my-skill",前端按字面拼接展示。 */ + command: string; + description: string; + /** 触发后注入到对话的内容(SKILL.md 正文,可能含 frontmatter 提示)。 */ + payload: string; + /** 来源 skill 名,便于点击跳转编辑。 */ + skill: string; + scope: ResourceScope; +} + +/** 热载去抖窗口(毫秒)。 */ +const DEBOUNCE_MS = 200; + +class SkillRegistry { + /** key = `${scope}:${name}`,避免 project/user 同名互相覆盖记录。 */ + private records = new Map(); + private watchers: FSWatcher[] = []; + private debounceTimer: NodeJS.Timeout | null = null; + private initialized = false; + private lastReloadAt: string | undefined; + + /** 启动:确保目录存在、首次扫描、启动 watcher。多次调用幂等。 */ + init(): void { + if (this.initialized) return; + this.initialized = true; + + ensureResourceDir('skill', 'project'); + // user 目录不主动创建——避免污染 HOME;watcher 会容忍不存在。 + + this.fullReload('init'); + this.startWatchers(); + } + + /** 关闭 watcher、清空缓存。 */ + async dispose(): Promise { + if (!this.initialized) return; + this.initialized = false; + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + await Promise.all(this.watchers.map((w) => w.close())); + this.watchers = []; + this.records.clear(); + } + + /** 全量重载(启动、手动 reload、目录批量变更时使用)。 */ + fullReload(_reason: string): void { + const newMap = new Map(); + for (const scope of ['user', 'project'] as ResourceScope[]) { + // 顺序:先 user 后 project,便于人工调试时观察日志;映射 key 含 scope 互不干扰。 + const dir = getResourceDir('skill', scope); + const { skills, errors } = scanSkillsInScope(dir, scope); + for (const s of skills) newMap.set(`${scope}:${s.name}`, { kind: 'ok', skill: s }); + for (const e of errors) { + // 扫描失败(根目录读失败)时 name 可能是 ;用稳定 key 防覆盖 + const key = e.name === '' ? `${scope}:` : `${scope}:${e.name}`; + newMap.set(key, { kind: 'error', error: e }); + } + } + this.records = newMap; + this.lastReloadAt = new Date().toISOString(); + emitResourceChange('skill', 'reload'); + } + + /** 启动文件监听。两层目录都监听;不存在的目录会被 chokidar 忍受。 */ + private startWatchers(): void { + const { project, user } = getResourceDirs('skill'); + for (const dir of [project, user]) { + const watcher = chokidar.watch(dir, { + ignoreInitial: true, + depth: 3, // skill 目录 + 子目录(scripts/assets)足够 + awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }, + }); + const trigger = (filePath: string) => { + // 仅当变更落在 SKILL.md 或者 skill 顶层目录时触发;其它(脚本/附件)也触发 + // 但用去抖合并成一次 reload。 + void filePath; + this.scheduleReload(); + }; + watcher + .on('add', trigger) + .on('change', trigger) + .on('unlink', trigger) + .on('addDir', trigger) + .on('unlinkDir', trigger) + .on('error', (err) => { + // eslint-disable-next-line no-console + console.warn('[Skills] watcher error:', err); + }); + this.watchers.push(watcher); + } + } + + private scheduleReload(): void { + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => { + this.debounceTimer = null; + this.fullReload('watch'); + }, DEBOUNCE_MS); + } + + /** 列出所有 skill 摘要(含 user 被遮蔽的那一份)。 */ + list(): SkillSummary[] { + const summaries: SkillSummary[] = []; + // 找出项目级 name 集合,用于判定 user 是否被 shadow + const projectNames = new Set(); + for (const [, rec] of this.records) { + if (rec.kind === 'ok' && rec.skill.scope === 'project') { + projectNames.add(rec.skill.name); + } + } + for (const [, rec] of this.records) { + if (rec.kind === 'ok') { + const s = rec.skill; + const shadowed = s.scope === 'user' && projectNames.has(s.name); + const status: SkillSummary['status'] = !s.frontmatter.enabled ? 'disabled' : 'loaded'; + summaries.push({ + name: s.name, + scope: s.scope, + status, + description: s.frontmatter.description, + version: s.frontmatter.version, + enabled: s.frontmatter.enabled, + allowedTools: s.frontmatter.allowedTools, + shadowed, + lastReloadAt: this.lastReloadAt, + scriptsCount: s.scripts.length, + assetsCount: s.assets.length, + }); + } else { + summaries.push({ + name: rec.error.name, + scope: rec.error.scope, + status: 'error', + enabled: false, + shadowed: false, + error: rec.error.message, + lastReloadAt: this.lastReloadAt, + scriptsCount: 0, + assetsCount: 0, + }); + } + } + // 排序:project 在前,name 升序 + summaries.sort((a, b) => { + if (a.scope !== b.scope) return a.scope === 'project' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return summaries; + } + + /** + * 获取单个 skill 的完整内容。 + * 不指定 scope 时返回 winning 版本(project 优先),便于编辑器载入。 + */ + get(name: string, scope?: ResourceScope): ParsedSkill | null { + if (scope) { + const rec = this.records.get(`${scope}:${name}`); + return rec && rec.kind === 'ok' ? rec.skill : null; + } + const proj = this.records.get(`project:${name}`); + if (proj && proj.kind === 'ok') return proj.skill; + const user = this.records.get(`user:${name}`); + if (user && user.kind === 'ok') return user.skill; + return null; + } + + /** + * 创建新 skill。 + * 默认写到 project;若同名已存在于该 scope 抛错。 + */ + create(input: { + name: string; + scope?: ResourceScope; + frontmatter: Omit & { extra?: Record }; + body: string; + }): ParsedSkill { + const scope = input.scope ?? 'project'; + assertValidResourceName(input.name); + + const dir = path.join(getResourceDir('skill', scope), input.name); + if (fs.existsSync(dir)) { + throw new Error(`skill 已存在: ${input.name} (scope=${scope})`); + } + + ensureResourceDir('skill', scope); + fs.mkdirSync(dir, { recursive: true }); + + const fm: SkillFrontmatter = { + name: input.name, + description: input.frontmatter.description, + version: input.frontmatter.version, + allowedTools: input.frontmatter.allowedTools, + enabled: input.frontmatter.enabled ?? true, + extra: input.frontmatter.extra ?? {}, + }; + const md = serializeSkillMarkdown(fm, input.body); + fs.writeFileSync(path.join(dir, 'SKILL.md'), md, 'utf-8'); + + // 立即同步内存(不等 watcher) + const result = parseSkillDir(dir, scope, input.name); + if (result.ok) { + this.records.set(`${scope}:${input.name}`, { kind: 'ok', skill: result.skill }); + this.lastReloadAt = new Date().toISOString(); + emitResourceChange('skill', 'add', { name: input.name, scope }); + return result.skill; + } + throw new Error(`skill 创建后解析失败: ${result.error.message}`); + } + + /** + * 更新 skill:直接覆盖 SKILL.md。 + * 若 scope 未指定,按 project>user 顺序定位现有版本。 + */ + update(input: { + name: string; + scope?: ResourceScope; + frontmatter: Partial> & { + extra?: Record; + }; + body?: string; + }): ParsedSkill { + const existing = input.scope + ? this.get(input.name, input.scope) + : this.get(input.name); + if (!existing) throw new Error(`skill 不存在: ${input.name}`); + + const merged: SkillFrontmatter = { + name: existing.frontmatter.name, + description: input.frontmatter.description ?? existing.frontmatter.description, + version: + input.frontmatter.version !== undefined + ? input.frontmatter.version + : existing.frontmatter.version, + allowedTools: + input.frontmatter.allowedTools !== undefined + ? input.frontmatter.allowedTools + : existing.frontmatter.allowedTools, + enabled: + input.frontmatter.enabled !== undefined + ? input.frontmatter.enabled + : existing.frontmatter.enabled, + extra: input.frontmatter.extra ?? existing.frontmatter.extra, + }; + + const body = input.body ?? existing.body; + const md = serializeSkillMarkdown(merged, body); + fs.writeFileSync(existing.filePath, md, 'utf-8'); + + const result = parseSkillDir(existing.dir, existing.scope, existing.name); + if (!result.ok) throw new Error(`skill 更新后解析失败: ${result.error.message}`); + + this.records.set(`${existing.scope}:${existing.name}`, { + kind: 'ok', + skill: result.skill, + }); + this.lastReloadAt = new Date().toISOString(); + emitResourceChange('skill', 'update', { name: existing.name, scope: existing.scope }); + return result.skill; + } + + /** 删除 skill 目录。 */ + delete(name: string, scope?: ResourceScope): void { + const target = scope ? this.get(name, scope) : this.get(name); + if (!target) throw new Error(`skill 不存在: ${name}`); + fs.rmSync(target.dir, { recursive: true, force: true }); + this.records.delete(`${target.scope}:${target.name}`); + this.lastReloadAt = new Date().toISOString(); + emitResourceChange('skill', 'remove', { name: target.name, scope: target.scope }); + } + + /** 启用 / 禁用:改写 frontmatter.enabled。 */ + toggle(name: string, enabled: boolean, scope?: ResourceScope): ParsedSkill { + return this.update({ + name, + scope, + frontmatter: { enabled }, + }); + } + + /** 暴露给 chat-meta 的 slash 命令清单(仅 enabled 且 winning 版本)。 */ + listSlashCommands(): SkillSlashCommand[] { + const cmds: SkillSlashCommand[] = []; + const seen = new Set(); + // 先 project,后 user;同名 user 被跳过实现 shadow 语义 + for (const scope of ['project', 'user'] as ResourceScope[]) { + for (const [, rec] of this.records) { + if (rec.kind !== 'ok') continue; + if (rec.skill.scope !== scope) continue; + if (!rec.skill.frontmatter.enabled) continue; + if (seen.has(rec.skill.name)) continue; + seen.add(rec.skill.name); + cmds.push({ + command: `/skill:${rec.skill.name}`, + description: rec.skill.frontmatter.description, + payload: rec.skill.body, + skill: rec.skill.name, + scope: rec.skill.scope, + }); + } + } + cmds.sort((a, b) => a.command.localeCompare(b.command)); + return cmds; + } + + /** 测试用:判断是否已 init。 */ + isInitialized(): boolean { + return this.initialized; + } +} + +/** 单例实例。所有模块共享同一份 registry。 */ +export const skillRegistry = new SkillRegistry(); diff --git a/src/services/resources/types.ts b/src/services/resources/types.ts new file mode 100644 index 0000000..debfac9 --- /dev/null +++ b/src/services/resources/types.ts @@ -0,0 +1,44 @@ +/** + * 资源系统共享类型定义 + * + * 资源(Resource)= Skill / MCP Server / Agent / Provider 之统称。 + * 每种资源都遵循“项目级(./data/...)优先、用户级(~/.opencode-bridge/...)兜底”的两层覆盖语义。 + */ + +/** 资源种类。 */ +export type ResourceKind = 'skill' | 'mcp' | 'agents' | 'provider'; + +/** 配置作用域。project = 项目级(./data/);user = 用户级(~/.opencode-bridge/)。 */ +export type ResourceScope = 'project' | 'user'; + +/** 资源加载/运行状态。 */ +export type ResourceStatus = + | 'loaded' // 解析成功且启用 + | 'disabled' // 解析成功但被显式禁用 + | 'error' // 解析失败 + | 'unloaded'; // 尚未尝试加载 + +/** 资源公共元信息(所有 kind 共用的字段集合)。 */ +export interface ResourceMeta { + kind: ResourceKind; + name: string; + scope: ResourceScope; + status: ResourceStatus; + description?: string; + /** 最近一次成功加载/写入时间(ISO 字符串)。 */ + lastReloadAt?: string; + /** 解析失败时的错误信息。 */ + error?: string; +} + +/** 资源变更事件载荷。 */ +export interface ResourceChangeEvent { + kind: ResourceKind; + /** 受影响资源名;批量重载时可能为 null。 */ + name: string | null; + /** add | update | remove | reload。 */ + action: 'add' | 'update' | 'remove' | 'reload'; + scope?: ResourceScope; + /** 触发时间(毫秒时间戳)。 */ + at: number; +} diff --git a/src/services/vision-ocr.ts b/src/services/vision-ocr.ts new file mode 100644 index 0000000..7e21176 --- /dev/null +++ b/src/services/vision-ocr.ts @@ -0,0 +1,240 @@ +/** + * vision-ocr.ts + * + * 非多模态主模型的图片预处理服务。 + * + * 流程: + * 1. 借用 opencode 已配置的多模态 model(见 VISION_OCR_MODEL) + * 2. 创建临时 session(复用调用方 workspace/directory) + * 3. 发送图片 + 引导提示词(VISION_OCR_PROMPT),指定 model override + * 4. 提取 assistant 回复中所有 text part,拼接为 OCR/描述文本 + * 5. 删除临时 session(OCR 无需保留历史) + * + * 任何阶段失败均返回 null,调用方据此回退到 "直发图片" 原路径。 + */ + +import { opencodeClient } from '../opencode/client.js'; +import { visionPreprocessConfig } from '../config/platform.js'; +import type { Part } from '@opencode-ai/sdk'; + +/** + * OCR 单张图片的请求参数。 + */ +export interface OcrImageRequest { + /** 图片内容(dataURL,如 `data:image/png;base64,...`) */ + imageDataUrl: string; + /** 图片 MIME 类型,如 `image/png` */ + mime: string; + /** 原始文件名(用于日志和提示词) */ + filename: string; + /** 来源消息的 workspace 目录;用于临时 session 的上下文对齐 */ + directory?: string; + /** 单次调用超时(毫秒)。默认 60s,GLM-4.5V 等 vision 模型在大图场景耗时可能接近 30s */ + timeoutMs?: number; +} + +/** + * 通过 opencode 内的多模态 model 对图片做 OCR / 内容描述。 + * + * @returns 成功时返回描述文本;任何失败 / 降级场景返回 `null` + */ +export async function ocrImageViaOpencode(req: OcrImageRequest): Promise { + const model = visionPreprocessConfig.model; + if (!model) { + console.warn('[vision-ocr] VISION_OCR_MODEL 未配置,跳过 OCR'); + return null; + } + + const prompt = visionPreprocessConfig.prompt; + const timeoutMs = Math.max(5_000, req.timeoutMs ?? 60_000); + + // 1) 创建临时 session + let sessionId: string | undefined; + try { + const session = await opencodeClient.createSession( + `[vision-ocr] ${req.filename}`, + req.directory, + ); + sessionId = session?.id; + } catch (error) { + console.warn('[vision-ocr] 创建临时 session 失败', error instanceof Error ? error.message : error); + return null; + } + + if (!sessionId) { + console.warn('[vision-ocr] 临时 session id 为空'); + return null; + } + + // 2) 发送 prompt(带 model override 与图片 part),3) 提取文字 + let ocrText: string | null = null; + try { + ocrText = await withTimeout( + callPromptAndExtract(sessionId, model, prompt, req), + timeoutMs, + `vision-ocr timeout after ${timeoutMs}ms`, + ); + } catch (error) { + console.warn('[vision-ocr] OCR 调用失败', error instanceof Error ? error.message : error); + ocrText = null; + } + + // 4) 无论成败都删除临时 session + try { + await opencodeClient.deleteSession(sessionId, { directory: req.directory }); + } catch (error) { + // 删除失败不影响主流程,仅日志 + console.debug('[vision-ocr] 删除临时 session 失败', error instanceof Error ? error.message : error); + } + + return ocrText; +} + +/** + * 调 session.prompt 并从响应 parts 中拼出 text。 + * + * 这里直接用 SDK 底层接口而不是 opencodeClient.sendMessageParts, + * 因为后者不支持单次 model override 传递(bridge 内部封装的 `options.providerId/modelId` + * 会被 resolveModelOption 再次覆盖为 default),也不便禁用 tools。 + */ +async function callPromptAndExtract( + sessionId: string, + model: { providerID: string; modelID: string }, + systemPrompt: string, + req: OcrImageRequest, +): Promise { + const sdkClient = opencodeClient.getClient(); + + const response = await sdkClient.session.prompt({ + path: { id: sessionId }, + body: { + model, + system: systemPrompt, + // 禁用所有工具:OCR 纯描述任务,禁止模型调用 bash / read 等工具消耗额度 + tools: {}, + parts: [ + { + type: 'file', + mime: req.mime, + url: req.imageDataUrl, + filename: req.filename, + }, + ], + }, + ...(req.directory ? { query: { directory: req.directory } } : {}), + }); + + const parts = response?.data?.parts as Part[] | undefined; + if (!Array.isArray(parts) || parts.length === 0) return null; + + const pieces: string[] = []; + for (const part of parts) { + if (!part || typeof part !== 'object') continue; + if ((part as { type?: string }).type !== 'text') continue; + const text = (part as { text?: unknown }).text; + if (typeof text !== 'string') continue; + const trimmed = text.trim(); + if (!trimmed) continue; + if ((part as { synthetic?: boolean }).synthetic) continue; + if ((part as { ignored?: boolean }).ignored) continue; + pieces.push(trimmed); + } + + if (pieces.length === 0) return null; + return pieces.join('\n\n'); +} + +function withTimeout(promise: Promise, ms: number, reason: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(reason)), ms); + promise + .then(value => { + clearTimeout(timer); + resolve(value); + }) + .catch(err => { + clearTimeout(timer); + reject(err); + }); + }); +} + +/* ─────────────────────────────── 共享预处理入口 ─────────────────────────────── */ + +/** + * 聊天 / API 层共享的 Part 形态(文本 + 文件)。 + * + * 所有平台 handler 已在用 `{type:'file'|'text', ...}` 的结构,这里做最小公共定义, + * 便于 `preprocessVisionParts` 在调度端(而非每个 handler)统一处理非多模态主模型的 + * 图片回退。 + */ +export type VisionPart = + | { type: 'text'; text: string } + | { type: 'file'; mime: string; url: string; filename?: string }; + +/** + * 预处理 parts:主模型不支持 image 输入时,把每张图片交给 opencode 内配置的多模态 + * model 做 OCR,用文本 part 替换原图片 file part;否则原样透传。 + * + * 触发条件(全部满足才走 OCR): + * - parts 中至少一个 `file` part 的 `mime` 以 `image/` 开头 + * - `visionPreprocessConfig.enabled === true` 且已配置 OCR 副模型 + * - 传入了 providerId / modelId,且 `getModelCapabilities().input.image === false` + * (能力嗅探返回 null / 未提供 ids → 乐观假设支持图片,不做 OCR) + * + * OCR 失败 / 异常:按议题 #54 约定降级为"直发原图",保持与现有行为一致。 + * + * @param parts 已构建好的 parts 数组(经过上传/下载/解码后的 file dataURL) + * @param ctx 主模型 + 会话工作区上下文 + * @param tag 日志前缀(如 `'飞书'` / `'Chat API'`),便于排查 + */ +export async function preprocessVisionParts( + parts: VisionPart[], + ctx: { providerId?: string; modelId?: string; directory?: string }, + tag = 'vision-preprocess', +): Promise { + const hasImage = parts.some(p => p.type === 'file' && typeof p.mime === 'string' && p.mime.startsWith('image/')); + if (!hasImage) return parts; + if (!visionPreprocessConfig.enabled || !visionPreprocessConfig.model) return parts; + + // 能力嗅探:只在 provider/model 都已知时嗅探;嗅探失败 / null → 乐观放行 + let mainModelSupportsImage = true; + if (ctx.providerId && ctx.modelId) { + const caps = await opencodeClient.getModelCapabilities(ctx.providerId, ctx.modelId); + if (caps && caps.input && typeof caps.input.image === 'boolean') { + mainModelSupportsImage = caps.input.image; + } + } + if (mainModelSupportsImage) return parts; + + const resolved: VisionPart[] = []; + for (const part of parts) { + if (part.type !== 'file' || !part.mime.startsWith('image/')) { + resolved.push(part); + continue; + } + + const safeName = part.filename?.trim() || 'image'; + try { + const ocrText = await ocrImageViaOpencode({ + imageDataUrl: part.url, + mime: part.mime, + filename: safeName, + directory: ctx.directory, + }); + if (ocrText) { + resolved.push({ type: 'text', text: `[图片 ${safeName} 内容描述]\n${ocrText}` }); + continue; + } + console.warn(`[${tag}] 图片 ${safeName} OCR 失败,降级为直发原图`); + } catch (err) { + console.warn( + `[${tag}] 图片 ${safeName} OCR 异常,降级为直发原图:`, + err instanceof Error ? err.message : err, + ); + } + resolved.push(part); + } + + return resolved; +} diff --git a/src/store/chat-session.ts b/src/store/chat-session.ts index 660c6ce..330a942 100644 --- a/src/store/chat-session.ts +++ b/src/store/chat-session.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import type { EffortLevel } from '../commands/effort.js'; export type ChatSessionType = 'p2p' | 'group'; +export type SessionOrderMode = 'default' | 'last_time'; export interface ChatSessionData { chatId: string; @@ -21,6 +22,11 @@ export interface ChatSessionData { resolvedDirectory?: string; projectName?: string; defaultDirectory?: string; + sessionOrderMode?: SessionOrderMode; + qqOutputOnlyText?: boolean; + helpWithQc?: boolean; + sessionWithCtl?: boolean; + sessionWithChange?: boolean; reminderSent?: boolean; interactionHistory: InteractionRecord[]; } @@ -353,6 +359,13 @@ class ChatSessionStore { || this.inferChatTypeFromTitle(title) || this.inferChatTypeFromTitle(current?.title); + const preservedInteractionHistory = Array.isArray(current?.interactionHistory) + ? current.interactionHistory.map(record => ({ + ...record, + botFeishuMsgIds: Array.isArray(record.botFeishuMsgIds) ? [...record.botFeishuMsgIds] : [], + })) + : []; + const data: ChatSessionData = { chatId: conversationId, sessionId, @@ -365,10 +378,17 @@ class ChatSessionStore { ...(options?.resolvedDirectory ? { resolvedDirectory: options.resolvedDirectory } : {}), ...(options?.projectName ? { projectName: options.projectName } : {}), ...(current?.defaultDirectory ? { defaultDirectory: current.defaultDirectory } : {}), + ...(current?.sessionOrderMode ? { sessionOrderMode: current.sessionOrderMode } : {}), + ...(typeof current?.qqOutputOnlyText === 'boolean' ? { qqOutputOnlyText: current.qqOutputOnlyText } : {}), + ...(typeof current?.helpWithQc === 'boolean' ? { helpWithQc: current.helpWithQc } : {}), + ...(typeof current?.sessionWithCtl === 'boolean' ? { sessionWithCtl: current.sessionWithCtl } : {}), + ...(typeof current?.sessionWithChange === 'boolean' ? { sessionWithChange: current.sessionWithChange } : {}), ...(current?.preferredModel ? { preferredModel: current.preferredModel } : {}), ...(current?.preferredAgent ? { preferredAgent: current.preferredAgent } : {}), ...(current?.preferredEffort ? { preferredEffort: current.preferredEffort } : {}), - interactionHistory: [], + ...(current?.lastFeishuUserMsgId ? { lastFeishuUserMsgId: current.lastFeishuUserMsgId } : {}), + ...(current?.lastFeishuAiMsgId ? { lastFeishuAiMsgId: current.lastFeishuAiMsgId } : {}), + interactionHistory: preservedInteractionHistory, }; this.removeExistingBindingsForSession(sessionId, key); @@ -410,6 +430,13 @@ class ChatSessionStore { || this.inferChatTypeFromTitle(title) || this.inferChatTypeFromTitle(current?.title); + const preservedInteractionHistory = Array.isArray(current?.interactionHistory) + ? current.interactionHistory.map(record => ({ + ...record, + botFeishuMsgIds: Array.isArray(record.botFeishuMsgIds) ? [...record.botFeishuMsgIds] : [], + })) + : []; + const data: ChatSessionData = { chatId, sessionId, @@ -422,10 +449,17 @@ class ChatSessionStore { ...(options?.resolvedDirectory ? { resolvedDirectory: options.resolvedDirectory } : {}), ...(options?.projectName ? { projectName: options.projectName } : {}), ...(current?.defaultDirectory ? { defaultDirectory: current.defaultDirectory } : {}), + ...(current?.sessionOrderMode ? { sessionOrderMode: current.sessionOrderMode } : {}), + ...(typeof current?.qqOutputOnlyText === 'boolean' ? { qqOutputOnlyText: current.qqOutputOnlyText } : {}), + ...(typeof current?.helpWithQc === 'boolean' ? { helpWithQc: current.helpWithQc } : {}), + ...(typeof current?.sessionWithCtl === 'boolean' ? { sessionWithCtl: current.sessionWithCtl } : {}), + ...(typeof current?.sessionWithChange === 'boolean' ? { sessionWithChange: current.sessionWithChange } : {}), ...(current?.preferredModel ? { preferredModel: current.preferredModel } : {}), ...(current?.preferredAgent ? { preferredAgent: current.preferredAgent } : {}), ...(current?.preferredEffort ? { preferredEffort: current.preferredEffort } : {}), - interactionHistory: [], + ...(current?.lastFeishuUserMsgId ? { lastFeishuUserMsgId: current.lastFeishuUserMsgId } : {}), + ...(current?.lastFeishuAiMsgId ? { lastFeishuAiMsgId: current.lastFeishuAiMsgId } : {}), + interactionHistory: preservedInteractionHistory, }; this.removeExistingBindingsForSession(sessionId, namespacedKey); @@ -507,6 +541,11 @@ class ChatSessionStore { preferredAgent?: string; preferredEffort?: EffortLevel; defaultDirectory?: string; + sessionOrderMode?: SessionOrderMode; + qqOutputOnlyText?: boolean; + helpWithQc?: boolean; + sessionWithCtl?: boolean; + sessionWithChange?: boolean; } ): void { const session = this.getChatDataLegacyOrNamespaced(chatId); @@ -543,6 +582,46 @@ class ChatSessionStore { delete session.defaultDirectory; } } + + if ('sessionOrderMode' in config) { + if (config.sessionOrderMode) { + session.sessionOrderMode = config.sessionOrderMode; + } else { + delete session.sessionOrderMode; + } + } + + if ('qqOutputOnlyText' in config) { + if (typeof config.qqOutputOnlyText === 'boolean') { + session.qqOutputOnlyText = config.qqOutputOnlyText; + } else { + delete session.qqOutputOnlyText; + } + } + + if ('helpWithQc' in config) { + if (typeof config.helpWithQc === 'boolean') { + session.helpWithQc = config.helpWithQc; + } else { + delete session.helpWithQc; + } + } + + if ('sessionWithCtl' in config) { + if (typeof config.sessionWithCtl === 'boolean') { + session.sessionWithCtl = config.sessionWithCtl; + } else { + delete session.sessionWithCtl; + } + } + + if ('sessionWithChange' in config) { + if (typeof config.sessionWithChange === 'boolean') { + session.sessionWithChange = config.sessionWithChange; + } else { + delete session.sessionWithChange; + } + } this.save(); } @@ -554,6 +633,11 @@ class ChatSessionStore { preferredAgent?: string; preferredEffort?: EffortLevel; defaultDirectory?: string; + sessionOrderMode?: SessionOrderMode; + qqOutputOnlyText?: boolean; + helpWithQc?: boolean; + sessionWithCtl?: boolean; + sessionWithChange?: boolean; } ): void { const session = this.getSessionByConversation(platform, conversationId); @@ -591,6 +675,46 @@ class ChatSessionStore { } } + if ('sessionOrderMode' in config) { + if (config.sessionOrderMode) { + session.sessionOrderMode = config.sessionOrderMode; + } else { + delete session.sessionOrderMode; + } + } + + if ('qqOutputOnlyText' in config) { + if (typeof config.qqOutputOnlyText === 'boolean') { + session.qqOutputOnlyText = config.qqOutputOnlyText; + } else { + delete session.qqOutputOnlyText; + } + } + + if ('helpWithQc' in config) { + if (typeof config.helpWithQc === 'boolean') { + session.helpWithQc = config.helpWithQc; + } else { + delete session.helpWithQc; + } + } + + if ('sessionWithCtl' in config) { + if (typeof config.sessionWithCtl === 'boolean') { + session.sessionWithCtl = config.sessionWithCtl; + } else { + delete session.sessionWithCtl; + } + } + + if ('sessionWithChange' in config) { + if (typeof config.sessionWithChange === 'boolean') { + session.sessionWithChange = config.sessionWithChange; + } else { + delete session.sessionWithChange; + } + } + this.save(); } @@ -720,6 +844,36 @@ class ChatSessionStore { } } + ensureInteraction(chatId: string, record: InteractionRecord): void { + const session = this.getChatDataLegacyOrNamespaced(chatId); + if (!session) { + return; + } + + if (!session.interactionHistory) { + session.interactionHistory = []; + } + + const existing = session.interactionHistory.find(item => item.userFeishuMsgId === record.userFeishuMsgId); + if (existing) { + return; + } + + session.interactionHistory.push({ + ...record, + botFeishuMsgIds: Array.isArray(record.botFeishuMsgIds) ? [...record.botFeishuMsgIds] : [], + }); + + this.updateLegacyPointers(session); + + if (session.interactionHistory.length > 20) { + session.interactionHistory.shift(); + this.updateLegacyPointers(session); + } + + this.save(); + } + removeSession(chatId: string): void { const namespacedKey = this.legacyToNamespacedKey(chatId); diff --git a/src/store/config-store.ts b/src/store/config-store.ts index ca74adf..7caa1c8 100644 --- a/src/store/config-store.ts +++ b/src/store/config-store.ts @@ -7,10 +7,41 @@ * 3. 首次启动时若无 DB 则返回空对象,由 config.ts 决定是否迁移 .env */ -import Database from 'better-sqlite3'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import type Database from 'better-sqlite3'; + +let DatabaseCtor: typeof Database; + +try { + DatabaseCtor = (await import('better-sqlite3')).default; +} catch (err: any) { + const msg = [ + '[ConfigStore] FATAL: 无法加载 better-sqlite3 原生模块。', + `[ConfigStore] 平台: ${process.platform} 架构: ${process.arch} Node: ${process.versions.node}`, + `[ConfigStore] 错误: ${err?.message || err}`, + ]; + if (process.platform === 'darwin') { + msg.push( + '[ConfigStore] macOS 排查建议:', + ' 1. 确认下载的安装包与 CPU 架构匹配 (arm64 = Apple Silicon, x64 = Intel)', + ' 2. 执行: xattr -cr "/Applications/OpenCode Bridge.app" (移除安全隔离)', + ' 3. 删除配置数据库后重试: rm ~/Library/Application\\ Support/opencode-bridge/data/config.db', + ); + } + if (process.platform === 'linux' && process.env.ELECTRON_RUN_AS_NODE === '1') { + msg.push( + '[ConfigStore] Linux Electron 环境排查建议:', + ' 1. 确认已执行 electron-rebuild: npm run rebuild', + ' 2. 检查 .node 文件权限: ls -la node_modules/better-sqlite3/prebuilds/', + ); + } + for (const line of msg) { + console.error(line); + } + throw err; +} // ────────────────────────────────────────────── // 数据结构:与 .env.example 完整对应的扁平 KV 类型 @@ -74,7 +105,9 @@ export interface BridgeSettings { OPENCODE_HOST?: string; OPENCODE_PORT?: string; OPENCODE_AUTO_START?: string; + /** @deprecated 不再使用,保留供旧配置读取 */ OPENCODE_AUTO_START_CMD?: string; + OPENCODE_AUTO_START_FOREGROUND?: string; OPENCODE_SERVER_USERNAME?: string; OPENCODE_SERVER_PASSWORD?: string; OPENCODE_CONFIG_FILE?: string; @@ -154,9 +187,20 @@ export interface BridgeSettings { // 附件 ATTACHMENT_MAX_SIZE?: string; + // 非多模态模型图片预处理(借用 opencode 内已配置的多模态 model 做 OCR) + IMAGE_VISION_PREPROCESS?: string; // 'true' | 'false' + VISION_OCR_MODEL?: string; // 'providerID/modelID' + VISION_OCR_PROMPT?: string; // OCR 引导提示词 + // 模型(扩展字段,.env.example 未列出但 config.ts 引用) DEFAULT_PROVIDER?: string; DEFAULT_MODEL?: string; + CHAT_MODEL_WHITELIST?: string; // JSON 数组:["provider/model", ...] + + // CLI / TUI 向导相关(仅 CLI 使用,web 端不展示) + CLI_LANG?: string; // 'zh' | 'en' + WEB_ADMIN_DISABLED?: string; // 'true' 时无头模式启动不开 web 管理面板 + ADMIN_PORT?: string; // web 管理面板端口(默认 4098) } // ────────────────────────────────────────────── @@ -186,7 +230,7 @@ class ConfigStore { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - this.db = new Database(this.dbPath); + this.db = new DatabaseCtor(this.dbPath); this.initSchema(); } @@ -299,68 +343,32 @@ class ConfigStore { .run(); } + // 注:管理后台已彻底移除账号 / 密码鉴权及登录超时机制, + // 原 getAdminPassword / setAdminPassword / needsPasswordChange / + // getPasswordChangedAt / setPasswordChangedAt / + // getLoginTimeout / setLoginTimeout 等接口已删除。 + // admin_meta 表中遗留的相关字段不再读写,老数据会被自然忽略。 + // ────────────────────────────────────────────── - // 密码管理 + // 首次安装引导(onboarding)状态 // ────────────────────────────────────────────── - /** 获取管理员密码(数据库存储) */ - getAdminPassword(): string | null { - const row = this.db - .prepare<[], { value: string }>(`SELECT value FROM admin_meta WHERE key = 'admin_password'`) - .get(); - const value = row?.value; - return value && value !== '' ? value : null; - } - - /** 设置管理员密码 */ - setAdminPassword(password: string): void { - this.db - .prepare( - `INSERT INTO admin_meta (key, value) VALUES ('admin_password', ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value` - ) - .run(password); - } - - /** 获取密码修改时间 */ - getPasswordChangedAt(): string | null { + /** 是否已完成或跳过首次安装引导 */ + isOnboardingCompleted(): boolean { const row = this.db - .prepare<[], { value: string }>(`SELECT value FROM admin_meta WHERE key = 'password_changed_at'`) + .prepare<[], { value: string }>(`SELECT value FROM admin_meta WHERE key = 'onboarding_completed'`) .get(); - return row?.value || null; - } - - /** 设置密码修改时间 */ - setPasswordChangedAt(timestamp: string): void { - this.db - .prepare( - `INSERT INTO admin_meta (key, value) VALUES ('password_changed_at', ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value` - ) - .run(timestamp); - } - - /** 判断是否需要修改密码(首次登录) */ - needsPasswordChange(): boolean { - return this.getPasswordChangedAt() === null; - } - - /** 获取登录超时时间(分钟),0 表示不限制 */ - getLoginTimeout(): number { - const row = this.db - .prepare<[], { value: string }>(`SELECT value FROM admin_meta WHERE key = 'login_timeout_minutes'`) - .get(); - return row ? parseInt(row.value, 10) : 0; + return row?.value === '1'; } - /** 设置登录超时时间(分钟) */ - setLoginTimeout(minutes: number): void { + /** 设置引导完成标记(true=完成或跳过;false=重置以便重新展示) */ + setOnboardingCompleted(completed: boolean): void { this.db .prepare( - `INSERT INTO admin_meta (key, value) VALUES ('login_timeout_minutes', ?) + `INSERT INTO admin_meta (key, value) VALUES ('onboarding_completed', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value` ) - .run(String(minutes)); + .run(completed ? '1' : '0'); } getDbPath(): string { diff --git a/src/utils/chat-model-whitelist.ts b/src/utils/chat-model-whitelist.ts new file mode 100644 index 0000000..3adafc8 --- /dev/null +++ b/src/utils/chat-model-whitelist.ts @@ -0,0 +1,172 @@ +import { modelConfig } from '../config.js'; + +function normalizePart(value: string | undefined): string { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} + +export function buildChatModelWhitelistKey(providerId: string, modelId: string): string { + return `${normalizePart(providerId)}/${normalizePart(modelId)}`; +} + +export function getChatModelWhitelistSet(): Set { + return new Set( + modelConfig.chatModelWhitelist + .map(item => item.trim().toLowerCase()) + .filter(Boolean) + ); +} + +export function hasChatModelWhitelist(): boolean { + return getChatModelWhitelistSet().size > 0; +} + +export function isChatModelAllowed( + providerId: string, + modelId: string, + whitelist: Set = getChatModelWhitelistSet() +): boolean { + if (whitelist.size === 0) { + return true; + } + return whitelist.has(buildChatModelWhitelistKey(providerId, modelId)); +} + +export function parseChatModelReference(raw: string | undefined): { providerId: string; modelId: string } | null { + const normalized = typeof raw === 'string' ? raw.trim() : ''; + if (!normalized) { + return null; + } + + const separator = normalized.includes(':') ? ':' : normalized.includes('/') ? '/' : ''; + if (!separator) { + return null; + } + + const index = normalized.indexOf(separator); + if (index <= 0 || index === normalized.length - 1) { + return null; + } + + const providerId = normalized.slice(0, index).trim(); + const modelId = normalized.slice(index + 1).trim(); + if (!providerId || !modelId) { + return null; + } + + return { providerId, modelId }; +} + +export interface ChatModelCatalogItem { + providerId: string; + providerName: string; + modelId: string; + modelName: string; +} + +export function collectAllowedChatModels(providers: unknown[]): ChatModelCatalogItem[] { + const items: ChatModelCatalogItem[] = []; + const seen = new Set(); + + for (const provider of providers) { + if (!provider || typeof provider !== 'object') { + continue; + } + + const providerRecord = provider as Record; + const providerId = typeof providerRecord.id === 'string' ? providerRecord.id.trim() : ''; + if (!providerId) { + continue; + } + + const providerName = typeof providerRecord.name === 'string' && providerRecord.name.trim() + ? providerRecord.name.trim() + : providerId; + + const rawModels = providerRecord.models; + if (Array.isArray(rawModels)) { + for (const rawModel of rawModels) { + if (!rawModel || typeof rawModel !== 'object') { + continue; + } + + const modelRecord = rawModel as Record; + const modelId = typeof modelRecord.id === 'string' ? modelRecord.id.trim() : ''; + if (!modelId || !isChatModelAllowed(providerId, modelId)) { + continue; + } + + const key = buildChatModelWhitelistKey(providerId, modelId); + if (seen.has(key)) { + continue; + } + seen.add(key); + + items.push({ + providerId, + providerName, + modelId, + modelName: typeof modelRecord.name === 'string' && modelRecord.name.trim() + ? modelRecord.name.trim() + : modelId, + }); + } + continue; + } + + if (!rawModels || typeof rawModels !== 'object') { + continue; + } + + const modelMap = rawModels as Record; + for (const [modelKey, rawModel] of Object.entries(modelMap)) { + const modelRecord = rawModel && typeof rawModel === 'object' + ? rawModel as Record + : undefined; + const modelId = typeof modelRecord?.id === 'string' && modelRecord.id.trim() + ? modelRecord.id.trim() + : modelKey.trim(); + if (!modelId || !isChatModelAllowed(providerId, modelId)) { + continue; + } + + const key = buildChatModelWhitelistKey(providerId, modelId); + if (seen.has(key)) { + continue; + } + seen.add(key); + + items.push({ + providerId, + providerName, + modelId, + modelName: typeof modelRecord?.name === 'string' && modelRecord.name.trim() + ? modelRecord.name.trim() + : modelId, + }); + } + } + + return items; +} + +export function findAllowedChatModel( + providers: unknown[], + rawInput: string | undefined +): ChatModelCatalogItem | null { + const normalized = typeof rawInput === 'string' ? rawInput.trim().toLowerCase() : ''; + if (!normalized) { + return null; + } + + const models = collectAllowedChatModels(providers); + return models.find(model => { + const candidates = [ + `${model.providerId}:${model.modelId}`, + `${model.providerId}/${model.modelId}`, + model.modelId, + model.modelName, + ]; + + return candidates.some(candidate => candidate.trim().toLowerCase() === normalized); + }) || null; +} diff --git a/src/utils/directory-policy.ts b/src/utils/directory-policy.ts index a2a8928..0726b52 100644 --- a/src/utils/directory-policy.ts +++ b/src/utils/directory-policy.ts @@ -43,6 +43,18 @@ export interface DirectoryResolveError { export type DirectoryResolveResult = DirectoryResolved | DirectoryResolveError; +/** + * 约束作用域: + * - 'platform'(默认):平台接入(telegram/discord/qq/wecom/weixin/whatsapp/dingtalk/feishu/group/p2p + * 以及 /send 文件下发)必须强制 ALLOWED_DIRECTORIES 白名单,因为路径来自外部消息输入,不可信。 + * - 'workspace':AI 工作区(Web 管理面板内的文件树 / Git / 终端 / 新建项目对话框)。 + * 调用方已通过管理面板身份认证,白名单和默认工作目录对其不适用 —— 只保留 + * 格式校验、危险路径拦截、存在性/可访问性检查以及 realpath 规范化。 + * + * 变更点:白名单与默认工作目录仅约束平台接入,与 AI 工作区权限脱钩。 + */ +export type DirectoryPolicyScope = 'platform' | 'workspace'; + interface DirectoryResolveOptions { explicitDirectory?: string; aliasName?: string; @@ -53,6 +65,10 @@ interface DirectoryResolveOptions { allowedDirectories?: string[]; gitRootNormalization?: boolean; maxPathLength?: number; + /** + * 作用域,默认 'platform'。AI 工作区请传 'workspace' 以脱钩白名单约束。 + */ + scope?: DirectoryPolicyScope; } const isWindows = process.platform === 'win32'; @@ -147,6 +163,9 @@ export class DirectoryPolicy { const allowedDirectories = options?.allowedDirectories ?? directoryConfig.allowedDirectories; const gitRootNormalization = options?.gitRootNormalization ?? directoryConfig.gitRootNormalization; const maxPathLength = options?.maxPathLength ?? directoryConfig.maxPathLength; + const scope: DirectoryPolicyScope = options?.scope ?? 'platform'; + // AI 工作区脱钩白名单:Web 管理面板已做过身份认证,允许浏览服务进程可访问的任意目录。 + const enforceAllowlist = scope !== 'workspace'; // 1) 优先级合并 let raw = ''; @@ -238,27 +257,29 @@ export class DirectoryPolicy { }; } - // 5) 允许目录校验 + // 5) 允许目录校验(仅平台接入作用域生效;AI 工作区脱钩) const normalizedAllowed = this.normalizeAllowedDirectories(allowedDirectories); - if (normalizedAllowed.length > 0) { - if (!this.isPathAllowed(normalized, normalizedAllowed)) { + if (enforceAllowlist) { + if (normalizedAllowed.length > 0) { + if (!this.isPathAllowed(normalized, normalizedAllowed)) { + return { + ok: false, + code: 'not_allowed', + userMessage: this.buildAllowlistUserMessage(raw, 'not_allowed', allowedDirectories), + internalDetail: `不在允许范围: ${normalized}`, + ...(source ? { source } : {}), + raw, + }; + } + } else if (source === 'explicit') { return { ok: false, - code: 'not_allowed', - userMessage: this.buildAllowlistUserMessage(raw, 'not_allowed', allowedDirectories), - internalDetail: `不在允许范围: ${normalized}`, - ...(source ? { source } : {}), + code: 'explicit_requires_allowlist', + userMessage: this.buildAllowlistUserMessage(raw, 'missing_allowlist', allowedDirectories), + internalDetail: 'explicit 输入需要 ALLOWED_DIRECTORIES', raw, }; } - } else if (source === 'explicit') { - return { - ok: false, - code: 'explicit_requires_allowlist', - userMessage: this.buildAllowlistUserMessage(raw, 'missing_allowlist', allowedDirectories), - internalDetail: 'explicit 输入需要 ALLOWED_DIRECTORIES', - raw, - }; } // 6) 存在性与可访问性 @@ -313,7 +334,7 @@ export class DirectoryPolicy { }; } - if (normalizedAllowed.length > 0 && realpath !== normalized) { + if (enforceAllowlist && normalizedAllowed.length > 0 && realpath !== normalized) { if (!this.isPathAllowed(realpath, normalizedAllowed)) { return { ok: false, @@ -342,8 +363,8 @@ export class DirectoryPolicy { } } - // 9) Git 根目录归一化后的允许目录复检 - if (gitRoot && gitRoot !== realpath) { + // 9) Git 根目录归一化后的允许目录复检(仅平台接入作用域生效) + if (enforceAllowlist && gitRoot && gitRoot !== realpath) { if (normalizedAllowed.length > 0 && !this.isPathAllowed(gitRoot, normalizedAllowed)) { return { ok: false, diff --git a/src/utils/text-builder.ts b/src/utils/text-builder.ts index 0ae4854..e2ba3d4 100644 --- a/src/utils/text-builder.ts +++ b/src/utils/text-builder.ts @@ -106,12 +106,169 @@ export function buildPortableUpdateText(data: StreamCardData, showThinking: bool return '⏳ 正在处理...'; } +function normalizeSegmentText(text: string): string { + return text.replace(/\r\n/g, '\n').trim(); +} + +function uniqueOrderedTexts(items: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const item of items) { + const normalized = normalizeSegmentText(item); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function buildQQSegmentText( + data: StreamCardData, + type: 'reasoning' | 'text', + fallback: string +): string { + const segmentTexts = uniqueOrderedTexts( + (data.segments ?? []) + .filter((segment): segment is Extract[number], { type: 'reasoning' | 'text' }> => segment.type === type) + .map(segment => segment.text) + ); + + if (segmentTexts.length === 0) { + return normalizeSegmentText(fallback); + } + + return segmentTexts.join('\n\n'); +} + +function stripDuplicatedReasoning(mainText: string, reasoningText: string): string { + const main = normalizeSegmentText(mainText); + const reasoning = normalizeSegmentText(reasoningText); + + if (!main || !reasoning) { + return main; + } + + if (main === reasoning) { + return ''; + } + + if (main.startsWith(reasoning)) { + return main.slice(reasoning.length).trimStart(); + } + + const doubled = `${reasoning}\n\n`; + if (main.startsWith(doubled)) { + return main.slice(doubled.length).trimStart(); + } + + const firstIndex = main.indexOf(reasoning); + if (firstIndex >= 0) { + const trimmed = `${main.slice(0, firstIndex)}${main.slice(firstIndex + reasoning.length)}`.trim(); + return trimmed; + } + + return main; +} + +function buildQQMarkdownUpdateText(data: StreamCardData, showThinking: boolean = true): string { + const reasoningText = showThinking ? buildQQSegmentText(data, 'reasoning', data.thinking) : ''; + const mainText = stripDuplicatedReasoning(buildQQSegmentText(data, 'text', data.text), reasoningText); + + if (mainText && reasoningText) { + const safeThinking = reasoningText.replace(/```/g, '` ` `'); + const clippedThinking = safeThinking.length > 1400 + ? `${safeThinking.slice(0, 1400)}\n...(思考内容已截断)` + : safeThinking; + return [ + '**思考过程**', + clippedThinking, + '', + '**回复**', + mainText, + ].join('\n'); + } + + if (mainText) { + return mainText; + } + + if (reasoningText) { + const safeThinking = reasoningText.replace(/```/g, '` ` `'); + const clippedThinking = safeThinking.length > 1400 + ? `${safeThinking.slice(0, 1400)}\n...(思考内容已截断)` + : safeThinking; + return [ + '**思考过程**', + clippedThinking, + '', + '⏳ 正在处理...', + ].join('\n'); + } + + if (data.status === 'failed') { + return '❌ 执行失败'; + } + + if (data.status === 'completed') { + return '✅ 已完成'; + } + + return '⏳ 正在处理...'; +} + +function buildQQPlainUpdateText(data: StreamCardData, showThinking: boolean = true): string { + const reasoningText = showThinking ? buildQQSegmentText(data, 'reasoning', data.thinking) : ''; + const mainText = stripDuplicatedReasoning(buildQQSegmentText(data, 'text', data.text), reasoningText); + + if (mainText && reasoningText) { + const clippedThinking = reasoningText.length > 1400 + ? `${reasoningText.slice(0, 1400)}\n...(思考内容已截断)` + : reasoningText; + return [ + '思考过程:', + clippedThinking, + '', + '回复:', + mainText, + ].join('\n'); + } + + if (mainText) { + return mainText; + } + + if (reasoningText) { + const clippedThinking = reasoningText.length > 1400 + ? `${reasoningText.slice(0, 1400)}\n...(思考内容已截断)` + : reasoningText; + return [ + '思考过程:', + clippedThinking, + '', + '⏳ 正在处理...', + ].join('\n'); + } + + if (data.status === 'failed') { + return '❌ 执行失败'; + } + + if (data.status === 'completed') { + return '✅ 已完成'; + } + + return '⏳ 正在处理...'; +} + /** * 可移植更新负载类型 */ export type PortableUpdatePayload = { text: string; markdown: string; + qqText: string; telegramText: string; discordText: string; discordComponents?: Array<{ @@ -157,11 +314,87 @@ export function buildPortableUpdatePayload( segments: filteredSegments, }; + if (platform === 'qq') { + const qqMarkdownText = buildQQMarkdownUpdateText(filteredData, showThinkingChain); + const qqPlainText = buildQQPlainUpdateText(filteredData, showThinkingChain); + + if (!data.pendingQuestion) { + return { + text: qqPlainText, + markdown: qqMarkdownText, + qqText: qqPlainText, + telegramText: qqPlainText, + discordText: qqMarkdownText, + }; + } + + const questionLine = `❓ ${data.pendingQuestion.question}`; + const progressLine = `第 ${data.pendingQuestion.questionIndex + 1}/${data.pendingQuestion.totalQuestions} 题`; + const qqMarkdownWithQuestion = `${qqMarkdownText}\n${questionLine}\n${progressLine}`; + const qqPlainWithQuestion = `${qqPlainText}\n\n${questionLine}\n${progressLine}`; + + const optionList = data.pendingQuestion.options + .filter(option => option.label.trim().length > 0) + .slice(0, 24) + .map(option => ({ + label: option.label, + value: option.label, + ...(option.description ? { description: option.description } : {}), + })); + + const options = [...optionList, { + label: '跳过本题', + value: '__skip__', + description: '留空并进入下一题', + }]; + + if (options.length === 0) { + return { + text: qqPlainWithQuestion, + markdown: qqMarkdownWithQuestion, + qqText: qqPlainWithQuestion, + telegramText: qqPlainWithQuestion, + discordText: qqMarkdownWithQuestion, + }; + } + + const maxValues = data.pendingQuestion.multiple + ? Math.min(Math.max(1, optionList.length), 25) + : 1; + + const telegramButtons = optionList.slice(0, 8).map((opt, idx) => ({ + text: opt.label, + callback_data: `oc_question:${idx}`, + })); + if (telegramButtons.length < 8) { + telegramButtons.push({ text: '跳过本题', callback_data: 'oc_question:skip' }); + } + + return { + text: qqPlainWithQuestion, + markdown: qqMarkdownWithQuestion, + qqText: qqPlainWithQuestion, + telegramText: qqPlainWithQuestion, + discordText: qqMarkdownWithQuestion, + discordComponents: [ + { + type: 'select', + customId: `oc_question:${conversationId}`, + placeholder: '选择当前问题答案', + options, + minValues: 1, + maxValues, + }, + ], + buttons: telegramButtons, + }; + } + const baseText = buildPortableUpdateText(filteredData, showThinkingChain); const telegramBaseText = buildTelegramText(filteredData, showThinkingChain); if (!data.pendingQuestion) { - return { text: baseText, markdown: baseText, telegramText: telegramBaseText, discordText: baseText }; + return { text: baseText, markdown: baseText, qqText: baseText, telegramText: telegramBaseText, discordText: baseText }; } const questionLine = `❓ ${data.pendingQuestion.question}`; @@ -185,7 +418,7 @@ export function buildPortableUpdatePayload( }]; if (options.length === 0) { - return { text: discordText, markdown: discordText, telegramText: telegramTextWithQuestion, discordText }; + return { text: discordText, markdown: discordText, qqText: discordText, telegramText: telegramTextWithQuestion, discordText }; } const maxValues = data.pendingQuestion.multiple @@ -203,6 +436,7 @@ export function buildPortableUpdatePayload( return { text: discordText, markdown: discordText, + qqText: discordText, telegramText: telegramTextWithQuestion, discordText, discordComponents: [ @@ -217,4 +451,4 @@ export function buildPortableUpdatePayload( ], buttons: telegramButtons, }; -} \ No newline at end of file +} diff --git a/src/utils/version.ts b/src/utils/version.ts new file mode 100644 index 0000000..6c36503 --- /dev/null +++ b/src/utils/version.ts @@ -0,0 +1,55 @@ +/** + * 读取本项目 package.json 中的 version 字段。 + * + * 取值优先级: + * 1. `process.env.APP_VERSION` + * - Electron 打包后,Admin / Bridge 以 ELECTRON_RUN_AS_NODE=1 启动,无法读 asar 内的 + * package.json;因此由 Electron 主进程在 spawn 时通过环境变量传入(见 electron/main.ts)。 + * 2. fs.readFileSync 多路径试探 + * - dist/utils/ → ../../package.json(开发 / 源码部署) + * - dist-electron/utils/ → ../../package.json(兜底) + * - resourcesPath/app/package.json、resourcesPath/package.json(Electron 打包场景的兜底) + * 3. 全部失败 → 'unknown',不崩溃 + * + * 绝不要改回 `import pkg from '../../package.json' with { type: 'json' }` —— ESM JSON 导入 + * 要求文件在解析路径上真实存在,这与 Electron asar 打包策略冲突。 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function readVersion(): string { + // 1) 环境变量(Electron 主进程注入) + const envVersion = process.env.APP_VERSION; + if (envVersion && envVersion.trim()) { + return envVersion.trim(); + } + + // 2) 磁盘多路径试探 + const candidates: string[] = [ + path.resolve(__dirname, '../../package.json'), + path.resolve(__dirname, '../../../package.json'), + ]; + const resourcesPath = (process as any).resourcesPath as string | undefined; + if (resourcesPath) { + candidates.push(path.join(resourcesPath, 'app', 'package.json')); + candidates.push(path.join(resourcesPath, 'package.json')); + } + + for (const p of candidates) { + try { + const text = fs.readFileSync(p, 'utf-8'); + const parsed = JSON.parse(text); + if (parsed && typeof parsed.version === 'string') { + return parsed.version; + } + } catch { + // 尝试下一个路径 + } + } + return 'unknown'; +} + +export const VERSION = readVersion(); diff --git a/tests/bridge-manager-restart-dedupe.test.ts b/tests/bridge-manager-restart-dedupe.test.ts new file mode 100644 index 0000000..59a2559 --- /dev/null +++ b/tests/bridge-manager-restart-dedupe.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { BridgeManager } from '../src/admin/bridge-manager.js'; + +type RestartResult = { success: boolean; pid?: number; error?: string }; +type StopResult = { success: boolean; error?: string }; + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('BridgeManager restart dedupe', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('并发 restart 调用应复用同一重启流程', async () => { + vi.useFakeTimers(); + + const manager = new BridgeManager(true) as BridgeManager & { + stop: () => Promise; + start: () => Promise; + }; + const stopDeferred = createDeferred(); + + manager.stop = vi.fn(() => stopDeferred.promise); + manager.start = vi.fn(async () => ({ success: true, pid: 4242 })); + + const firstRestart = manager.restart(); + const secondRestart = manager.restart(); + + expect(manager.stop).toHaveBeenCalledTimes(1); + expect(manager.start).not.toHaveBeenCalled(); + + stopDeferred.resolve({ success: true }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(1000); + + await expect(Promise.all([firstRestart, secondRestart])).resolves.toEqual([ + { success: true, pid: 4242 }, + { success: true, pid: 4242 }, + ]); + + expect(manager.stop).toHaveBeenCalledTimes(1); + expect(manager.start).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/chat-event-normalizer.test.ts b/tests/chat-event-normalizer.test.ts new file mode 100644 index 0000000..5eecde6 --- /dev/null +++ b/tests/chat-event-normalizer.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { ChatEventBus } from '../src/admin/chat/event-bus.js'; +import { ChatEventNormalizer } from '../src/admin/chat/event-normalizer.js'; + +describe('ChatEventNormalizer', () => { + it('不应把用户消息的 text part 增量发布成 assistant 回复,但应同步用户 message_start', () => { + const bus = new ChatEventBus(); + const normalizer = new ChatEventNormalizer(bus); + + (normalizer as any).onPartUpdated({ + part: { + id: 'part-user-1', + type: 'text', + sessionID: 'session-1', + messageID: 'message-user-1', + text: '请回复OK', + }, + delta: '请回复OK', + }); + + (normalizer as any).onMessageUpdated({ + id: 'message-user-1', + sessionID: 'session-1', + role: 'user', + time: { created: 1 }, + agent: 'chat', + model: { + providerID: 'openai', + modelID: 'gpt-5.4', + }, + }); + + expect(bus.snapshot('session-1').map(item => item.event)).toEqual([ + { + type: 'message_start', + msg: { + id: 'message-user-1', + role: 'user', + createdAt: 1000, + model: { + providerId: 'openai', + modelId: 'gpt-5.4', + }, + agent: 'chat', + }, + }, + ]); + }); + + it('assistant part 先到时,应在 message.updated 后回放到同一条 assistant 消息', () => { + const bus = new ChatEventBus(); + const normalizer = new ChatEventNormalizer(bus); + + (normalizer as any).onPartUpdated({ + part: { + id: 'part-assistant-1', + type: 'text', + sessionID: 'session-2', + messageID: 'message-assistant-1', + text: 'OK', + }, + delta: 'OK', + }); + + (normalizer as any).onMessageUpdated({ + id: 'message-assistant-1', + sessionID: 'session-2', + role: 'assistant', + time: { created: 1 }, + parentID: 'message-user-1', + }); + + const events = bus.snapshot('session-2').map(item => item.event); + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + type: 'message_start', + msg: { + id: 'message-assistant-1', + role: 'assistant', + parentId: 'message-user-1', + }, + }); + expect(events[1]).toEqual({ + type: 'text_delta', + msgId: 'message-assistant-1', + text: 'OK', + }); + }); + + it('user message.updated 应发布 message_start,供前端对齐 optimistic user', () => { + const bus = new ChatEventBus(); + const normalizer = new ChatEventNormalizer(bus); + + (normalizer as any).onMessageUpdated({ + id: 'message-user-2', + sessionID: 'session-3', + role: 'user', + time: { created: 2 }, + agent: 'chat', + model: { + providerID: 'openai', + modelID: 'gpt-5.4', + }, + }); + + const events = bus.snapshot('session-3').map(item => item.event); + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: 'message_start', + msg: { + id: 'message-user-2', + role: 'user', + createdAt: 2000, + model: { + providerId: 'openai', + modelId: 'gpt-5.4', + }, + agent: 'chat', + }, + }); + }); +}); diff --git a/tests/directory-policy.test.ts b/tests/directory-policy.test.ts index ca21046..4165d4a 100644 --- a/tests/directory-policy.test.ts +++ b/tests/directory-policy.test.ts @@ -345,6 +345,91 @@ describe('DirectoryPolicy - Path Normalization and Security', () => { }); }); + describe('resolve - AI 工作区脱钩白名单(scope: workspace)', () => { + it('scope=workspace 时 explicit 路径不再要求白名单', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-bridge-ws-')); + try { + const result = DirectoryPolicy.resolve({ + explicitDirectory: tempDir, + allowedDirectories: [], + scope: 'workspace', + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.source).toBe('explicit'); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('scope=workspace 时 explicit 路径可在白名单之外', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-bridge-ws-')); + try { + const result = DirectoryPolicy.resolve({ + explicitDirectory: tempDir, + allowedDirectories: ['/non/matching/allowlist'], + scope: 'workspace', + }); + + expect(result.ok).toBe(true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('scope=workspace 仍应拦截危险路径', () => { + if (process.platform !== 'win32') { + const result = DirectoryPolicy.resolve({ + explicitDirectory: '/etc/test', + scope: 'workspace', + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe('dangerous_path'); + } + } + }); + + it('scope=workspace 仍应拒绝相对路径', () => { + const result = DirectoryPolicy.resolve({ + explicitDirectory: './relative', + scope: 'workspace', + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe('not_absolute'); + } + }); + + it('scope=workspace 仍应拒绝不存在的目录', () => { + const result = DirectoryPolicy.resolve({ + explicitDirectory: '/tmp/this-path-should-not-exist-xyz-opencode-test', + scope: 'workspace', + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(['not_found', 'not_accessible']).toContain(result.code); + } + }); + + it('默认 scope=platform 保持原有强制行为(回归)', () => { + const result = DirectoryPolicy.resolve({ + explicitDirectory: '/explicit', + allowedDirectories: [], + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe('explicit_requires_allowlist'); + } + }); + }); + describe('isAllowedPath', () => { it('应该返回 false 当允许目录列表为空', () => { const result = DirectoryPolicy.isAllowedPath('/any/path', []); diff --git a/tests/feishu-client-stop-reset.test.ts b/tests/feishu-client-stop-reset.test.ts new file mode 100644 index 0000000..10734b2 --- /dev/null +++ b/tests/feishu-client-stop-reset.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { feishuClient } from '../src/feishu/client.js'; + +type InternalFeishuClient = typeof feishuClient & { + eventDispatcher: object; + cardActionHandler?: (event: unknown) => Promise; + cardUpdateQueue: Map>; + handleCardAction: (event: unknown) => Promise; +}; + +describe('FeishuClient stop state reset', () => { + const internalClient = feishuClient as InternalFeishuClient; + + beforeEach(() => { + vi.restoreAllMocks(); + feishuClient.removeAllListeners('cardAction'); + }); + + afterEach(() => { + feishuClient.removeAllListeners('cardAction'); + internalClient.stop(); + }); + + it('stop 应重置卡片处理器、更新队列与事件分发器', async () => { + const previousDispatcher = internalClient.eventDispatcher; + const previousHandler = vi.fn(async () => ({ msg: 'handled' })); + const cardActionSpy = vi.fn(); + + feishuClient.on('cardAction', cardActionSpy); + internalClient.setCardActionHandler(previousHandler); + internalClient.cardUpdateQueue.set('msg-1', Promise.resolve(true)); + + internalClient.stop(); + + expect(internalClient.eventDispatcher).not.toBe(previousDispatcher); + expect(internalClient.cardActionHandler).toBeUndefined(); + expect(internalClient.cardUpdateQueue.size).toBe(0); + + const response = await internalClient.handleCardAction({ + operator: { open_id: 'ou_test_user' }, + action: { tag: 'button', value: { action: 'restart' } }, + token: 'card-token', + open_message_id: 'om_msg_1', + open_chat_id: 'oc_chat_1', + open_thread_id: 'ot_thread_1', + }); + + expect(previousHandler).not.toHaveBeenCalled(); + expect(cardActionSpy).toHaveBeenCalledTimes(1); + expect(cardActionSpy).toHaveBeenCalledWith(expect.objectContaining({ + openId: 'ou_test_user', + messageId: 'om_msg_1', + chatId: 'oc_chat_1', + threadId: 'ot_thread_1', + })); + expect(response).toEqual({ msg: 'ok' }); + }); +}); diff --git a/tests/feishu-streamer-terminal-visibility.test.ts b/tests/feishu-streamer-terminal-visibility.test.ts new file mode 100644 index 0000000..b32f46d --- /dev/null +++ b/tests/feishu-streamer-terminal-visibility.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const sendCard = vi.fn<(chatId: string, card: object) => Promise>(); +const updateCard = vi.fn<(messageId: string, card: object) => Promise>(); +const deleteMessage = vi.fn<(messageId: string) => Promise>(); +const buildStreamCard = vi.fn((state: object) => ({ ...state })); + +vi.mock('../src/feishu/client.js', () => ({ + feishuClient: { + sendCard, + updateCard, + deleteMessage, + }, +})); + +vi.mock('../src/feishu/cards-stream.js', () => ({ + buildStreamCard, +})); + +describe('Feishu streamer terminal visibility', () => { + beforeEach(() => { + vi.clearAllMocks(); + sendCard.mockResolvedValue('final-message-id'); + updateCard.mockResolvedValue(true); + deleteMessage.mockResolvedValue(true); + }); + + it('处理中先发送初始卡片,完成时额外发送最终可见卡片并删除旧卡', async () => { + sendCard.mockResolvedValueOnce('stream-message-id').mockResolvedValueOnce('final-message-id'); + + const { CardStreamer } = await import('../src/feishu/streamer.js'); + const streamer = new CardStreamer('oc_chat_123'); + + await streamer.start(); + streamer.updateText('最终答案'); + streamer.setStatus('completed'); + await vi.waitFor(() => expect(sendCard).toHaveBeenCalledTimes(2)); + + expect(updateCard).toHaveBeenCalledWith( + 'stream-message-id', + expect.objectContaining({ + text: '最终答案', + status: 'completed', + }) + ); + expect(sendCard).toHaveBeenNthCalledWith( + 2, + 'oc_chat_123', + expect.objectContaining({ + text: '最终答案', + status: 'completed', + }) + ); + expect(deleteMessage).toHaveBeenCalledTimes(1); + expect(deleteMessage).toHaveBeenCalledWith('stream-message-id'); + + streamer.close(); + }); + + it('终态重复刷新时只发送一次最终可见卡片并只删除一次旧卡', async () => { + sendCard.mockResolvedValueOnce('stream-message-id').mockResolvedValue('final-message-id'); + + const { CardStreamer } = await import('../src/feishu/streamer.js'); + const streamer = new CardStreamer('oc_chat_456'); + + await streamer.start(); + streamer.setStatus('failed'); + await vi.waitFor(() => expect(sendCard).toHaveBeenCalledTimes(2)); + + streamer.setStatus('failed'); + await Promise.resolve(); + + expect(sendCard).toHaveBeenCalledTimes(2); + expect(deleteMessage).toHaveBeenCalledTimes(1); + expect(deleteMessage).toHaveBeenCalledWith('stream-message-id'); + expect(sendCard).toHaveBeenLastCalledWith( + 'oc_chat_456', + expect.objectContaining({ status: 'failed' }) + ); + + streamer.close(); + }); + + it('删除旧卡失败时不影响最终卡片发送', async () => { + sendCard.mockResolvedValueOnce('stream-message-id').mockResolvedValueOnce('final-message-id'); + deleteMessage.mockResolvedValueOnce(false); + + const { CardStreamer } = await import('../src/feishu/streamer.js'); + const streamer = new CardStreamer('oc_chat_789'); + + await streamer.start(); + streamer.setStatus('completed'); + await vi.waitFor(() => expect(sendCard).toHaveBeenCalledTimes(2)); + + expect(deleteMessage).toHaveBeenCalledTimes(1); + expect(deleteMessage).toHaveBeenCalledWith('stream-message-id'); + expect(sendCard).toHaveBeenLastCalledWith( + 'oc_chat_789', + expect.objectContaining({ status: 'completed' }) + ); + + streamer.close(); + }); +}); diff --git a/tests/router/resources.test.ts b/tests/router/resources.test.ts new file mode 100644 index 0000000..42df795 --- /dev/null +++ b/tests/router/resources.test.ts @@ -0,0 +1,562 @@ +/** + * Resources API REST End-to-End Tests + * + * 测试 REST API 端点的 HTTP 状态码和请求/响应格式。 + * 由于这些是集成测试,需要 mock 底层 registry 以避免文件系统依赖。 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { createResourcesRoutes } from '../../src/admin/routes/resources.js'; + +// Mock registries +vi.mock('../../src/services/resources/skills/registry.js', () => ({ + skillRegistry: { + list: vi.fn(), + get: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + toggle: vi.fn(), + listSlashCommands: vi.fn(), + }, +})); + +vi.mock('../../src/services/resources/mcp/manager.js', () => ({ + getMCPRegistry: vi.fn(() => ({ + list: vi.fn(), + get: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + toggle: vi.fn(), + })), +})); + +vi.mock('../../src/services/resources/agents/manager.js', () => ({ + getAgentRegistry: vi.fn(() => ({ + list: vi.fn(), + get: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + toggle: vi.fn(), + })), +})); + +vi.mock('../../src/services/resources/providers/manager.js', () => ({ + getProviderRegistry: vi.fn(() => ({ + list: vi.fn(), + get: vi.fn(), + setKey: vi.fn(), + removeKey: vi.fn(), + refreshModels: vi.fn(), + getModels: vi.fn(), + getAllModels: vi.fn(), + })), +})); + +vi.mock('../../src/services/resources/events.js', () => ({ + onResourceChange: vi.fn(() => vi.fn()), +})); + +import { skillRegistry } from '../../src/services/resources/skills/registry.js'; +import { getMCPRegistry } from '../../src/services/resources/mcp/manager.js'; +import { getAgentRegistry } from '../../src/services/resources/agents/manager.js'; +import { getProviderRegistry } from '../../src/services/resources/providers/manager.js'; + +describe('Resources API - Skills Endpoints', () => { + let app: express.Express; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/api/resources', createResourcesRoutes()); + vi.clearAllMocks(); + }); + + describe('GET /api/resources/skills', () => { + it('应返回 200 和技能列表', async () => { + const mockSkills = [ + { name: 'test-skill', description: 'Test', enabled: true }, + ]; + vi.mocked(skillRegistry.list).mockReturnValue(mockSkills); + + const response = await request(app) + .get('/api/resources/skills') + .expect(200); + + expect(response.body).toEqual({ resources: mockSkills }); + expect(skillRegistry.list).toHaveBeenCalledTimes(1); + }); + + it('应在 registry 抛错时返回 500', async () => { + vi.mocked(skillRegistry.list).mockImplementation(() => { + throw new Error('Registry error'); + }); + + const response = await request(app) + .get('/api/resources/skills') + .expect(500); + + expect(response.body.error).toContain('Registry error'); + }); + }); + + describe('GET /api/resources/skills/:name', () => { + it('应返回 200 和技能详情', async () => { + const mockSkill = { name: 'test-skill', description: 'Test', enabled: true }; + vi.mocked(skillRegistry.get).mockReturnValue(mockSkill); + + const response = await request(app) + .get('/api/resources/skills/test-skill') + .expect(200); + + expect(response.body).toEqual({ skill: mockSkill }); + }); + + it('应在不存在的技能时返回 404', async () => { + vi.mocked(skillRegistry.get).mockReturnValue(null); + + const response = await request(app) + .get('/api/resources/skills/nonexistent') + .expect(404); + + expect(response.body.error).toContain('not found'); + }); + }); + + describe('POST /api/resources/skills', () => { + it('应在缺少 name 时返回 400', async () => { + const response = await request(app) + .post('/api/resources/skills') + .send({ markdown: '# Test' }) + .expect(400); + + expect(response.body.error).toContain('name'); + }); + + it('应在缺少 markdown 时返回 400', async () => { + const response = await request(app) + .post('/api/resources/skills') + .send({ name: 'test' }) + .expect(400); + + expect(response.body.error).toContain('markdown'); + }); + + it('应在成功创建时返回 201', async () => { + const mockSkill = { name: 'test-skill', description: 'Test', enabled: true }; + vi.mocked(skillRegistry.create).mockReturnValue(mockSkill); + + const response = await request(app) + .post('/api/resources/skills') + .send({ + name: 'test-skill', + markdown: '# Test', + }) + .expect(201); + + expect(response.body).toEqual({ skill: mockSkill }); + }); + }); + + describe('PUT /api/resources/skills/:name', () => { + it('应在缺少 markdown 时返回 400', async () => { + const response = await request(app) + .put('/api/resources/skills/test') + .send({}) + .expect(400); + + expect(response.body.error).toContain('markdown'); + }); + + it('应在成功更新时返回 200', async () => { + const mockSkill = { name: 'test', description: 'Updated', enabled: true }; + vi.mocked(skillRegistry.update).mockReturnValue(mockSkill); + + const response = await request(app) + .put('/api/resources/skills/test') + .send({ markdown: '# Updated' }) + .expect(200); + + expect(response.body).toEqual({ skill: mockSkill }); + }); + }); + + describe('DELETE /api/resources/skills/:name', () => { + it('应在成功删除时返回 200', async () => { + vi.mocked(skillRegistry.delete).mockImplementation(() => {}); + + const response = await request(app) + .delete('/api/resources/skills/test') + .expect(200); + + expect(response.body.ok).toBe(true); + expect(response.body.message).toContain('deleted'); + }); + }); + + describe('POST /api/resources/skills/:name/toggle', () => { + it('应在缺少 enabled 时返回 400', async () => { + const response = await request(app) + .post('/api/resources/skills/test/toggle') + .send({}) + .expect(400); + + expect(response.body.error).toContain('enabled'); + }); + + it('应在成功切换时返回 200', async () => { + const mockSkill = { name: 'test', description: 'Test', enabled: false }; + vi.mocked(skillRegistry.toggle).mockReturnValue(mockSkill); + + const response = await request(app) + .post('/api/resources/skills/test/toggle') + .send({ enabled: false }) + .expect(200); + + expect(response.body).toEqual({ + skill: mockSkill, + message: 'Skill "test" disabled', + }); + }); + }); + + describe('GET /api/resources/skills/slash', () => { + it('应返回 200 和 slash 命令列表', async () => { + const mockCommands = [ + { name: 'test', description: 'Test command' }, + ]; + vi.mocked(skillRegistry.listSlashCommands).mockReturnValue(mockCommands); + + const response = await request(app) + .get('/api/resources/skills/slash') + .expect(200); + + expect(response.body).toEqual({ commands: mockCommands }); + }); + }); +}); + +describe('Resources API - MCP Endpoints', () => { + let app: express.Express; + let mockMCPRegistry: any; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/api/resources', createResourcesRoutes()); + vi.clearAllMocks(); + + mockMCPRegistry = { + list: vi.fn(), + get: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + toggle: vi.fn(), + }; + vi.mocked(getMCPRegistry).mockReturnValue(mockMCPRegistry); + }); + + describe('GET /api/resources/mcp', () => { + it('应返回 200 和 MCP 服务器列表', async () => { + const mockServers = [ + { name: 'test-server', transport: 'stdio', enabled: true, order: 100 }, + ]; + mockMCPRegistry.list.mockReturnValue(mockServers); + + const response = await request(app) + .get('/api/resources/mcp') + .expect(200); + + expect(response.body).toEqual({ resources: mockServers }); + }); + }); + + describe('GET /api/resources/mcp/:name', () => { + it('应返回 200 和服务器详情', async () => { + const mockServer = { + name: 'test-server', + transport: 'stdio', + command: 'node', + enabled: true, + order: 100, + }; + mockMCPRegistry.get.mockReturnValue(mockServer); + + const response = await request(app) + .get('/api/resources/mcp/test-server') + .expect(200); + + expect(response.body).toEqual({ server: mockServer }); + }); + + it('应在不存在的服务器时返回 404', async () => { + mockMCPRegistry.get.mockReturnValue(null); + + await request(app) + .get('/api/resources/mcp/nonexistent') + .expect(404); + }); + }); + + describe('POST /api/resources/mcp', () => { + it('应在缺少 name 时返回 400', async () => { + const response = await request(app) + .post('/api/resources/mcp') + .send({ transport: 'stdio' }) + .expect(400); + + expect(response.body.error).toContain('name'); + }); + + it('应在无效 transport 时返回 400', async () => { + const response = await request(app) + .post('/api/resources/mcp') + .send({ name: 'test', transport: 'invalid' }) + .expect(400); + + expect(response.body.error).toContain('transport'); + }); + + it('应在 stdio 缺少 command 时返回 400', async () => { + const response = await request(app) + .post('/api/resources/mcp') + .send({ name: 'test', transport: 'stdio' }) + .expect(400); + + expect(response.body.error).toContain('command'); + }); + + it('应在成功创建时返回 201', async () => { + const mockServer = { + name: 'test-server', + transport: 'stdio', + command: 'node', + enabled: true, + order: 100, + }; + mockMCPRegistry.create.mockResolvedValue(mockServer); + + const response = await request(app) + .post('/api/resources/mcp') + .send({ + name: 'test-server', + transport: 'stdio', + command: 'node', + }) + .expect(201); + + expect(response.body).toEqual({ server: mockServer }); + }); + }); + + describe('DELETE /api/resources/mcp/:name', () => { + it('应在成功删除时返回 200', async () => { + mockMCPRegistry.delete.mockResolvedValue(undefined); + + const response = await request(app) + .delete('/api/resources/mcp/test') + .expect(200); + + expect(response.body.ok).toBe(true); + }); + }); +}); + +describe('Resources API - Agents Endpoints', () => { + let app: express.Express; + let mockAgentRegistry: any; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/api/resources', createResourcesRoutes()); + vi.clearAllMocks(); + + mockAgentRegistry = { + list: vi.fn(), + get: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + toggle: vi.fn(), + }; + vi.mocked(getAgentRegistry).mockReturnValue(mockAgentRegistry); + }); + + describe('GET /api/resources/agents', () => { + it('应返回 200 和 Agent 列表', async () => { + const mockAgents = [ + { name: 'test-agent', description: 'Test', enabled: true, order: 100 }, + ]; + mockAgentRegistry.list.mockReturnValue(mockAgents); + + const response = await request(app) + .get('/api/resources/agents') + .expect(200); + + expect(response.body).toEqual({ resources: mockAgents }); + }); + }); + + describe('POST /api/resources/agents', () => { + it('应在缺少 name 时返回 400', async () => { + const response = await request(app) + .post('/api/resources/agents') + .send({}) + .expect(400); + + expect(response.body.error).toContain('name'); + }); + + it('应在成功创建时返回 201', async () => { + const mockAgent = { + name: 'test-agent', + description: 'Test', + enabled: true, + order: 100, + }; + mockAgentRegistry.create.mockResolvedValue(mockAgent); + + const response = await request(app) + .post('/api/resources/agents') + .send({ name: 'test-agent' }) + .expect(201); + + expect(response.body).toEqual({ agent: mockAgent }); + }); + }); +}); + +describe('Resources API - Providers Endpoints', () => { + let app: express.Express; + let mockProviderRegistry: any; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/api/resources', createResourcesRoutes()); + vi.clearAllMocks(); + + mockProviderRegistry = { + list: vi.fn(), + get: vi.fn(), + setKey: vi.fn(), + removeKey: vi.fn(), + refreshModels: vi.fn(), + getModels: vi.fn(), + getAllModels: vi.fn(), + }; + vi.mocked(getProviderRegistry).mockReturnValue(mockProviderRegistry); + }); + + describe('GET /api/resources/providers', () => { + it('应返回 200 和 Provider 列表', async () => { + const mockProviders = [ + { providerId: 'openai', type: 'api', configured: true }, + ]; + mockProviderRegistry.list.mockReturnValue(mockProviders); + + const response = await request(app) + .get('/api/resources/providers') + .expect(200); + + expect(response.body).toEqual({ resources: mockProviders }); + }); + }); + + describe('GET /api/resources/providers/:id', () => { + it('应返回 200 和 Provider 详情', async () => { + const mockProvider = { type: 'api', key: 'sk-test' }; + mockProviderRegistry.get.mockReturnValue(mockProvider); + + const response = await request(app) + .get('/api/resources/providers/openai') + .expect(200); + + // API key 应该被脱敏 + expect(response.body.provider.key).toBe('••••••••'); + }); + + it('应在不存在的 provider 时返回 404', async () => { + mockProviderRegistry.get.mockReturnValue(null); + + await request(app) + .get('/api/resources/providers/nonexistent') + .expect(404); + }); + }); + + describe('PUT /api/resources/providers/:id', () => { + it('应在缺少 key 时返回 400', async () => { + const response = await request(app) + .put('/api/resources/providers/openai') + .send({}) + .expect(400); + + expect(response.body.error).toContain('key'); + }); + + it('应在成功设置时返回 200', async () => { + mockProviderRegistry.setKey.mockResolvedValue(undefined); + + const response = await request(app) + .put('/api/resources/providers/openai') + .send({ key: 'sk-test' }) + .expect(200); + + expect(response.body.ok).toBe(true); + }); + }); + + describe('GET /api/resources/providers/:id/models', () => { + it('应返回 200 和模型列表', async () => { + const mockModels = ['gpt-4', 'gpt-3.5-turbo']; + mockProviderRegistry.getModels.mockReturnValue(mockModels); + + const response = await request(app) + .get('/api/resources/providers/openai/models') + .expect(200); + + expect(response.body).toEqual({ + providerId: 'openai', + models: mockModels, + }); + }); + }); +}); + +describe('Resources API - Stats Endpoint', () => { + let app: express.Express; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/api/resources', createResourcesRoutes()); + vi.clearAllMocks(); + + vi.mocked(skillRegistry.list).mockReturnValue([]); + vi.mocked(getMCPRegistry).mockReturnValue({ list: vi.fn(() => []) }); + vi.mocked(getAgentRegistry).mockReturnValue({ list: vi.fn(() => []) }); + vi.mocked(getProviderRegistry).mockReturnValue({ list: vi.fn(() => []) }); + }); + + describe('GET /api/resources/stats', () => { + it('应返回 200 和资源统计', async () => { + const response = await request(app) + .get('/api/resources/stats') + .expect(200); + + expect(response.body).toMatchObject({ + skills: expect.any(Number), + mcp: expect.any(Number), + agents: expect.any(Number), + providers: expect.any(Number), + }); + }); + }); +}); diff --git a/tests/services/resources/agents.test.ts b/tests/services/resources/agents.test.ts new file mode 100644 index 0000000..c288f57 --- /dev/null +++ b/tests/services/resources/agents.test.ts @@ -0,0 +1,460 @@ +/** + * Agent Manager 单元测试 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { AgentRegistry } from '../../../src/services/resources/agents/manager.js'; +import type { AgentInput } from '../../../src/services/resources/agents/types.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.join(__dirname, '.fixtures-agents'); + +describe('Agent Manager', () => { + let registry: AgentRegistry; + + beforeEach(async () => { + // 清理并创建临时目录 + await fs.rm(FIXTURE_DIR, { recursive: true, force: true }); + await fs.mkdir(FIXTURE_DIR, { recursive: true }); + + // 创建子目录 + await fs.mkdir(path.join(FIXTURE_DIR, 'project', 'agents'), { recursive: true }); + await fs.mkdir(path.join(FIXTURE_DIR, 'user', 'agents'), { recursive: true }); + + // 设置环境变量指向临时目录(同时设置项目级和用户级) + process.env.OPENCODE_BRIDGE_DATA_ROOT = path.join(FIXTURE_DIR, 'project'); + process.env.OPENCODE_BRIDGE_USER_DATA_ROOT = path.join(FIXTURE_DIR, 'user'); + + registry = new AgentRegistry(); + }); + + afterEach(async () => { + await registry.dispose(); + await fs.rm(FIXTURE_DIR, { recursive: true, force: true }); + delete process.env.OPENCODE_BRIDGE_DATA_ROOT; + delete process.env.OPENCODE_BRIDGE_USER_DATA_ROOT; + }); + + describe('Agent 配置解析', () => { + it('应该正确解析有效的 agent 配置', async () => { + const agentConfig: AgentInput = { + description: '测试助手', + mode: 'primary', + prompt: '你是一个测试助手', + tools: { bash: true, read: true }, + enabled: true, + }; + + await registry.init(); + const created = await registry.create('test-agent', agentConfig, 'project'); + + expect(created.name).toBe('test-agent'); + expect(created.description).toBe('测试助手'); + expect(created.mode).toBe('primary'); + expect(created.prompt).toBe('你是一个测试助手'); + expect(created.tools).toEqual({ bash: true, read: true }); + expect(created.enabled).toBe(true); + }); + + it('应该正确解析最小配置(仅必填字段)', async () => { + await registry.init(); + const created = await registry.create('minimal', { + enabled: true, + }, 'project'); + + expect(created.name).toBe('minimal'); + expect(created.enabled).toBe(true); + expect(created.description).toBeUndefined(); + expect(created.mode).toBeUndefined(); + }); + + it('应该拒绝 name 与文件名不一致的配置', async () => { + const filePath = path.join(FIXTURE_DIR, 'project', 'agents', 'bad-name.json'); + await fs.writeFile(filePath, JSON.stringify({ + name: 'different-name', + enabled: true, + order: 1, + }), 'utf-8'); + + await registry.init(); + const list = registry.list(); + + const badAgent = list.find(a => a.name === 'bad-name'); + expect(badAgent?.valid).toBe(false); + expect(badAgent?.error).toContain('不一致'); + }); + + it('应该拒绝缺少必填字段的配置', async () => { + const filePath = path.join(FIXTURE_DIR, 'project', 'agents', 'missing-fields.json'); + await fs.writeFile(filePath, JSON.stringify({ + name: 'missing-fields', + // 缺少 enabled + order: 1, + }), 'utf-8'); + + await registry.init(); + const list = registry.list(); + + const badAgent = list.find(a => a.name === 'missing-fields'); + expect(badAgent?.valid).toBe(false); + expect(badAgent?.error).toContain('enabled'); + }); + + it('应该拒绝无效的 mode 值', async () => { + const filePath = path.join(FIXTURE_DIR, 'project', 'agents', 'invalid-mode.json'); + await fs.writeFile(filePath, JSON.stringify({ + name: 'invalid-mode', + enabled: true, + mode: 'invalid', + order: 1, + }), 'utf-8'); + + await registry.init(); + const list = registry.list(); + + const badAgent = list.find(a => a.name === 'invalid-mode'); + expect(badAgent?.valid).toBe(false); + expect(badAgent?.error).toContain('mode'); + }); + }); + + describe('双层覆盖(project > user)', () => { + it('项目级 agent 应该遮蔽用户级同名 agent', async () => { + await registry.init(); + + // 先创建 user 层 + await registry.create('shadow-test', { + description: 'User layer', + mode: 'primary', + enabled: true, + }, 'user'); + + // 再创建 project 层 + await registry.create('shadow-test', { + description: 'Project layer', + mode: 'subagent', + enabled: false, + }, 'project'); + + const list = registry.list(); + const shadowAgent = list.find(a => a.name === 'shadow-test' && a.scope === 'project'); + + expect(shadowAgent).toBeDefined(); + expect(shadowAgent?.description).toBe('Project layer'); + expect(shadowAgent?.enabled).toBe(false); + + // user 层条目标记为 shadowed + const userAgent = list.find(a => a.name === 'shadow-test' && a.scope === 'user'); + expect(userAgent?.shadowed).toBe(true); + }); + + it('get(name) 默认返回 winning(project 层)', async () => { + await registry.init(); + + await registry.create('override-test', { + description: 'User layer', + mode: 'primary', + enabled: true, + }, 'user'); + + await registry.create('override-test', { + description: 'Project layer', + mode: 'subagent', + enabled: false, + }, 'project'); + + const winning = registry.get('override-test'); + expect(winning?.description).toBe('Project layer'); + }); + + it('get(name, scope) 可以显式获取指定 scope', async () => { + await registry.init(); + + await registry.create('scope-test', { + description: 'User layer', + mode: 'primary', + enabled: true, + }, 'user'); + + await registry.create('scope-test', { + description: 'Project layer', + mode: 'subagent', + enabled: false, + }, 'project'); + + const userConfig = registry.get('scope-test', 'user'); + const projectConfig = registry.get('scope-test', 'project'); + + expect(userConfig?.description).toBe('User layer'); + expect(projectConfig?.description).toBe('Project layer'); + }); + }); + + describe('CRUD 操作', () => { + it('应该正确创建 agent', async () => { + await registry.init(); + const input: AgentInput = { + description: '新建助手', + mode: 'subagent', + enabled: true, + }; + + const created = await registry.create('new-agent', input, 'project'); + + expect(created.name).toBe('new-agent'); + expect(created.description).toBe('新建助手'); + expect(created.mode).toBe('subagent'); + expect(created.order).toBeGreaterThan(0); + }); + + it('应该正确更新 agent', async () => { + await registry.init(); + + await registry.create('update-test', { + description: '旧描述', + mode: 'primary', + enabled: false, + }, 'project'); + + const updated = await registry.update('update-test', { + description: '新描述', + mode: 'subagent', + }, 'project'); + + expect(updated.description).toBe('新描述'); + expect(updated.mode).toBe('subagent'); + expect(updated.enabled).toBe(false); // 保持不变 + }); + + it('应该正确删除 agent', async () => { + await registry.init(); + + await registry.create('delete-test', { + enabled: true, + }, 'project'); + + await registry.delete('delete-test', 'project'); + + const list = registry.list(); + expect(list.find(a => a.name === 'delete-test')).toBeUndefined(); + }); + + it('创建已存在的 agent 应该抛出错误', async () => { + await registry.init(); + + await registry.create('dup-test', { + enabled: true, + }, 'project'); + + await expect( + registry.create('dup-test', { + enabled: true, + }, 'project') + ).rejects.toThrow('已存在'); + }); + + it('更新不存在的 agent 应该抛出错误', async () => { + await registry.init(); + + await expect( + registry.update('nonexistent', { enabled: true }, 'project') + ).rejects.toThrow('不存在'); + }); + + it('删除不存在的 agent 应该抛出错误', async () => { + await registry.init(); + + await expect( + registry.delete('nonexistent', 'project') + ).rejects.toThrow('不存在'); + }); + + it('toggle 应该正确切换 enabled 状态', async () => { + await registry.init(); + + await registry.create('toggle-test', { + enabled: true, + }, 'project'); + + const disabled = await registry.toggle('toggle-test', false); + expect(disabled.enabled).toBe(false); + + const enabled = await registry.toggle('toggle-test', true); + expect(enabled.enabled).toBe(true); + }); + }); + + describe('order 排序', () => { + it('list 应该按 order 排序', async () => { + await registry.init(); + + await registry.create('agent-a', { + enabled: true, + order: 30, + }, 'project'); + + await registry.create('agent-b', { + enabled: true, + order: 10, + }, 'project'); + + await registry.create('agent-c', { + enabled: true, + order: 20, + }, 'project'); + + const list = registry.list(); + const enabledAgents = list.filter(a => a.enabled); + + expect(enabledAgents[0].name).toBe('agent-b'); + expect(enabledAgents[1].name).toBe('agent-c'); + expect(enabledAgents[2].name).toBe('agent-a'); + }); + + it('新建 agent 时应该自动分配递增的 order', async () => { + await registry.init(); + + await registry.create('first', { + enabled: true, + }, 'project'); + + await registry.create('second', { + enabled: true, + }, 'project'); + + const list = registry.list(); + const first = list.find(a => a.name === 'first'); + const second = list.find(a => a.name === 'second'); + + expect(second?.order).toBeGreaterThan(first?.order ?? 0); + }); + }); + + describe('热载', () => { + it('文件变更后应该自动重载', async () => { + await registry.init(); + + const created = await registry.create('hot-test', { + description: '原始描述', + enabled: true, + }, 'project'); + + // 直接修改文件 + const filePath = path.join(FIXTURE_DIR, 'project', 'agents', 'hot-test.json'); + await fs.writeFile(filePath, JSON.stringify({ + ...created, + description: '修改后描述', + }, null, 2), 'utf-8'); + + // 等待热载(去抖 200ms + 安全余量) + await new Promise(resolve => setTimeout(resolve, 500)); + + const updated = registry.get('hot-test'); + expect(updated?.description).toBe('修改后描述'); + }); + + it('删除文件后应该自动从列表移除', async () => { + await registry.init(); + + await registry.create('hot-delete-test', { + enabled: true, + }, 'project'); + + // 删除文件 + const filePath = path.join(FIXTURE_DIR, 'project', 'agents', 'hot-delete-test.json'); + await fs.unlink(filePath); + + // 等待热载 + await new Promise(resolve => setTimeout(resolve, 500)); + + const list = registry.list(); + expect(list.find(a => a.name === 'hot-delete-test')).toBeUndefined(); + }); + }); + + describe('name 校验', () => { + it('应该拒绝包含非法字符的 name', async () => { + await registry.init(); + + await expect( + registry.create('bad/name', { + enabled: true, + }, 'project') + ).rejects.toThrow(); + }); + + it('应该拒绝空 name', async () => { + await registry.init(); + + await expect( + registry.create('', { + enabled: true, + }, 'project') + ).rejects.toThrow(); + }); + }); + + describe('导出为 OpenCode 格式', () => { + it('应该正确导出 winning 且 enabled 的 agent', async () => { + await registry.init(); + + await registry.create('agent-1', { + description: 'Agent 1', + mode: 'primary', + enabled: true, + }, 'project'); + + await registry.create('agent-2', { + description: 'Agent 2', + mode: 'subagent', + enabled: false, // 禁用,不应导出 + }, 'project'); + + await registry.create('agent-3', { + description: 'Agent 3', + mode: 'subagent', + enabled: true, + }, 'user'); + + const exported = registry.exportForOpenCode(); + + expect(Object.keys(exported)).toHaveLength(2); + expect(exported['agent-1']).toEqual({ + description: 'Agent 1', + mode: 'primary', + }); + expect(exported['agent-3']).toEqual({ + description: 'Agent 3', + mode: 'subagent', + }); + expect(exported['agent-2']).toBeUndefined(); + }); + + it('project 层 agent 应该覆盖 user 层', async () => { + await registry.init(); + + await registry.create('dup-agent', { + description: 'User layer', + mode: 'primary', + enabled: true, + }, 'user'); + + await registry.create('dup-agent', { + description: 'Project layer', + mode: 'subagent', + enabled: true, + }, 'project'); + + const exported = registry.exportForOpenCode(); + + expect(Object.keys(exported)).toHaveLength(1); + expect(exported['dup-agent']).toEqual({ + description: 'Project layer', + mode: 'subagent', + }); + }); + }); +}); diff --git a/tests/services/resources/events.test.ts b/tests/services/resources/events.test.ts new file mode 100644 index 0000000..0bc4572 --- /dev/null +++ b/tests/services/resources/events.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + clearResourceListeners, + emitResourceChange, + onResourceChange, +} from '../../../src/services/resources/events.js'; + +describe('resources/events', () => { + afterEach(() => clearResourceListeners()); + + it('delivers events to subscribers', () => { + const handler = vi.fn(); + const unsub = onResourceChange(handler); + + emitResourceChange('skill', 'add', { name: 'demo', scope: 'project' }); + + expect(handler).toHaveBeenCalledTimes(1); + const ev = handler.mock.calls[0][0]; + expect(ev.kind).toBe('skill'); + expect(ev.action).toBe('add'); + expect(ev.name).toBe('demo'); + expect(ev.scope).toBe('project'); + expect(typeof ev.at).toBe('number'); + + unsub(); + emitResourceChange('skill', 'remove', { name: 'demo' }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('defaults name to null when omitted', () => { + const handler = vi.fn(); + onResourceChange(handler); + emitResourceChange('mcp', 'reload'); + expect(handler.mock.calls[0][0].name).toBeNull(); + }); + + it('clearResourceListeners removes all listeners', () => { + const handler = vi.fn(); + onResourceChange(handler); + clearResourceListeners(); + emitResourceChange('agent', 'add'); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/services/resources/mcp.test.ts b/tests/services/resources/mcp.test.ts new file mode 100644 index 0000000..b98225d --- /dev/null +++ b/tests/services/resources/mcp.test.ts @@ -0,0 +1,497 @@ +/** + * MCP Manager 单元测试 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { MCPRegistry } from '../../../src/services/resources/mcp/manager.js'; +import type { MCPInput } from '../../../src/services/resources/mcp/types.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.join(__dirname, '.fixtures-mcp'); + +describe('MCP Manager', () => { + let registry: MCPRegistry; + + beforeEach(async () => { + // 清理并创建临时目录 + await fs.rm(FIXTURE_DIR, { recursive: true, force: true }); + await fs.mkdir(FIXTURE_DIR, { recursive: true }); + + // 创建子目录 + await fs.mkdir(path.join(FIXTURE_DIR, 'project', 'mcp'), { recursive: true }); + await fs.mkdir(path.join(FIXTURE_DIR, 'user', 'mcp'), { recursive: true }); + + // 设置环境变量指向临时目录(同时设置项目级和用户级) + process.env.OPENCODE_BRIDGE_DATA_ROOT = path.join(FIXTURE_DIR, 'project'); + process.env.OPENCODE_BRIDGE_USER_DATA_ROOT = path.join(FIXTURE_DIR, 'user'); + + registry = new MCPRegistry(); + }); + + afterEach(async () => { + await registry.dispose(); + await fs.rm(FIXTURE_DIR, { recursive: true, force: true }); + delete process.env.OPENCODE_BRIDGE_DATA_ROOT; + delete process.env.OPENCODE_BRIDGE_USER_DATA_ROOT; + }); + + describe('MCP 配置解析', () => { + it('应该正确解析有效的 stdio 配置', async () => { + const serverConfig: MCPInput = { + transport: 'stdio', + command: 'npx', + args: ['@modelcontextprotocol/server-filesystem', '/path'], + enabled: true, + description: 'Filesystem MCP', + }; + + await registry.init(); + const created = await registry.create('fs-test', serverConfig, 'project'); + + expect(created.name).toBe('fs-test'); + expect(created.transport).toBe('stdio'); + expect(created.command).toBe('npx'); + expect(created.args).toEqual(['@modelcontextprotocol/server-filesystem', '/path']); + expect(created.enabled).toBe(true); + expect(created.description).toBe('Filesystem MCP'); + }); + + it('应该正确解析有效的 sse 配置', async () => { + const serverConfig: MCPInput = { + transport: 'sse', + url: 'https://example.com/mcp', + enabled: true, + description: 'SSE MCP', + }; + + await registry.init(); + const created = await registry.create('sse-test', serverConfig, 'project'); + + expect(created.transport).toBe('sse'); + expect(created.url).toBe('https://example.com/mcp'); + expect(created.enabled).toBe(true); + }); + + it('应该正确解析有效的 http 配置', async () => { + const serverConfig: MCPInput = { + transport: 'http', + url: 'https://example.com/mcp', + enabled: false, + headers: { Authorization: 'Bearer token' }, + }; + + await registry.init(); + const created = await registry.create('http-test', serverConfig, 'project'); + + expect(created.transport).toBe('http'); + expect(created.url).toBe('https://example.com/mcp'); + expect(created.enabled).toBe(false); + expect(created.headers).toEqual({ Authorization: 'Bearer token' }); + }); + + it('应该拒绝 name 与文件名不一致的配置', async () => { + const filePath = path.join(FIXTURE_DIR, 'project', 'mcp', 'bad-name.json'); + await fs.writeFile(filePath, JSON.stringify({ + name: 'different-name', + transport: 'stdio', + command: 'test', + enabled: true, + order: 1, + }), 'utf-8'); + + await registry.init(); + const list = registry.list(); + + const badServer = list.find(s => s.name === 'bad-name'); + expect(badServer?.valid).toBe(false); + expect(badServer?.error).toContain('不一致'); + }); + + it('应该拒绝缺少必填字段的配置', async () => { + const filePath = path.join(FIXTURE_DIR, 'project', 'mcp', 'missing-fields.json'); + await fs.writeFile(filePath, JSON.stringify({ + name: 'missing-fields', + transport: 'stdio', + // 缺少 command + enabled: true, + order: 1, + }), 'utf-8'); + + await registry.init(); + const list = registry.list(); + + const badServer = list.find(s => s.name === 'missing-fields'); + expect(badServer?.valid).toBe(false); + expect(badServer?.error).toContain('command'); + }); + }); + + describe('索引管理', () => { + it('创建 server 时应该自动更新索引', async () => { + await registry.init(); + + await registry.create('server1', { + transport: 'stdio', + command: 'test1', + enabled: true, + }, 'project'); + + await registry.create('server2', { + transport: 'stdio', + command: 'test2', + enabled: false, + }, 'project'); + + // 读取索引文件 + const indexPath = path.join(FIXTURE_DIR, 'project', 'mcp', '_index.json'); + const indexContent = JSON.parse(await fs.readFile(indexPath, 'utf-8')); + + expect(indexContent.enabled).toContain('server1'); + expect(indexContent.disabled).toContain('server2'); + }); + + it('切换 enabled 状态时应该更新索引', async () => { + await registry.init(); + + await registry.create('toggle-test', { + transport: 'stdio', + command: 'test', + enabled: true, + }, 'project'); + + // 禁用 + await registry.toggle('toggle-test', false); + + const indexPath = path.join(FIXTURE_DIR, 'project', 'mcp', '_index.json'); + const indexContent = JSON.parse(await fs.readFile(indexPath, 'utf-8')); + + expect(indexContent.enabled).not.toContain('toggle-test'); + expect(indexContent.disabled).toContain('toggle-test'); + }); + + it('删除 server 时应该从索引移除', async () => { + await registry.init(); + + await registry.create('delete-test', { + transport: 'stdio', + command: 'test', + enabled: true, + }, 'project'); + + await registry.delete('delete-test', 'project'); + + const indexPath = path.join(FIXTURE_DIR, 'project', 'mcp', '_index.json'); + const indexContent = JSON.parse(await fs.readFile(indexPath, 'utf-8')); + + expect(indexContent.enabled).not.toContain('delete-test'); + expect(indexContent.disabled).not.toContain('delete-test'); + }); + }); + + describe('双层覆盖(project > user)', () => { + it('项目级 server 应该遮蔽用户级同名 server', async () => { + await registry.init(); + + // 先创建 user 层 + await registry.create('shadow-test', { + transport: 'stdio', + command: 'user-command', + enabled: true, + description: 'User layer', + }, 'user'); + + // 再创建 project 层 + await registry.create('shadow-test', { + transport: 'stdio', + command: 'project-command', + enabled: false, + description: 'Project layer', + }, 'project'); + + const list = registry.list(); + const shadowServer = list.find(s => s.name === 'shadow-test' && s.scope === 'project'); + + expect(shadowServer).toBeDefined(); + expect(shadowServer?.description).toBe('Project layer'); + expect(shadowServer?.enabled).toBe(false); + + // user 层条目标记为 shadowed + const userServer = list.find(s => s.name === 'shadow-test' && s.scope === 'user'); + expect(userServer?.shadowed).toBe(true); + }); + + it('get(name) 默认返回 winning(project 层)', async () => { + await registry.init(); + + await registry.create('override-test', { + transport: 'stdio', + command: 'user-command', + enabled: true, + }, 'user'); + + await registry.create('override-test', { + transport: 'stdio', + command: 'project-command', + enabled: false, + }, 'project'); + + const winning = registry.get('override-test'); + expect(winning?.command).toBe('project-command'); + }); + + it('get(name, scope) 可以显式获取指定 scope', async () => { + await registry.init(); + + await registry.create('scope-test', { + transport: 'stdio', + command: 'user-command', + enabled: true, + }, 'user'); + + await registry.create('scope-test', { + transport: 'stdio', + command: 'project-command', + enabled: false, + }, 'project'); + + const userConfig = registry.get('scope-test', 'user'); + const projectConfig = registry.get('scope-test', 'project'); + + expect(userConfig?.command).toBe('user-command'); + expect(projectConfig?.command).toBe('project-command'); + }); + }); + + describe('CRUD 操作', () => { + it('应该正确创建 server', async () => { + await registry.init(); + const input: MCPInput = { + transport: 'stdio', + command: 'new-server', + enabled: true, + description: 'New server', + }; + + const created = await registry.create('new-server', input, 'project'); + + expect(created.name).toBe('new-server'); + expect(created.description).toBe('New server'); + expect(created.order).toBeGreaterThan(0); + }); + + it('应该正确更新 server', async () => { + await registry.init(); + + await registry.create('update-test', { + transport: 'stdio', + command: 'old-command', + enabled: false, + }, 'project'); + + const updated = await registry.update('update-test', { + command: 'new-command', + enabled: true, + }, 'project'); + + expect(updated.command).toBe('new-command'); + expect(updated.enabled).toBe(true); + }); + + it('应该正确删除 server', async () => { + await registry.init(); + + await registry.create('delete-test', { + transport: 'stdio', + command: 'test', + enabled: true, + }, 'project'); + + await registry.delete('delete-test', 'project'); + + const list = registry.list(); + expect(list.find(s => s.name === 'delete-test')).toBeUndefined(); + }); + + it('创建已存在的 server 应该抛出错误', async () => { + await registry.init(); + + await registry.create('dup-test', { + transport: 'stdio', + command: 'test', + enabled: true, + }, 'project'); + + await expect( + registry.create('dup-test', { + transport: 'stdio', + command: 'test2', + enabled: true, + }, 'project') + ).rejects.toThrow('已存在'); + }); + + it('更新不存在的 server 应该抛出错误', async () => { + await registry.init(); + + await expect( + registry.update('nonexistent', { enabled: true }, 'project') + ).rejects.toThrow('不存在'); + }); + + it('删除不存在的 server 应该抛出错误', async () => { + await registry.init(); + + await expect( + registry.delete('nonexistent', 'project') + ).rejects.toThrow('不存在'); + }); + }); + + describe('order 排序', () => { + it('list 应该按 order 排序', async () => { + await registry.init(); + + await registry.create('server-a', { + transport: 'stdio', + command: 'a', + enabled: true, + order: 30, + }, 'project'); + + await registry.create('server-b', { + transport: 'stdio', + command: 'b', + enabled: true, + order: 10, + }, 'project'); + + await registry.create('server-c', { + transport: 'stdio', + command: 'c', + enabled: true, + order: 20, + }, 'project'); + + const list = registry.list(); + const enabledServers = list.filter(s => s.enabled); + + expect(enabledServers[0].name).toBe('server-b'); + expect(enabledServers[1].name).toBe('server-c'); + expect(enabledServers[2].name).toBe('server-a'); + }); + + it('新建 server 时应该自动分配递增的 order', async () => { + await registry.init(); + + await registry.create('first', { + transport: 'stdio', + command: 'first', + enabled: true, + }, 'project'); + + await registry.create('second', { + transport: 'stdio', + command: 'second', + enabled: true, + }, 'project'); + + const list = registry.list(); + const first = list.find(s => s.name === 'first'); + const second = list.find(s => s.name === 'second'); + + expect(second?.order).toBeGreaterThan(first?.order ?? 0); + }); + }); + + describe('热载', () => { + it('文件变更后应该自动重载', async () => { + await registry.init(); + + const created = await registry.create('hot-test', { + transport: 'stdio', + command: 'original', + enabled: true, + }, 'project'); + + // 直接修改文件 + const filePath = path.join(FIXTURE_DIR, 'project', 'mcp', 'hot-test.json'); + await fs.writeFile(filePath, JSON.stringify({ + ...created, + command: 'modified', + }, null, 2), 'utf-8'); + + // 等待热载(去抖 200ms + 安全余量) + await new Promise(resolve => setTimeout(resolve, 800)); + + const updated = registry.get('hot-test'); + expect(updated?.command).toBe('modified'); + }); + + it('删除文件后应该自动从列表移除', async () => { + await registry.init(); + + await registry.create('hot-delete-test', { + transport: 'stdio', + command: 'test', + enabled: true, + }, 'project'); + + // 删除文件 + const filePath = path.join(FIXTURE_DIR, 'project', 'mcp', 'hot-delete-test.json'); + await fs.unlink(filePath); + + // 等待热载 + await new Promise(resolve => setTimeout(resolve, 800)); + + const list = registry.list(); + expect(list.find(s => s.name === 'hot-delete-test')).toBeUndefined(); + }); + }); + + describe('name 校验', () => { + it('应该拒绝包含非法字符的 name', async () => { + await registry.init(); + + await expect( + registry.create('bad/name', { + transport: 'stdio', + command: 'test', + enabled: true, + }, 'project') + ).rejects.toThrow(); + }); + + it('应该拒绝空 name', async () => { + await registry.init(); + + await expect( + registry.create('', { + transport: 'stdio', + command: 'test', + enabled: true, + }, 'project') + ).rejects.toThrow(); + }); + }); + + describe('环境变量与工作目录', () => { + it('应该正确保存 stdio 的 env 和 cwd', async () => { + await registry.init(); + + const created = await registry.create('env-test', { + transport: 'stdio', + command: 'node', + args: ['server.js'], + cwd: '/workspace', + env: { API_KEY: 'secret', NODE_ENV: 'production' }, + enabled: true, + }, 'project'); + + expect(created.cwd).toBe('/workspace'); + expect(created.env).toEqual({ API_KEY: 'secret', NODE_ENV: 'production' }); + }); + }); +}); diff --git a/tests/services/resources/paths.test.ts b/tests/services/resources/paths.test.ts new file mode 100644 index 0000000..facf081 --- /dev/null +++ b/tests/services/resources/paths.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs'; + +import { + assertValidResourceName, + ensureAllProjectDirs, + ensureResourceDir, + getProjectDataRoot, + getResourceDir, + getResourceDirs, + getUserDataRoot, + isValidResourceName, + locateResource, +} from '../../../src/services/resources/paths.js'; + +const TMP_PREFIX = path.join(os.tmpdir(), 'opencode-bridge-paths-test-'); + +describe('resources/paths', () => { + let projectRoot: string; + let userRoot: string; + let originalProj: string | undefined; + let originalUser: string | undefined; + + beforeEach(() => { + projectRoot = fs.mkdtempSync(TMP_PREFIX + 'proj-'); + userRoot = fs.mkdtempSync(TMP_PREFIX + 'user-'); + originalProj = process.env.OPENCODE_BRIDGE_DATA_ROOT; + originalUser = process.env.OPENCODE_BRIDGE_USER_DATA_ROOT; + process.env.OPENCODE_BRIDGE_DATA_ROOT = projectRoot; + process.env.OPENCODE_BRIDGE_USER_DATA_ROOT = userRoot; + }); + + afterEach(() => { + if (originalProj === undefined) delete process.env.OPENCODE_BRIDGE_DATA_ROOT; + else process.env.OPENCODE_BRIDGE_DATA_ROOT = originalProj; + if (originalUser === undefined) delete process.env.OPENCODE_BRIDGE_USER_DATA_ROOT; + else process.env.OPENCODE_BRIDGE_USER_DATA_ROOT = originalUser; + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(userRoot, { recursive: true, force: true }); + }); + + it('resolves project & user roots from env', () => { + expect(getProjectDataRoot()).toBe(path.resolve(projectRoot)); + expect(getUserDataRoot()).toBe(path.resolve(userRoot)); + }); + + it('maps each kind to the correct subdirectory', () => { + expect(getResourceDir('skill', 'project')).toBe(path.join(projectRoot, 'skills')); + expect(getResourceDir('mcp', 'user')).toBe(path.join(userRoot, 'mcp')); + expect(getResourceDir('agents', 'project')).toBe(path.join(projectRoot, 'agents')); + expect(getResourceDir('provider', 'user')).toBe(path.join(userRoot, 'providers')); + + const both = getResourceDirs('skill'); + expect(both.project).toBe(path.join(projectRoot, 'skills')); + expect(both.user).toBe(path.join(userRoot, 'skills')); + }); + + it('creates project dirs idempotently', () => { + const skillsDir = getResourceDir('skill', 'project'); + expect(fs.existsSync(skillsDir)).toBe(false); + expect(ensureResourceDir('skill', 'project')).toBe(true); + expect(fs.existsSync(skillsDir)).toBe(true); + expect(ensureResourceDir('skill', 'project')).toBe(false); // already exists + + ensureAllProjectDirs(); + for (const sub of ['skills', 'mcp', 'agents', 'providers']) { + expect(fs.existsSync(path.join(projectRoot, sub))).toBe(true); + } + }); + + it('locateResource prefers project scope over user scope', () => { + const projDir = getResourceDir('mcp', 'project'); + const userDir = getResourceDir('mcp', 'user'); + fs.mkdirSync(projDir, { recursive: true }); + fs.mkdirSync(userDir, { recursive: true }); + + fs.writeFileSync(path.join(userDir, 'github.json'), '{}'); + expect(locateResource('mcp', 'github.json')).toEqual({ + scope: 'user', + absPath: path.join(userDir, 'github.json'), + }); + + fs.writeFileSync(path.join(projDir, 'github.json'), '{}'); + expect(locateResource('mcp', 'github.json')).toEqual({ + scope: 'project', + absPath: path.join(projDir, 'github.json'), + }); + + expect(locateResource('mcp', 'missing.json')).toBeNull(); + }); + + it('validates resource names', () => { + expect(isValidResourceName('my-skill_1')).toBe(true); + expect(isValidResourceName('a')).toBe(true); + expect(isValidResourceName('')).toBe(false); + expect(isValidResourceName('has space')).toBe(false); + expect(isValidResourceName('../escape')).toBe(false); + expect(isValidResourceName('a'.repeat(65))).toBe(false); + + expect(() => assertValidResourceName('ok-1')).not.toThrow(); + expect(() => assertValidResourceName('../bad')).toThrow(/Invalid resource name/); + }); +}); diff --git a/tests/services/resources/providers.test.ts b/tests/services/resources/providers.test.ts new file mode 100644 index 0000000..f250b3f --- /dev/null +++ b/tests/services/resources/providers.test.ts @@ -0,0 +1,444 @@ +/** + * Provider Manager 单元测试 + * + * 这些测试专注于文件系统交互和配置管理,不依赖外部 opencode 命令。 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { ProviderRegistry } from '../../../src/services/resources/providers/manager.js'; +import type { OpenCodeAuthConfig } from '../../../src/services/resources/providers/types.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.join(__dirname, '.fixtures-providers'); + +describe('Provider Manager', () => { + let registry: ProviderRegistry; + + beforeEach(async () => { + // 清理并创建临时目录 + await fs.rm(FIXTURE_DIR, { recursive: true, force: true }); + await fs.mkdir(FIXTURE_DIR, { recursive: true }); + + // 设置临时 auth.json 路径 + const tempAuthPath = path.join(FIXTURE_DIR, 'auth.json'); + process.env.OPENCODE_AUTH_PATH = tempAuthPath; + + // 创建初始 auth.json + await fs.writeFile(tempAuthPath, JSON.stringify({ + 'test-provider': { + type: 'api', + key: 'sk-test-key', + }, + 'oauth-provider': { + type: 'oauth', + access: 'test-access-token', + refresh: 'test-refresh-token', + expires: 1234567890, + }, + }, null, 2), 'utf-8'); + }); + + afterEach(async () => { + await registry?.dispose(); + await fs.rm(FIXTURE_DIR, { recursive: true, force: true }); + delete process.env.OPENCODE_AUTH_PATH; + }); + + describe('初始化', () => { + it('应该正确读取 auth.json', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + const testProvider = registry.get('test-provider'); + expect(testProvider).toBeDefined(); + expect(testProvider?.type).toBe('api'); + if (testProvider?.type === 'api') { + expect(testProvider.key).toBe('sk-test-key'); + } + }); + + it('auth.json 不存在时应该返回空配置', async () => { + // 删除 auth.json + const tempAuthPath = path.join(FIXTURE_DIR, 'auth.json'); + await fs.unlink(tempAuthPath); + + registry = new ProviderRegistry(); + await registry.init(); + + const list = registry.list(); + expect(list.length).toBe(0); + }); + + it('auth.json 格式错误时应该返回空配置', async () => { + // 写入无效 JSON + const tempAuthPath = path.join(FIXTURE_DIR, 'auth.json'); + await fs.writeFile(tempAuthPath, '{invalid json', 'utf-8'); + + registry = new ProviderRegistry(); + await registry.init(); + + const list = registry.list(); + expect(list.length).toBe(0); + }); + + it('多次调用 init 应该幂等', async () => { + registry = new ProviderRegistry(); + + await registry.init(); + await registry.init(); + + const list = registry.list(); + expect(list.length).toBe(2); // 两个初始 provider + }); + }); + + describe('list / get', () => { + it('应该正确列出所有 provider', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + const list = registry.list(); + + expect(list.length).toBe(2); + + const apiProvider = list.find(p => p.providerId === 'test-provider'); + expect(apiProvider).toBeDefined(); + expect(apiProvider?.type).toBe('api'); + expect(apiProvider?.configured).toBe(true); + expect(apiProvider?.editable).toBe(true); + + const oauthProvider = list.find(p => p.providerId === 'oauth-provider'); + expect(oauthProvider).toBeDefined(); + expect(oauthProvider?.type).toBe('oauth'); + expect(oauthProvider?.configured).toBe(true); + expect(oauthProvider?.editable).toBe(false); + }); + + it('应该正确获取 API provider', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + const provider = registry.get('test-provider'); + + expect(provider).toBeDefined(); + expect(provider?.type).toBe('api'); + if (provider?.type === 'api') { + expect(provider.key).toBe('sk-test-key'); + } + }); + + it('应该正确获取 OAuth provider', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + const provider = registry.get('oauth-provider'); + + expect(provider).toBeDefined(); + expect(provider?.type).toBe('oauth'); + if (provider?.type === 'oauth') { + expect(provider.access).toBe('test-access-token'); + expect(provider.refresh).toBe('test-refresh-token'); + expect(provider.expires).toBe(1234567890); + } + }); + + it('获取不存在的 provider 应该返回 null', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + const provider = registry.get('nonexistent'); + expect(provider).toBeNull(); + }); + }); + + describe('setKey', () => { + it('应该正确设置新的 API Key', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + await registry.setKey('new-provider', 'sk-new-key'); + + const provider = registry.get('new-provider'); + expect(provider).toBeDefined(); + expect(provider?.type).toBe('api'); + if (provider?.type === 'api') { + expect(provider.key).toBe('sk-new-key'); + } + + // 验证已写入文件 + const tempAuthPath = path.join(FIXTURE_DIR, 'auth.json'); + const content = await fs.readFile(tempAuthPath, 'utf-8'); + const authConfig = JSON.parse(content) as OpenCodeAuthConfig; + + expect(authConfig['new-provider']).toBeDefined(); + expect(authConfig['new-provider'].type).toBe('api'); + if (authConfig['new-provider'].type === 'api') { + expect(authConfig['new-provider'].key).toBe('sk-new-key'); + } + }); + + it('应该更新现有 provider 的 API Key', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + await registry.setKey('test-provider', 'sk-updated-key'); + + const provider = registry.get('test-provider'); + expect(provider).toBeDefined(); + if (provider?.type === 'api') { + expect(provider.key).toBe('sk-updated-key'); + } + }); + + it('应该拒绝覆盖 OAuth 类型的 provider', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + await expect( + registry.setKey('oauth-provider', 'sk-new-key') + ).rejects.toThrow('OAuth'); + }); + + it('setKey 后 list 应该包含新 provider', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + await registry.setKey('another-provider', 'sk-another-key'); + + const list = registry.list(); + const newProvider = list.find(p => p.providerId === 'another-provider'); + + expect(newProvider).toBeDefined(); + expect(newProvider?.type).toBe('api'); + expect(newProvider?.configured).toBe(true); + }); + + it('setKey 应该原子性写入文件(不破坏原有内容)', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + await registry.setKey('atomic-test', 'sk-atomic-key'); + + // 验证原有数据未被破坏 + const testProvider = registry.get('test-provider'); + expect(testProvider).toBeDefined(); + if (testProvider?.type === 'api') { + expect(testProvider.key).toBe('sk-test-key'); + } + + const oauthProvider = registry.get('oauth-provider'); + expect(oauthProvider).toBeDefined(); + }); + }); + + describe('removeKey', () => { + it('应该正确删除 API provider', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + await registry.removeKey('test-provider'); + + const provider = registry.get('test-provider'); + expect(provider).toBeNull(); + + // 验证已从文件删除 + const tempAuthPath = path.join(FIXTURE_DIR, 'auth.json'); + const content = await fs.readFile(tempAuthPath, 'utf-8'); + const authConfig = JSON.parse(content) as OpenCodeAuthConfig; + + expect(authConfig['test-provider']).toBeUndefined(); + }); + + it('删除后 list 不应包含该 provider', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + await registry.removeKey('test-provider'); + + const list = registry.list(); + const deleted = list.find(p => p.providerId === 'test-provider'); + + expect(deleted).toBeUndefined(); + expect(list.length).toBe(1); // 只剩 oauth-provider + }); + + it('删除不存在的 provider 应该抛出错误', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + await expect( + registry.removeKey('nonexistent') + ).rejects.toThrow('不存在'); + }); + + it('应该拒绝删除 OAuth 类型的 provider', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + await expect( + registry.removeKey('oauth-provider') + ).rejects.toThrow('OAuth'); + }); + + it('删除应该原子性写入文件(不破坏原有内容)', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + await registry.removeKey('test-provider'); + + // 验证 OAuth provider 未被破坏 + const oauthProvider = registry.get('oauth-provider'); + expect(oauthProvider).toBeDefined(); + }); + }); + + describe('模型缓存', () => { + it('初始时模型缓存应该为空', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + const openaiModels = registry.getModels('openai'); + expect(openaiModels).toEqual([]); + + const allModels = registry.getAllModels(); + expect(allModels).toEqual([]); + }); + + it('getModels 对于不存在的 provider 应该返回空数组', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + const models = registry.getModels('nonexistent'); + expect(models).toEqual([]); + }); + + it('refreshModels 应该不抛出错误(即使 opencode 命令失败)', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + // refreshModels 会尝试执行 opencode models,但测试环境可能没有 opencode + // 应该不抛出错误,只是返回空缓存或缓存当前可用模型 + await registry.refreshModels(); + + const models = registry.getAllModels(); + expect(Array.isArray(models)).toBe(true); + }, 60000); // 60秒超时(opencode models 可能很慢) + }); + + describe('isConfigured', () => { + it('已配置的 API provider 应返回 true', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + expect(registry.isConfigured('test-provider')).toBe(true); + }); + + it('已配置的 OAuth provider 应返回 true', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + expect(registry.isConfigured('oauth-provider')).toBe(true); + }); + + it('不存在的 provider 应返回 false', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + expect(registry.isConfigured('nonexistent')).toBe(false); + }); + + it('删除后应返回 false', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + expect(registry.isConfigured('test-provider')).toBe(true); + + await registry.removeKey('test-provider'); + + expect(registry.isConfigured('test-provider')).toBe(false); + }); + }); + + describe('dispose', () => { + it('dispose 后应该清空模型缓存', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + await registry.dispose(); + + // dispose 后模型缓存应该被清空 + const models = registry.getAllModels(); + expect(models).toEqual([]); + }); + + it('dispose 后再次 init 应该抛出错误', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + await registry.dispose(); + + await expect(registry.init()).rejects.toThrow('已释放'); + }); + + it('dispose 后再次 dispose 应该安全(幂等)', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + await registry.dispose(); + await registry.dispose(); // 不应该抛出错误 + }); + }); + + describe('边界情况', () => { + it('应该正确处理空的 auth.json', async () => { + const tempAuthPath = path.join(FIXTURE_DIR, 'auth.json'); + await fs.writeFile(tempAuthPath, '{}', 'utf-8'); + + registry = new ProviderRegistry(); + await registry.init(); + + const list = registry.list(); + expect(list.length).toBe(0); + }); + + it('应该正确处理包含额外字段的 provider', async () => { + const tempAuthPath = path.join(FIXTURE_DIR, 'auth.json'); + await fs.writeFile(tempAuthPath, JSON.stringify({ + 'custom-provider': { + type: 'api', + key: 'sk-custom', + extraField: 'should be preserved', + }, + }, null, 2), 'utf-8'); + + registry = new ProviderRegistry(); + await registry.init(); + + const provider = registry.get('custom-provider'); + expect(provider).toBeDefined(); + expect(provider?.type).toBe('api'); + + // 额外字段应该被保留 + const content = await fs.readFile(tempAuthPath, 'utf-8'); + const authConfig = JSON.parse(content) as OpenCodeAuthConfig; + expect(authConfig['custom-provider']).toHaveProperty('extraField'); + }); + + it('应该正确处理特殊字符的 provider ID', async () => { + registry = new ProviderRegistry(); + await registry.init(); + + // provider ID 可以包含字母、数字、连字符、下划线 + await registry.setKey('test_provider-123', 'sk-key'); + + const provider = registry.get('test_provider-123'); + expect(provider).toBeDefined(); + if (provider?.type === 'api') { + expect(provider.key).toBe('sk-key'); + } + }); + }); +}); diff --git a/tests/services/resources/skills.test.ts b/tests/services/resources/skills.test.ts new file mode 100644 index 0000000..0a41052 --- /dev/null +++ b/tests/services/resources/skills.test.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs'; + +import { + parseSkillDir, + scanSkillsInScope, + serializeSkillMarkdown, +} from '../../../src/services/resources/skills/loader.js'; +import { skillRegistry } from '../../../src/services/resources/skills/registry.js'; +import { onResourceChange, clearResourceListeners } from '../../../src/services/resources/events.js'; + +const TMP_PREFIX = path.join(os.tmpdir(), 'opencode-bridge-skills-test-'); + +/** 写一个最小可解析的 SKILL.md。 */ +function writeSkill( + dir: string, + name: string, + opts: { + description?: string; + enabled?: boolean; + version?: string; + allowedTools?: string[]; + body?: string; + } = {}, +): void { + fs.mkdirSync(dir, { recursive: true }); + const fmLines = [ + '---', + `name: ${name}`, + `description: ${opts.description ?? `desc for ${name}`}`, + ]; + if (opts.version) fmLines.push(`version: ${opts.version}`); + if (opts.allowedTools) { + fmLines.push('allowed-tools:'); + for (const t of opts.allowedTools) fmLines.push(` - ${t}`); + } + if (opts.enabled === false) fmLines.push('enabled: false'); + fmLines.push('---', '', opts.body ?? `# ${name}\n\nbody`); + fs.writeFileSync(path.join(dir, 'SKILL.md'), fmLines.join('\n'), 'utf-8'); +} + +describe('resources/skills/loader', () => { + let projectRoot: string; + + beforeEach(() => { + projectRoot = fs.mkdtempSync(TMP_PREFIX + 'load-'); + }); + afterEach(() => { + fs.rmSync(projectRoot, { recursive: true, force: true }); + }); + + it('parseSkillDir parses a valid skill', () => { + const dir = path.join(projectRoot, 'skills', 'hello'); + writeSkill(dir, 'hello', { + description: 'Greets the user', + version: '1.0.0', + allowedTools: ['bash', 'read'], + }); + const r = parseSkillDir(dir, 'project'); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.skill.name).toBe('hello'); + expect(r.skill.frontmatter.description).toBe('Greets the user'); + expect(r.skill.frontmatter.version).toBe('1.0.0'); + expect(r.skill.frontmatter.allowedTools).toEqual(['bash', 'read']); + expect(r.skill.frontmatter.enabled).toBe(true); + expect(r.skill.body).toContain('# hello'); + expect(r.skill.scope).toBe('project'); + } + }); + + it('parseSkillDir reports missing SKILL.md', () => { + const dir = path.join(projectRoot, 'skills', 'broken'); + fs.mkdirSync(dir, { recursive: true }); + const r = parseSkillDir(dir, 'project'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.message).toMatch(/缺少 SKILL.md/); + }); + + it('parseSkillDir rejects mismatched name', () => { + const dir = path.join(projectRoot, 'skills', 'dirname'); + writeSkill(dir, 'different'); + const r = parseSkillDir(dir, 'project'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.message).toMatch(/与目录名/); + }); + + it('parseSkillDir requires non-empty description', () => { + const dir = path.join(projectRoot, 'skills', 'nodescr'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'SKILL.md'), + '---\nname: nodescr\ndescription: ""\n---\nbody', + 'utf-8', + ); + const r = parseSkillDir(dir, 'project'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.message).toMatch(/description/); + }); + + it('scanSkillsInScope collects valid + invalid entries', () => { + const root = path.join(projectRoot, 'skills'); + writeSkill(path.join(root, 'a'), 'a'); + writeSkill(path.join(root, 'b'), 'b', { enabled: false }); + fs.mkdirSync(path.join(root, 'c-broken'), { recursive: true }); // 缺 SKILL.md + const { skills, errors } = scanSkillsInScope(root, 'project'); + expect(skills.map((s) => s.name).sort()).toEqual(['a', 'b']); + expect(errors).toHaveLength(1); + expect(errors[0]!.name).toBe('c-broken'); + }); + + it('serializeSkillMarkdown round-trips', () => { + const dir = path.join(projectRoot, 'skills', 'rt'); + writeSkill(dir, 'rt', { description: 'd', allowedTools: ['x'] }); + const r = parseSkillDir(dir, 'project'); + expect(r.ok).toBe(true); + if (r.ok) { + const md = serializeSkillMarkdown(r.skill.frontmatter, r.skill.body); + expect(md).toMatch(/name: rt/); + expect(md).toMatch(/description: d/); + expect(md).toMatch(/allowed-tools:/); + } + }); +}); + +describe('resources/skills/registry', () => { + let projectRoot: string; + let userRoot: string; + let originalProj: string | undefined; + let originalUser: string | undefined; + + beforeEach(async () => { + projectRoot = fs.mkdtempSync(TMP_PREFIX + 'reg-proj-'); + userRoot = fs.mkdtempSync(TMP_PREFIX + 'reg-user-'); + originalProj = process.env.OPENCODE_BRIDGE_DATA_ROOT; + originalUser = process.env.OPENCODE_BRIDGE_USER_DATA_ROOT; + process.env.OPENCODE_BRIDGE_DATA_ROOT = projectRoot; + process.env.OPENCODE_BRIDGE_USER_DATA_ROOT = userRoot; + // 强制重置:dispose 可能清空状态,再 init + await skillRegistry.dispose(); + clearResourceListeners(); + }); + + afterEach(async () => { + await skillRegistry.dispose(); + clearResourceListeners(); + if (originalProj === undefined) delete process.env.OPENCODE_BRIDGE_DATA_ROOT; + else process.env.OPENCODE_BRIDGE_DATA_ROOT = originalProj; + if (originalUser === undefined) delete process.env.OPENCODE_BRIDGE_USER_DATA_ROOT; + else process.env.OPENCODE_BRIDGE_USER_DATA_ROOT = originalUser; + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(userRoot, { recursive: true, force: true }); + }); + + it('init scans both scopes and project shadows user with same name', () => { + writeSkill(path.join(userRoot, 'skills', 'shared'), 'shared', { description: 'user-shared' }); + writeSkill(path.join(userRoot, 'skills', 'only-user'), 'only-user', { description: 'only-u' }); + writeSkill(path.join(projectRoot, 'skills', 'shared'), 'shared', { description: 'proj-shared' }); + + skillRegistry.init(); + + const list = skillRegistry.list(); + const byKey = new Map(list.map((s) => [`${s.scope}:${s.name}`, s])); + expect(byKey.get('project:shared')!.shadowed).toBe(false); + expect(byKey.get('user:shared')!.shadowed).toBe(true); + expect(byKey.get('user:only-user')!.shadowed).toBe(false); + + // get() 默认返回 winning(project)版本 + const winning = skillRegistry.get('shared'); + expect(winning?.scope).toBe('project'); + expect(winning?.frontmatter.description).toBe('proj-shared'); + + // 显式取 user 版本 + const userVer = skillRegistry.get('shared', 'user'); + expect(userVer?.frontmatter.description).toBe('user-shared'); + }); + + it('listSlashCommands skips disabled and shadowed entries', () => { + writeSkill(path.join(projectRoot, 'skills', 'a'), 'a'); + writeSkill(path.join(projectRoot, 'skills', 'b'), 'b', { enabled: false }); + writeSkill(path.join(userRoot, 'skills', 'a'), 'a', { description: 'shadowed' }); + writeSkill(path.join(userRoot, 'skills', 'c'), 'c'); + + skillRegistry.init(); + const cmds = skillRegistry.listSlashCommands(); + const commandNames = cmds.map((c) => c.command).sort(); + expect(commandNames).toEqual(['/skill:a', '/skill:c']); + const a = cmds.find((c) => c.command === '/skill:a')!; + expect(a.scope).toBe('project'); // shadowed user 版本被跳过 + }); + + it('create + update + delete + emit events', () => { + skillRegistry.init(); + const events: string[] = []; + onResourceChange((e) => { + events.push(`${e.action}:${e.name ?? '*'}`); + }); + + const created = skillRegistry.create({ + name: 'new-one', + frontmatter: { description: 'fresh', enabled: true }, + body: '# body', + }); + expect(created.scope).toBe('project'); + expect(fs.existsSync(path.join(projectRoot, 'skills', 'new-one', 'SKILL.md'))).toBe(true); + + const updated = skillRegistry.update({ + name: 'new-one', + frontmatter: { description: 'edited', enabled: false }, + }); + expect(updated.frontmatter.description).toBe('edited'); + expect(updated.frontmatter.enabled).toBe(false); + + skillRegistry.delete('new-one'); + expect(fs.existsSync(path.join(projectRoot, 'skills', 'new-one'))).toBe(false); + + expect(events).toContain('add:new-one'); + expect(events).toContain('update:new-one'); + expect(events).toContain('remove:new-one'); + }); + + it('toggle flips enabled flag and rewrites file', () => { + writeSkill(path.join(projectRoot, 'skills', 'tog'), 'tog'); + skillRegistry.init(); + const before = skillRegistry.get('tog'); + expect(before?.frontmatter.enabled).toBe(true); + + skillRegistry.toggle('tog', false); + const after = skillRegistry.get('tog'); + expect(after?.frontmatter.enabled).toBe(false); + + // 文件中应已写入 enabled: false + const md = fs.readFileSync(path.join(projectRoot, 'skills', 'tog', 'SKILL.md'), 'utf-8'); + expect(md).toMatch(/enabled: false/); + }); + + it('rejects creating duplicate skill in same scope', () => { + writeSkill(path.join(projectRoot, 'skills', 'dup'), 'dup'); + skillRegistry.init(); + expect(() => + skillRegistry.create({ + name: 'dup', + frontmatter: { description: 'x', enabled: true }, + body: '', + }), + ).toThrow(/已存在/); + }); + + it('rejects invalid resource name on create', () => { + skillRegistry.init(); + expect(() => + skillRegistry.create({ + name: '../escape', + frontmatter: { description: 'x', enabled: true }, + body: '', + }), + ).toThrow(/Invalid resource name/); + }); +}); diff --git a/tests/web-chat-model-events.test.ts b/tests/web-chat-model-events.test.ts new file mode 100644 index 0000000..f54be99 --- /dev/null +++ b/tests/web-chat-model-events.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from 'vitest'; +import { applyChatEvent, buildConversationTurns, finalizeStreamingMessages, type ChatMessageVm } from '../web/src/composables/chat-model.ts'; + +describe('web chat model event handling', () => { + it('user message_start 应把最新 optimistic user 对齐成真实消息 ID', () => { + const messages: ChatMessageVm[] = [ + { + id: 'optimistic-user-1', + role: 'user', + createdAt: 1500, + text: '请解释这个问题', + reasoning: '', + tools: [], + status: 'done', + optimistic: true, + }, + ]; + + applyChatEvent(messages, { + type: 'message_start', + msg: { + id: 'user-real-1', + role: 'user', + createdAt: 1000, + model: { providerId: 'openai', modelId: 'gpt-5.4' }, + agent: 'chat', + }, + }); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + id: 'user-real-1', + role: 'user', + text: '请解释这个问题', + optimistic: false, + model: { providerId: 'openai', modelId: 'gpt-5.4' }, + agent: 'chat', + }); + expect(messages[0].createdAt).toBe(1500); + }); + + it('assistant 实时事件时间戳较早时,仍应排在对应用户消息之后', () => { + const messages: ChatMessageVm[] = [ + { + id: 'user-1', + role: 'user', + createdAt: 1000, + text: '第一问', + reasoning: '', + tools: [], + status: 'done', + }, + { + id: 'assistant-1', + role: 'assistant', + createdAt: 1000, + text: '第一答', + reasoning: '', + tools: [], + status: 'done', + }, + { + id: 'optimistic-user-2', + role: 'user', + createdAt: 1800, + text: '第二问', + reasoning: '', + tools: [], + status: 'done', + optimistic: true, + }, + ]; + + applyChatEvent(messages, { + type: 'message_start', + msg: { + id: 'assistant-2', + role: 'assistant', + createdAt: 1000, + parentId: 'user-real-2', + }, + }); + + expect(messages.map(message => message.id)).toEqual([ + 'user-1', + 'assistant-1', + 'optimistic-user-2', + 'assistant-2', + ]); + expect(messages[3]).toMatchObject({ + id: 'assistant-2', + createdAt: 1800, + status: 'streaming', + }); + }); + + it('应按 assistant.parentId 把延迟到达的回复归到正确轮次,而不是最后一轮', () => { + const messages: ChatMessageVm[] = [ + { + id: 'user-1', + role: 'user', + createdAt: 1000, + text: '第一问', + reasoning: '', + tools: [], + status: 'done', + }, + { + id: 'user-2', + role: 'user', + createdAt: 2000, + text: '第二问', + reasoning: '', + tools: [], + status: 'done', + }, + { + id: 'assistant-late', + role: 'assistant', + createdAt: 2500, + parentId: 'user-1', + text: '第一问的延迟回复', + reasoning: '', + tools: [], + status: 'done', + }, + { + id: 'assistant-2', + role: 'assistant', + createdAt: 2600, + parentId: 'user-2', + text: '第二答', + reasoning: '', + tools: [], + status: 'done', + }, + ]; + + const turns = buildConversationTurns(messages); + + expect(turns).toHaveLength(2); + expect(turns[0].userMessage?.id).toBe('user-1'); + expect(turns[0].assistantMessages.map(message => message.id)).toEqual(['assistant-late']); + expect(turns[1].userMessage?.id).toBe('user-2'); + expect(turns[1].assistantMessages.map(message => message.id)).toEqual(['assistant-2']); + }); + + it('空白真实 user 占位不应单独渲染成窄蓝条,而应并回上一条用户消息', () => { + const messages: ChatMessageVm[] = [ + { + id: 'optimistic-user-1', + role: 'user', + createdAt: 1000, + text: '请解释这个问题', + reasoning: '', + tools: [], + status: 'done', + optimistic: true, + }, + { + id: 'user-real-1', + role: 'user', + createdAt: 1001, + text: '', + reasoning: '', + tools: [], + status: 'done', + }, + { + id: 'assistant-1', + role: 'assistant', + createdAt: 1002, + parentId: 'user-real-1', + text: '这是解释', + reasoning: '', + tools: [], + status: 'done', + }, + ]; + + const turns = buildConversationTurns(messages); + + expect(turns).toHaveLength(1); + expect(turns[0].userMessage?.id).toBe('optimistic-user-1'); + expect(turns[0].userMessage?.text).toBe('请解释这个问题'); + expect(turns[0].assistantMessages.map(message => message.id)).toEqual(['assistant-1']); + }); + + it('message_start 重放时不应清空已经收到的回复文本', () => { + const messages: ChatMessageVm[] = []; + + applyChatEvent(messages, { type: 'text_delta', msgId: 'assistant-1', text: 'Hello' }); + applyChatEvent(messages, { + type: 'message_start', + msg: { + id: 'assistant-1', + role: 'assistant', + createdAt: 123, + parentId: 'user-real-1', + model: { providerId: 'openai', modelId: 'gpt-5.4' }, + agent: 'chat', + }, + }); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + id: 'assistant-1', + text: 'Hello', + status: 'streaming', + parentId: 'user-real-1', + model: { providerId: 'openai', modelId: 'gpt-5.4' }, + agent: 'chat', + }); + expect(messages[0].createdAt).toBeGreaterThanOrEqual(123); + }); + + it('重复的 message_start 不应把已完成消息退回到 streaming', () => { + const messages: ChatMessageVm[] = []; + + applyChatEvent(messages, { + type: 'message_start', + msg: { + id: 'assistant-2', + role: 'assistant', + createdAt: 100, + }, + }); + applyChatEvent(messages, { type: 'text_delta', msgId: 'assistant-2', text: 'Done' }); + applyChatEvent(messages, { + type: 'message_end', + msgId: 'assistant-2', + finish: 'stop', + }); + + applyChatEvent(messages, { + type: 'message_start', + msg: { + id: 'assistant-2', + role: 'assistant', + createdAt: 100, + }, + }); + + expect(messages[0]).toMatchObject({ + id: 'assistant-2', + text: 'Done', + status: 'done', + finish: 'stop', + }); + }); + + it('text_delta 先到后 message_start 时,不应把 assistant 重新排到用户消息前面', () => { + const messages: ChatMessageVm[] = [ + { + id: 'optimistic-user-3', + role: 'user', + createdAt: 2500, + text: '第三问', + reasoning: '', + tools: [], + status: 'done', + optimistic: true, + }, + ]; + + applyChatEvent(messages, { type: 'text_delta', msgId: 'assistant-3', text: '第三答' }); + applyChatEvent(messages, { + type: 'message_start', + msg: { + id: 'assistant-3', + role: 'assistant', + createdAt: 2000, + }, + }); + + expect(messages.map(message => message.id)).toEqual([ + 'optimistic-user-3', + 'assistant-3', + ]); + expect(messages[1]).toMatchObject({ + id: 'assistant-3', + text: '第三答', + createdAt: expect.any(Number), + }); + expect(messages[1].createdAt).toBeGreaterThanOrEqual(messages[0].createdAt); + }); + + it('session idle 收到后应收口残留的 streaming assistant 消息', () => { + const messages: ChatMessageVm[] = [ + { + id: 'assistant-4', + role: 'assistant', + createdAt: 3000, + text: '最终回复', + reasoning: '', + tools: [], + status: 'streaming', + }, + ]; + + finalizeStreamingMessages(messages); + + expect(messages[0]).toMatchObject({ + id: 'assistant-4', + status: 'done', + text: '最终回复', + }); + }); +}); diff --git a/tests/web-chat-model-variants.test.ts b/tests/web-chat-model-variants.test.ts new file mode 100644 index 0000000..3f3e6bf --- /dev/null +++ b/tests/web-chat-model-variants.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { formatVariantLabel, resolveSupportedVariant } from '../web/src/composables/chat-model.ts'; + +describe('chat model variant helpers', () => { + it('应把供应商原始 variant 名称映射成统一显示档位', () => { + expect(formatVariantLabel('reasoning_high')).toBe('high'); + expect(formatVariantLabel('thinking_medium')).toBe('medium'); + expect(formatVariantLabel('deep')).toBe('xhigh'); + }); + + it('应把已选档位映射到当前模型真正支持的原始 variant 值', () => { + expect(resolveSupportedVariant('low', ['reasoning_low', 'reasoning_high'])).toBe('reasoning_low'); + expect(resolveSupportedVariant('thinking_high', ['high', 'max'])).toBe('high'); + expect(resolveSupportedVariant('medium', ['reasoning_low', 'reasoning_high'])).toBeUndefined(); + }); +}); diff --git a/tests/web-chat-stream-utils.test.ts b/tests/web-chat-stream-utils.test.ts new file mode 100644 index 0000000..ce7c25f --- /dev/null +++ b/tests/web-chat-stream-utils.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { parseEventSeq, resolveReplaySince } from '../web/src/composables/chat-stream-utils.ts'; + +describe('chat stream reconnect helpers', () => { + it('应从 Last-Event-ID 中提取合法事件序号', () => { + expect(parseEventSeq('5')).toBe(5); + expect(parseEventSeq('0')).toBeNull(); + expect(parseEventSeq('not-a-number')).toBeNull(); + expect(parseEventSeq(undefined)).toBeNull(); + }); + + it('仅在同一会话重连时才应携带 since 序号', () => { + expect(resolveReplaySince('session-1', 'session-1', 5)).toBe(5); + expect(resolveReplaySince('session-1', 'session-2', 5)).toBeNull(); + expect(resolveReplaySince('session-1', 'session-1', null)).toBeNull(); + }); +}); diff --git a/web/auto-imports.d.ts b/web/auto-imports.d.ts new file mode 100644 index 0000000..9d24007 --- /dev/null +++ b/web/auto-imports.d.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + +} diff --git a/web/components.d.ts b/web/components.d.ts new file mode 100644 index 0000000..8e823aa --- /dev/null +++ b/web/components.d.ts @@ -0,0 +1,74 @@ +/* eslint-disable */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + CodeBlock: typeof import('./src/components/ai-elements/CodeBlock.vue')['default'] + ConfigActionBar: typeof import('./src/components/ConfigActionBar.vue')['default'] + Conversation: typeof import('./src/components/ai-elements/Conversation.vue')['default'] + ElAlert: typeof import('element-plus/es')['ElAlert'] + ElAside: typeof import('element-plus/es')['ElAside'] + ElAvatar: typeof import('element-plus/es')['ElAvatar'] + ElBadge: typeof import('element-plus/es')['ElBadge'] + ElButton: typeof import('element-plus/es')['ElButton'] + ElCard: typeof import('element-plus/es')['ElCard'] + ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] + ElCol: typeof import('element-plus/es')['ElCol'] + ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] + ElContainer: typeof import('element-plus/es')['ElContainer'] + ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] + ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] + ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDivider: typeof import('element-plus/es')['ElDivider'] + ElEmpty: typeof import('element-plus/es')['ElEmpty'] + ElForm: typeof import('element-plus/es')['ElForm'] + ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElIcon: typeof import('element-plus/es')['ElIcon'] + ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElMain: typeof import('element-plus/es')['ElMain'] + ElMenu: typeof import('element-plus/es')['ElMenu'] + ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] + ElOption: typeof import('element-plus/es')['ElOption'] + ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup'] + ElPagination: typeof import('element-plus/es')['ElPagination'] + ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm'] + ElPopover: typeof import('element-plus/es')['ElPopover'] + ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] + ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] + ElRow: typeof import('element-plus/es')['ElRow'] + ElSegmented: typeof import('element-plus/es')['ElSegmented'] + ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] + ElSkeletonItem: typeof import('element-plus/es')['ElSkeletonItem'] + ElSwitch: typeof import('element-plus/es')['ElSwitch'] + ElTable: typeof import('element-plus/es')['ElTable'] + ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] + ElTabPane: typeof import('element-plus/es')['ElTabPane'] + ElTabs: typeof import('element-plus/es')['ElTabs'] + ElTag: typeof import('element-plus/es')['ElTag'] + ElText: typeof import('element-plus/es')['ElText'] + ElTooltip: typeof import('element-plus/es')['ElTooltip'] + FileTree: typeof import('./src/components/ai-elements/FileTree.vue')['default'] + HelpMenu: typeof import('./src/components/onboarding/HelpMenu.vue')['default'] + Markdown: typeof import('./src/components/ai-elements/Markdown.vue')['default'] + Message: typeof import('./src/components/ai-elements/Message.vue')['default'] + OnboardingWizard: typeof import('./src/components/onboarding/OnboardingWizard.vue')['default'] + QQConfig: typeof import('./src/components/QQConfig.vue')['default'] + Reasoning: typeof import('./src/components/ai-elements/Reasoning.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + Task: typeof import('./src/components/ai-elements/Task.vue')['default'] + TelegramConfig: typeof import('./src/components/TelegramConfig.vue')['default'] + Terminal: typeof import('./src/components/ai-elements/Terminal.vue')['default'] + Tool: typeof import('./src/components/ai-elements/Tool.vue')['default'] + WhatsAppConfig: typeof import('./src/components/WhatsAppConfig.vue')['default'] + } + export interface ComponentCustomProperties { + vLoading: typeof import('element-plus/es')['ElLoadingDirective'] + } +} diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..823834b --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3414 @@ +{ + "name": "opencode-bridge-admin-web", + "version": "3.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opencode-bridge-admin-web", + "version": "3.1.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "@shikijs/core": "^4.0.2", + "@shikijs/engine-javascript": "^4.0.2", + "@shikijs/langs": "^4.0.2", + "@shikijs/themes": "^4.0.2", + "ansi-to-html": "^0.7.2", + "axios": "^1.7.0", + "driver.js": "^1.4.0", + "element-plus": "^2.9.0", + "markdown-it": "^14.1.1", + "pinia": "^2.3.0", + "vue": "^3.5.0", + "vue-router": "^4.5.0", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.4", + "typescript": "^5.6.0", + "unplugin-auto-import": "^0.18.6", + "unplugin-vue-components": "^0.27.5", + "vite": "^6.4.1", + "vue-tsc": "^2.2.12" + } + }, + "node_modules/@antfu/utils": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-0.7.10.tgz", + "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", + "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", + "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", + "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", + "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", + "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", + "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-to-html": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz", + "integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==", + "license": "MIT", + "dependencies": { + "entities": "^2.2.0" + }, + "bin": { + "ansi-to-html": "bin/ansi-to-html" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/driver.js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz", + "integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.13.7", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.7.tgz", + "integrity": "sha512-XdHATFZOyzVFL1DaHQ90IOJQSg9UnSAV+bhDW+YB5UoZ0Hxs50mwqjqfwXkuwpSag+VXXizVcErBR6Movo5daw==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", + "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "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==", + "dev": true, + "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/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "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==", + "dev": true, + "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/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport": { + "version": "3.14.6", + "resolved": "https://registry.npmjs.org/unimport/-/unimport-3.14.6.tgz", + "integrity": "sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.4", + "acorn": "^8.14.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "fast-glob": "^3.3.3", + "local-pkg": "^1.0.0", + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "pathe": "^2.0.1", + "picomatch": "^4.0.2", + "pkg-types": "^1.3.0", + "scule": "^1.3.0", + "strip-literal": "^2.1.1", + "unplugin": "^1.16.1" + } + }, + "node_modules/unimport/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unimport/node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/unimport/node_modules/local-pkg/node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unplugin": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", + "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/unplugin-auto-import": { + "version": "0.18.6", + "resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-0.18.6.tgz", + "integrity": "sha512-LMFzX5DtkTj/3wZuyG5bgKBoJ7WSgzqSGJ8ppDRdlvPh45mx6t6w3OcbExQi53n3xF5MYkNGPNR/HYOL95KL2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.10", + "@rollup/pluginutils": "^5.1.3", + "fast-glob": "^3.3.2", + "local-pkg": "^0.5.1", + "magic-string": "^0.30.14", + "minimatch": "^9.0.5", + "unimport": "^3.13.4", + "unplugin": "^1.16.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-0.27.5.tgz", + "integrity": "sha512-m9j4goBeNwXyNN8oZHHxvIIYiG8FQ9UfmKWeNllpDvhU7btKNNELGPt+o3mckQKuPwrE7e0PvCsx+IWuDSD9Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.10", + "@rollup/pluginutils": "^5.1.3", + "chokidar": "^3.6.0", + "debug": "^4.3.7", + "fast-glob": "^3.3.2", + "local-pkg": "^0.5.1", + "magic-string": "^0.30.14", + "minimatch": "^9.0.5", + "mlly": "^1.7.3", + "unplugin": "^1.16.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@babel/parser": "^7.15.8", + "@nuxt/kit": "^3.2.2", + "vue": "2 || 3" + }, + "peerDependenciesMeta": { + "@babel/parser": { + "optional": true + }, + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", + "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/xterm": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", + "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", + "license": "MIT" + }, + "node_modules/xterm-addon-fit": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz", + "integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==", + "deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.", + "license": "MIT", + "peerDependencies": { + "xterm": "^5.0.0" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/web/package.json b/web/package.json index 56183cf..0a2a9bd 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "opencode-bridge-admin-web", - "version": "2.9.59", + "version": "3.1.0", "private": true, "type": "module", "scripts": { @@ -10,15 +10,26 @@ }, "dependencies": { "@element-plus/icons-vue": "^2.3.1", + "@shikijs/core": "^4.0.2", + "@shikijs/engine-javascript": "^4.0.2", + "@shikijs/langs": "^4.0.2", + "@shikijs/themes": "^4.0.2", + "ansi-to-html": "^0.7.2", "axios": "^1.7.0", + "driver.js": "^1.4.0", "element-plus": "^2.9.0", + "markdown-it": "^14.1.1", "pinia": "^2.3.0", "vue": "^3.5.0", - "vue-router": "^4.5.0" + "vue-router": "^4.5.0", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.2.4", "typescript": "^5.6.0", + "unplugin-auto-import": "^0.18.6", + "unplugin-vue-components": "^0.27.5", "vite": "^6.4.1", "vue-tsc": "^2.2.12" } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7eb162a..d658651 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -11,12 +11,33 @@ importers: '@element-plus/icons-vue': specifier: ^2.3.1 version: 2.3.2(vue@3.5.30(typescript@5.9.3)) + '@shikijs/core': + specifier: ^4.0.2 + version: 4.0.2 + '@shikijs/engine-javascript': + specifier: ^4.0.2 + version: 4.0.2 + '@shikijs/langs': + specifier: ^4.0.2 + version: 4.0.2 + '@shikijs/themes': + specifier: ^4.0.2 + version: 4.0.2 + ansi-to-html: + specifier: ^0.7.2 + version: 0.7.2 axios: specifier: ^1.7.0 version: 1.13.6 + driver.js: + specifier: ^1.4.0 + version: 1.4.0 element-plus: specifier: ^2.9.0 version: 2.13.5(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + markdown-it: + specifier: ^14.1.1 + version: 14.1.1 pinia: specifier: ^2.3.0 version: 2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) @@ -26,6 +47,12 @@ importers: vue-router: specifier: ^4.5.0 version: 4.6.4(vue@3.5.30(typescript@5.9.3)) + xterm: + specifier: ^5.3.0 + version: 5.3.0 + xterm-addon-fit: + specifier: ^0.8.0 + version: 0.8.0(xterm@5.3.0) devDependencies: '@vitejs/plugin-vue': specifier: ^5.2.4 @@ -33,6 +60,12 @@ importers: typescript: specifier: ^5.6.0 version: 5.9.3 + unplugin-auto-import: + specifier: ^0.18.6 + version: 0.18.6(@vueuse/core@12.0.0(typescript@5.9.3))(rollup@4.59.0) + unplugin-vue-components: + specifier: ^0.27.5 + version: 0.27.5(@babel/parser@7.29.2)(rollup@4.59.0)(vue@3.5.30(typescript@5.9.3)) vite: specifier: ^6.4.1 version: 6.4.1 @@ -42,6 +75,9 @@ importers: packages: + '@antfu/utils@0.7.10': + resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -236,6 +272,27 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] @@ -374,21 +431,60 @@ packages: cpu: [x64] os: [win32] + '@shikijs/core@4.0.2': + resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.0.2': + resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} + engines: {node: '>=20'} + + '@shikijs/langs@4.0.2': + resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.0.2': + resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} + engines: {node: '>=20'} + + '@shikijs/themes@4.0.2': + resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} + engines: {node: '>=20'} + + '@shikijs/types@4.0.2': + resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sxzz/popperjs-es@2.11.8': resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} '@types/lodash@4.17.24': resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/web-bluetooth@0.0.20': resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vitejs/plugin-vue@5.2.4': resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -457,9 +553,26 @@ packages: '@vueuse/shared@12.0.0': resolution: {integrity: sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + alien-signals@1.0.13: resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + ansi-to-html@0.7.2: + resolution: {integrity: sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==} + engines: {node: '>=8.0.0'} + hasBin: true + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + async-validator@4.2.5: resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} @@ -472,17 +585,47 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -492,10 +635,29 @@ packages: de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + driver.js@1.4.0: + resolution: {integrity: sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -505,6 +667,13 @@ packages: peerDependencies: vue: ^3.3.0 + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -530,9 +699,26 @@ packages: engines: {node: '>=18'} hasBin: true + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -542,6 +728,10 @@ packages: picomatch: optional: true + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} @@ -571,6 +761,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -587,10 +781,49 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} @@ -607,13 +840,46 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -626,6 +892,12 @@ packages: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} @@ -634,15 +906,32 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + normalize-wheel-es@1.2.0: resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.5: + resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} @@ -656,31 +945,148 @@ packages: typescript: optional: true + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + unimport@3.14.6: + resolution: {integrity: sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unplugin-auto-import@0.18.6: + resolution: {integrity: sha512-LMFzX5DtkTj/3wZuyG5bgKBoJ7WSgzqSGJ8ppDRdlvPh45mx6t6w3OcbExQi53n3xF5MYkNGPNR/HYOL95KL2A==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': ^3.2.2 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + + unplugin-vue-components@0.27.5: + resolution: {integrity: sha512-m9j4goBeNwXyNN8oZHHxvIIYiG8FQ9UfmKWeNllpDvhU7btKNNELGPt+o3mckQKuPwrE7e0PvCsx+IWuDSD9Vg==} + engines: {node: '>=14'} + peerDependencies: + '@babel/parser': ^7.15.8 + '@nuxt/kit': ^3.2.2 + vue: 2 || 3 + peerDependenciesMeta: + '@babel/parser': + optional: true + '@nuxt/kit': + optional: true + + unplugin@1.16.1: + resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==} + engines: {node: '>=14.0.0'} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@6.4.1: resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -754,8 +1160,26 @@ packages: typescript: optional: true + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + xterm-addon-fit@0.8.0: + resolution: {integrity: sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==} + deprecated: This package is now deprecated. Move to @xterm/addon-fit instead. + peerDependencies: + xterm: ^5.0.0 + + xterm@5.3.0: + resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==} + deprecated: This package is now deprecated. Move to @xterm/xterm instead. + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: + '@antfu/utils@0.7.10': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -866,6 +1290,26 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@rollup/pluginutils@5.3.0(rollup@4.59.0)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.59.0 + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -941,18 +1385,65 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@shikijs/core@4.0.2': + dependencies: + '@shikijs/primitive': 4.0.2 + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.5 + + '@shikijs/langs@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + + '@shikijs/primitive@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/themes@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + + '@shikijs/types@4.0.2': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@sxzz/popperjs-es@2.11.8': {} '@types/estree@1.0.8': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/lodash-es@4.17.12': dependencies: '@types/lodash': 4.17.24 '@types/lodash@4.17.24': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/unist@3.0.3': {} + '@types/web-bluetooth@0.0.20': {} + '@ungap/structured-clone@1.3.0': {} + '@vitejs/plugin-vue@5.2.4(vite@6.4.1)(vue@3.5.30(typescript@5.9.3))': dependencies: vite: 6.4.1 @@ -1061,8 +1552,21 @@ snapshots: transitivePeerDependencies: - typescript + acorn@8.16.0: {} + alien-signals@1.0.13: {} + ansi-to-html@0.7.2: + dependencies: + entities: 2.2.0 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@2.0.1: {} + async-validator@4.2.5: {} asynckit@0.4.0: {} @@ -1077,27 +1581,69 @@ snapshots: balanced-match@1.0.2: {} + binary-extensions@2.3.0: {} + brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + + confbox@0.1.8: {} + + confbox@0.2.4: {} + csstype@3.2.3: {} dayjs@1.11.20: {} de-indent@1.0.2: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + delayed-stream@1.0.0: {} + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + driver.js@1.4.0: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1124,6 +1670,10 @@ snapshots: transitivePeerDependencies: - typescript + entities@2.2.0: {} + + entities@4.5.0: {} + entities@7.0.1: {} es-define-property@1.0.1: {} @@ -1170,12 +1720,36 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + escape-string-regexp@5.0.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + exsolve@1.0.8: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + follow-redirects@1.15.11: {} form-data@4.0.5: @@ -1209,6 +1783,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + gopd@1.2.0: {} has-symbols@1.1.0: {} @@ -1221,8 +1799,57 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + he@1.2.0: {} + html-void-elements@3.0.0: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + js-tokens@9.0.1: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.2 + pkg-types: 1.3.1 + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.0 + quansync: 0.2.11 + lodash-es@4.17.23: {} lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.23)(lodash@4.17.23): @@ -1237,10 +1864,57 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + math-intrinsics@1.1.0: {} + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdurl@2.0.0: {} + memoize-one@6.0.0: {} + merge2@1.4.1: {} + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + mime-db@1.52.0: {} mime-types@2.1.35: @@ -1251,16 +1925,39 @@ snapshots: dependencies: brace-expansion: 2.0.2 + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + muggle-string@0.4.1: {} nanoid@3.3.11: {} + normalize-path@3.0.0: {} + normalize-wheel-es@1.2.0: {} + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.5: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.1.0 + regex-recursion: 6.0.2 + path-browserify@1.0.1: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.3: {} pinia@2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)): @@ -1273,14 +1970,50 @@ snapshots: transitivePeerDependencies: - '@vue/composition-api' + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 + property-information@7.1.0: {} + proxy-from-env@1.1.0: {} + punycode.js@2.3.1: {} + + quansync@0.2.11: {} + + queue-microtask@1.2.3: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + reusify@1.1.0: {} + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -1312,15 +2045,133 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + scule@1.3.0: {} + source-map-js@1.2.1: {} + space-separated-tokens@2.0.2: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + trim-lines@3.0.1: {} + typescript@5.9.3: {} + uc.micro@2.1.0: {} + + ufo@1.6.3: {} + + unimport@3.14.6(rollup@4.59.0): + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + acorn: 8.16.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + fast-glob: 3.3.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.2 + pathe: 2.0.3 + picomatch: 4.0.3 + pkg-types: 1.3.1 + scule: 1.3.0 + strip-literal: 2.1.1 + unplugin: 1.16.1 + transitivePeerDependencies: + - rollup + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unplugin-auto-import@0.18.6(@vueuse/core@12.0.0(typescript@5.9.3))(rollup@4.59.0): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + fast-glob: 3.3.3 + local-pkg: 0.5.1 + magic-string: 0.30.21 + minimatch: 9.0.9 + unimport: 3.14.6(rollup@4.59.0) + unplugin: 1.16.1 + optionalDependencies: + '@vueuse/core': 12.0.0(typescript@5.9.3) + transitivePeerDependencies: + - rollup + + unplugin-vue-components@0.27.5(@babel/parser@7.29.2)(rollup@4.59.0)(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + chokidar: 3.6.0 + debug: 4.4.3 + fast-glob: 3.3.3 + local-pkg: 0.5.1 + magic-string: 0.30.21 + minimatch: 9.0.9 + mlly: 1.8.2 + unplugin: 1.16.1 + vue: 3.5.30(typescript@5.9.3) + optionalDependencies: + '@babel/parser': 7.29.2 + transitivePeerDependencies: + - rollup + - supports-color + + unplugin@1.16.1: + dependencies: + acorn: 8.16.0 + webpack-virtual-modules: 0.6.2 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite@6.4.1: dependencies: esbuild: 0.25.12 @@ -1358,3 +2209,13 @@ snapshots: '@vue/shared': 3.5.30 optionalDependencies: typescript: 5.9.3 + + webpack-virtual-modules@0.6.2: {} + + xterm-addon-fit@0.8.0(xterm@5.3.0): + dependencies: + xterm: 5.3.0 + + xterm@5.3.0: {} + + zwitch@2.0.4: {} diff --git a/web/src/App.vue b/web/src/App.vue index 6b73412..d649294 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,47 +1,56 @@ + + diff --git a/web/src/components/ai-elements/Conversation.vue b/web/src/components/ai-elements/Conversation.vue new file mode 100644 index 0000000..3806adf --- /dev/null +++ b/web/src/components/ai-elements/Conversation.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/web/src/components/ai-elements/FileTree.vue b/web/src/components/ai-elements/FileTree.vue new file mode 100644 index 0000000..973e3d0 --- /dev/null +++ b/web/src/components/ai-elements/FileTree.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/web/src/components/ai-elements/Markdown.vue b/web/src/components/ai-elements/Markdown.vue new file mode 100644 index 0000000..98f019c --- /dev/null +++ b/web/src/components/ai-elements/Markdown.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/web/src/components/ai-elements/Message.vue b/web/src/components/ai-elements/Message.vue new file mode 100644 index 0000000..fee478f --- /dev/null +++ b/web/src/components/ai-elements/Message.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/web/src/components/ai-elements/Reasoning.vue b/web/src/components/ai-elements/Reasoning.vue new file mode 100644 index 0000000..b1b6eac --- /dev/null +++ b/web/src/components/ai-elements/Reasoning.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/web/src/components/ai-elements/Task.vue b/web/src/components/ai-elements/Task.vue new file mode 100644 index 0000000..300fa29 --- /dev/null +++ b/web/src/components/ai-elements/Task.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/web/src/components/ai-elements/Terminal.vue b/web/src/components/ai-elements/Terminal.vue new file mode 100644 index 0000000..a79388b --- /dev/null +++ b/web/src/components/ai-elements/Terminal.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/web/src/components/ai-elements/Tool.vue b/web/src/components/ai-elements/Tool.vue new file mode 100644 index 0000000..0c95661 --- /dev/null +++ b/web/src/components/ai-elements/Tool.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/web/src/components/ai-elements/markdown-utils.ts b/web/src/components/ai-elements/markdown-utils.ts new file mode 100644 index 0000000..781d4dc --- /dev/null +++ b/web/src/components/ai-elements/markdown-utils.ts @@ -0,0 +1,73 @@ +export interface MarkdownTextSegment { + id: string + type: 'markdown' + content: string +} + +export interface MarkdownCodeSegment { + id: string + type: 'code' + code: string + language?: string +} + +export type MarkdownSegment = MarkdownTextSegment | MarkdownCodeSegment + +const CODE_FENCE_PATTERN = /```([^\n`]*)\n?([\s\S]*?)```/g + +export function escapeHtml(source: string): string { + return source + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export function splitMarkdownSegments(source: string): MarkdownSegment[] { + if (!source) return [] + + const segments: MarkdownSegment[] = [] + let match: RegExpExecArray | null + let lastIndex = 0 + let segmentIndex = 0 + + while ((match = CODE_FENCE_PATTERN.exec(source)) !== null) { + const [fullMatch, info = '', rawCode = ''] = match + const matchIndex = match.index + + if (matchIndex > lastIndex) { + segments.push({ + id: `markdown-${segmentIndex++}`, + type: 'markdown', + content: source.slice(lastIndex, matchIndex), + }) + } + + const language = info.trim().split(/\s+/)[0] || undefined + const code = rawCode.endsWith('\n') + ? rawCode.slice(0, -1) + : rawCode + + segments.push({ + id: `code-${segmentIndex++}`, + type: 'code', + code, + language, + }) + + lastIndex = matchIndex + fullMatch.length + } + + if (lastIndex < source.length) { + segments.push({ + id: `markdown-${segmentIndex}`, + type: 'markdown', + content: source.slice(lastIndex), + }) + } + + return segments.filter(segment => + segment.type === 'code' || segment.content.length > 0 + ) +} diff --git a/web/src/components/ai-elements/shiki.ts b/web/src/components/ai-elements/shiki.ts new file mode 100644 index 0000000..aa5b376 --- /dev/null +++ b/web/src/components/ai-elements/shiki.ts @@ -0,0 +1,88 @@ +import { createHighlighterCore } from '@shikijs/core' +import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript' + +const SHIKI_THEME = 'github-light' + +const LANGUAGE_ALIASES: Record = { + js: 'javascript', + ts: 'typescript', + md: 'markdown', + yml: 'yaml', + shell: 'bash', + console: 'bash', + text: 'plaintext', + txt: 'plaintext', +} + +type ShikiHighlighter = Awaited> +type LanguageRegistrationModule = { + default: Parameters +} + +let highlighterPromise: Promise | null = null +const loadedLanguages = new Set(['plaintext', 'text', 'txt', 'plain']) + +const LANGUAGE_LOADERS: Record Promise> = { + bash: () => Promise.all([import('@shikijs/langs/bash')]), + json: () => Promise.all([import('@shikijs/langs/json')]), + yaml: () => Promise.all([import('@shikijs/langs/yaml')]), + markdown: () => Promise.all([import('@shikijs/langs/markdown')]), + typescript: () => Promise.all([import('@shikijs/langs/typescript')]), + tsx: () => Promise.all([import('@shikijs/langs/typescript'), import('@shikijs/langs/tsx')]), + javascript: () => Promise.all([import('@shikijs/langs/javascript')]), + jsx: () => Promise.all([import('@shikijs/langs/javascript'), import('@shikijs/langs/jsx')]), + vue: () => Promise.all([ + import('@shikijs/langs/vue'), + import('@shikijs/langs/html'), + import('@shikijs/langs/css'), + import('@shikijs/langs/javascript'), + import('@shikijs/langs/typescript'), + ]), + html: () => Promise.all([import('@shikijs/langs/html')]), + css: () => Promise.all([import('@shikijs/langs/css')]), + diff: () => Promise.all([import('@shikijs/langs/diff')]), + sql: () => Promise.all([import('@shikijs/langs/sql')]), +} + +export function normalizeCodeLanguage(language?: string): string { + if (!language) return 'plaintext' + const normalized = language.trim().toLowerCase() + return LANGUAGE_ALIASES[normalized] || normalized +} + +export function getShikiTheme(): string { + return SHIKI_THEME +} + +export async function getShikiHighlighter(): Promise { + if (!highlighterPromise) { + highlighterPromise = Promise.all([ + import('@shikijs/themes/github-light'), + ]).then(([githubLight]) => + createHighlighterCore({ + engine: createJavaScriptRegexEngine(), + themes: [githubLight.default], + }) + ) + } + + return highlighterPromise +} + +export async function ensureShikiLanguage(language?: string): Promise { + const normalized = normalizeCodeLanguage(language) + if (loadedLanguages.has(normalized)) { + return normalized + } + + const loader = LANGUAGE_LOADERS[normalized] + if (!loader) { + return 'plaintext' + } + + const highlighter = await getShikiHighlighter() + const modules = await loader() + await highlighter.loadLanguage(...modules.flatMap(module => module.default)) + loadedLanguages.add(normalized) + return normalized +} diff --git a/web/src/components/onboarding/HelpMenu.vue b/web/src/components/onboarding/HelpMenu.vue new file mode 100644 index 0000000..2a2e3fb --- /dev/null +++ b/web/src/components/onboarding/HelpMenu.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/web/src/components/onboarding/OnboardingWizard.vue b/web/src/components/onboarding/OnboardingWizard.vue new file mode 100644 index 0000000..1f61adc --- /dev/null +++ b/web/src/components/onboarding/OnboardingWizard.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/web/src/composables/chat-model.ts b/web/src/composables/chat-model.ts new file mode 100644 index 0000000..6608492 --- /dev/null +++ b/web/src/composables/chat-model.ts @@ -0,0 +1,639 @@ +import type { + ChatEvent, + ChatHistoryMessage, + ChatMessagePart, + ChatModelRef, + ChatPermissionRequest, + ChatTodoItem, + ChatTokenUsage, +} from '../api' + +export type ChatMessageStatus = 'streaming' | 'done' | 'error' +export type ChatStreamState = 'disconnected' | 'connecting' | 'connected' | 'idle' + +export interface ChatToolCallVm { + id: string + callId: string + name: string + title?: string + input?: unknown + output: string + status: 'running' | 'completed' | 'error' + isError: boolean + durationMs?: number +} + +export interface ChatMessageVm { + id: string + role: 'user' | 'assistant' + createdAt: number + parentId?: string + text: string + reasoning: string + tools: ChatToolCallVm[] + status: ChatMessageStatus + usage?: ChatTokenUsage + finish?: string + error?: string + model?: ChatModelRef + agent?: string + optimistic?: boolean + parts?: ChatMessagePart[] +} + +function isTodoWriteTool(name: string): boolean { + const normalized = name.trim().toLowerCase() + return normalized === 'todowrite' || normalized === 'todo_write' || normalized === 'todo-write' +} + +function toTodoItems(raw: unknown): ChatTodoItem[] | undefined { + if (!Array.isArray(raw)) return undefined + + const tasks: ChatTodoItem[] = [] + for (const item of raw) { + if (!item || typeof item !== 'object') continue + const record = item as Record + const id = typeof record.id === 'string' ? record.id.trim() : '' + const contentValue = record.content ?? record.title ?? record.text + const content = typeof contentValue === 'string' ? contentValue.trim() : '' + const status = typeof record.status === 'string' && record.status.trim() + ? record.status.trim() + : 'pending' + + if (!id || !content) continue + tasks.push({ + id, + content, + status, + priority: typeof record.priority === 'string' ? record.priority : undefined, + }) + } + + return tasks.length > 0 ? tasks : undefined +} + +function extractTodoItemsFromToolState(state: Extract['state']): ChatTodoItem[] | undefined { + if ('metadata' in state && state.metadata?.todos) { + const fromMetadata = toTodoItems(state.metadata.todos) + if (fromMetadata) return fromMetadata + } + + return toTodoItems(state.input?.todos) +} + +function toModelRef(message: ChatHistoryMessage['info']): ChatModelRef | undefined { + if (message.role === 'user' && message.model?.providerID && message.model?.modelID) { + return { + providerId: message.model.providerID, + modelId: message.model.modelID, + } + } + + if (message.role === 'assistant' && message.providerID && message.modelID) { + return { + providerId: message.providerID, + modelId: message.modelID, + } + } + + return undefined +} + +function toUsage(message: ChatHistoryMessage['info']): ChatTokenUsage | undefined { + const tokens = message.tokens + if (!tokens) return undefined + return { + input: tokens.input ?? 0, + output: tokens.output ?? 0, + reasoning: tokens.reasoning ?? 0, + cacheRead: tokens.cache?.read ?? 0, + cacheWrite: tokens.cache?.write ?? 0, + cost: message.cost, + } +} + +function appendToolPart(tools: ChatToolCallVm[], part: Extract): void { + const existing = tools.find(item => item.id === part.id || item.callId === part.callID) + const status = part.state.status === 'error' + ? 'error' + : part.state.status === 'completed' + ? 'completed' + : 'running' + const output = part.state.status === 'completed' + ? part.state.output + : part.state.status === 'error' + ? part.state.error + : '' + + const next: ChatToolCallVm = { + id: part.id, + callId: part.callID, + name: part.tool, + input: part.state.input, + title: 'title' in part.state ? part.state.title : undefined, + output, + status, + isError: part.state.status === 'error', + } + + if (existing) { + Object.assign(existing, next) + return + } + + tools.push(next) +} + +export function normalizeHistoryMessage(message: ChatHistoryMessage): ChatMessageVm { + const textParts: string[] = [] + const reasoningParts: string[] = [] + const tools: ChatToolCallVm[] = [] + + for (const part of message.parts ?? []) { + if (part.type === 'text') { + textParts.push(part.text ?? '') + continue + } + + if (part.type === 'reasoning') { + reasoningParts.push(part.text ?? '') + continue + } + + if (part.type === 'tool') { + appendToolPart(tools, part) + } + } + + const error = message.info.error?.data?.message + const assistantDone = message.info.role === 'assistant' && Boolean(message.info.time.completed) + + return { + id: message.info.id, + role: message.info.role, + createdAt: (message.info.time.created ?? Date.now() / 1000) * 1000, + parentId: message.info.role === 'assistant' ? message.info.parentID : undefined, + text: textParts.join(''), + reasoning: reasoningParts.join(''), + tools, + status: error ? 'error' : assistantDone || message.info.role === 'user' ? 'done' : 'streaming', + usage: message.info.role === 'assistant' ? toUsage(message.info) : undefined, + finish: message.info.finish, + error, + model: toModelRef(message.info), + agent: message.info.role === 'assistant' ? message.info.mode : message.info.agent, + parts: message.parts, + } +} + +export function extractTasksFromHistory(history: ChatHistoryMessage[]): ChatTodoItem[] { + let latestTasks: ChatTodoItem[] = [] + + for (const message of history) { + for (const part of message.parts ?? []) { + if (part.type !== 'tool' || !isTodoWriteTool(part.tool)) continue + const nextTasks = extractTodoItemsFromToolState(part.state) + if (nextTasks) { + latestTasks = nextTasks + } + } + } + + return latestTasks +} + +function createAssistantMessage(msgId: string, createdAt = Date.now()): ChatMessageVm { + return { + id: msgId, + role: 'assistant', + createdAt, + parentId: undefined, + text: '', + reasoning: '', + tools: [], + status: 'streaming', + } +} + +function resolveAssistantCreatedAt(messages: ChatMessageVm[], createdAt: number, parentId?: string): number { + if (parentId) { + const parentMessage = messages.find(item => item.role === 'user' && item.id === parentId) + if (parentMessage && parentMessage.createdAt > createdAt) { + return parentMessage.createdAt + } + } + + const lastMessage = messages[messages.length - 1] + if (!lastMessage) { + return createdAt + } + + // SSE 的 assistant createdAt 只有秒级精度,而 optimistic user 是毫秒级。 + // 如果 assistant 被排到最新一条 user 前面,会把多轮回复错误地归并到上一轮。 + if (lastMessage.role === 'user' && lastMessage.createdAt > createdAt) { + return lastMessage.createdAt + } + + return createdAt +} + +function findLatestOptimisticUser(messages: ChatMessageVm[]): ChatMessageVm | undefined { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (message.role === 'user' && message.optimistic) { + return message + } + } + return undefined +} + +function syncOptimisticUserMessage( + messages: ChatMessageVm[], + meta: Extract['msg'] +): ChatMessageVm | undefined { + if (meta.role !== 'user') { + return undefined + } + + const optimistic = findLatestOptimisticUser(messages) + if (!optimistic) { + return undefined + } + + optimistic.id = meta.id + if (typeof meta.createdAt === 'number' && Number.isFinite(meta.createdAt)) { + optimistic.createdAt = Math.max(optimistic.createdAt, meta.createdAt) + } + optimistic.model = meta.model ?? optimistic.model + optimistic.agent = meta.agent ?? optimistic.agent + optimistic.optimistic = false + messages.sort((left, right) => left.createdAt - right.createdAt) + return optimistic +} + +function upsertMessage(messages: ChatMessageVm[], message: ChatMessageVm): ChatMessageVm { + const existing = messages.find(item => item.id === message.id) + if (existing) { + Object.assign(existing, message) + return existing + } + + messages.push(message) + messages.sort((left, right) => left.createdAt - right.createdAt) + return message +} + +function ensureAssistant(messages: ChatMessageVm[], msgId: string): ChatMessageVm { + const existing = messages.find(item => item.id === msgId) + if (existing) return existing + return upsertMessage(messages, createAssistantMessage(msgId)) +} + +function ensureTool(message: ChatMessageVm, toolId: string, callId: string, name: string): ChatToolCallVm { + let tool = message.tools.find(item => item.id === toolId || item.callId === callId) + if (!tool) { + tool = { + id: toolId, + callId, + name, + output: '', + status: 'running', + isError: false, + } + message.tools.push(tool) + } + return tool +} + +export function applyChatEvent(messages: ChatMessageVm[], event: ChatEvent): void { + switch (event.type) { + case 'message_start': { + if (event.msg.role === 'user') { + const syncedUser = syncOptimisticUserMessage(messages, event.msg) + if (syncedUser) { + return + } + } + + const existing = messages.find(item => item.id === event.msg.id) + if (existing) { + existing.role = event.msg.role + if (typeof event.msg.createdAt === 'number' && Number.isFinite(event.msg.createdAt)) { + existing.createdAt = Math.max(existing.createdAt, event.msg.createdAt) + } + existing.parentId = event.msg.parentId ?? existing.parentId + existing.model = event.msg.model ?? existing.model + existing.agent = event.msg.agent ?? existing.agent + if (existing.role === 'assistant' && existing.status !== 'done' && existing.status !== 'error') { + existing.status = 'streaming' + } + messages.sort((left, right) => left.createdAt - right.createdAt) + return + } + + upsertMessage(messages, { + id: event.msg.id, + role: event.msg.role, + createdAt: event.msg.role === 'assistant' + ? resolveAssistantCreatedAt(messages, event.msg.createdAt, event.msg.parentId) + : event.msg.createdAt, + parentId: event.msg.parentId, + text: '', + reasoning: '', + tools: [], + status: event.msg.role === 'assistant' ? 'streaming' : 'done', + model: event.msg.model, + agent: event.msg.agent, + }) + return + } + + case 'text_delta': + ensureAssistant(messages, event.msgId).text += event.text + return + + case 'reasoning_delta': + ensureAssistant(messages, event.msgId).reasoning += event.text + return + + case 'tool_start': { + const message = ensureAssistant(messages, event.msgId) + const tool = ensureTool(message, event.tool.id, event.tool.callId, event.tool.name) + tool.input = event.tool.input + tool.title = event.tool.title + tool.status = 'running' + tool.isError = false + return + } + + case 'tool_delta': { + const message = ensureAssistant(messages, event.msgId) + const tool = ensureTool(message, event.toolId, event.toolId, 'tool') + tool.output += event.output + return + } + + case 'tool_end': { + const message = ensureAssistant(messages, event.msgId) + const tool = ensureTool(message, event.toolId, event.callId, event.name) + tool.title = event.title + tool.output = event.result + tool.status = event.isError ? 'error' : 'completed' + tool.isError = event.isError + tool.durationMs = event.durationMs + return + } + + case 'message_end': { + const message = ensureAssistant(messages, event.msgId) + message.status = event.error ? 'error' : 'done' + message.error = event.error + message.finish = event.finish + message.usage = event.usage + return + } + + default: + return + } +} + +export function finalizeStreamingMessages(messages: ChatMessageVm[]): void { + for (const message of messages) { + if (message.role === 'assistant' && message.status === 'streaming') { + message.status = 'done' + } + } +} + +export function createOptimisticUserMessage( + text: string, + model?: ChatModelRef, + parts?: Array<{ type: 'text'; text: string } | { type: 'file'; mime: string; url: string; filename?: string }> +): ChatMessageVm { + return { + id: `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + role: 'user', + createdAt: Date.now(), + text, + reasoning: '', + tools: [], + status: 'done', + model, + optimistic: true, + parts: parts as ChatMessagePart[], + } +} + +export function createErrorAssistantMessage(message: string): ChatMessageVm { + return { + id: `error-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + role: 'assistant', + createdAt: Date.now(), + parentId: undefined, + text: '', + reasoning: '', + tools: [], + status: 'error', + error: message, + } +} + +export function mergeChatMessages(history: ChatMessageVm[], current: ChatMessageVm[]): ChatMessageVm[] { + const merged = new Map() + + for (const message of history) { + merged.set(message.id, { ...message, tools: message.tools.map(tool => ({ ...tool })) }) + } + + for (const message of current) { + const existing = merged.get(message.id) + if (!existing) { + merged.set(message.id, { ...message, tools: message.tools.map(tool => ({ ...tool })) }) + continue + } + + merged.set(message.id, { + ...existing, + ...message, + text: message.text.length >= existing.text.length ? message.text : existing.text, + reasoning: message.reasoning.length >= existing.reasoning.length ? message.reasoning : existing.reasoning, + tools: message.tools.length >= existing.tools.length ? message.tools : existing.tools, + usage: message.usage ?? existing.usage, + error: message.error ?? existing.error, + parentId: message.parentId ?? existing.parentId, + optimistic: message.optimistic || existing.optimistic, + status: message.status === 'streaming' ? 'streaming' : existing.status === 'error' ? 'error' : message.status, + }) + } + + return Array.from(merged.values()).sort((left, right) => left.createdAt - right.createdAt) +} + +export interface ConversationTurn { + id: string + userMessage: ChatMessageVm | null + assistantMessages: ChatMessageVm[] + autoExpand: boolean +} + +function isBlankUserPlaceholder(message: ChatMessageVm): boolean { + if (message.role !== 'user') { + return false + } + + return !message.text.trim() + && !message.reasoning.trim() + && message.tools.length === 0 + && !message.error +} + +export function buildConversationTurns(messages: ChatMessageVm[]): ConversationTurn[] { + const turns: ConversationTurn[] = [] + const turnsByUserId = new Map() + const seenMessageIds = new Set() + let currentSequentialTurn: ConversationTurn | null = null + + for (const message of messages) { + if (seenMessageIds.has(message.id)) { + continue + } + seenMessageIds.add(message.id) + + if (message.role === 'user') { + const existingTurn = turnsByUserId.get(message.id) + if (existingTurn) { + existingTurn.userMessage = message + currentSequentialTurn = existingTurn + continue + } + + const previousTurn = turns[turns.length - 1] + if ( + isBlankUserPlaceholder(message) + && previousTurn?.userMessage + && previousTurn.assistantMessages.length === 0 + ) { + turnsByUserId.set(message.id, previousTurn) + currentSequentialTurn = previousTurn + continue + } + + const turn: ConversationTurn = { + id: `turn-${message.id}`, + userMessage: message, + assistantMessages: [], + autoExpand: false, + } + turns.push(turn) + turnsByUserId.set(message.id, turn) + currentSequentialTurn = turn + continue + } + + const parentId = message.parentId?.trim() + if (parentId) { + const linkedTurn = turnsByUserId.get(parentId) + if (linkedTurn) { + if (!linkedTurn.assistantMessages.some(item => item.id === message.id)) { + linkedTurn.assistantMessages.push(message) + } + continue + } + } + + if (!currentSequentialTurn) { + currentSequentialTurn = { + id: `turn-orphan-${message.id}`, + userMessage: null, + assistantMessages: [message], + autoExpand: false, + } + turns.push(currentSequentialTurn) + continue + } + + if (!currentSequentialTurn.assistantMessages.some(item => item.id === message.id)) { + currentSequentialTurn.assistantMessages.push(message) + } + } + + const dedupedTurns: ConversationTurn[] = [] + const seenTurnSignatures = new Set() + + for (const turn of turns) { + const assistantIds = turn.assistantMessages.map(message => message.id).join(',') + const signature = `${turn.userMessage?.id || 'orphan'}|${assistantIds}` + if (seenTurnSignatures.has(signature)) { + continue + } + seenTurnSignatures.add(signature) + dedupedTurns.push(turn) + } + + if (dedupedTurns.length > 0) { + const lastTurn = dedupedTurns[dedupedTurns.length - 1] + if (lastTurn.assistantMessages.some(message => message.status === 'streaming')) { + lastTurn.autoExpand = true + } + } + + return dedupedTurns +} + +// 支持的思考强度选项映射,用于标准化不同模型的参数名称 +export const EFFORT_VARIANT_MAP: Record = { + 'none': 'none', + 'minimal': 'minimal', + 'low': 'low', + 'medium': 'medium', + 'high': 'high', + 'max': 'max', + 'xhigh': 'xhigh', + 'fast': 'low', + 'balanced': 'high', + 'deep': 'xhigh', + // 兼容其他可能的参数名称 + 'thinking_low': 'low', + 'thinking_medium': 'medium', + 'thinking_high': 'high', + 'reasoning_low': 'low', + 'reasoning_medium': 'medium', + 'reasoning_high': 'high', +} + +// 获取标准化的思考强度选项 +export function normalizeVariant(variant: string): string { + const normalized = variant.trim().toLowerCase() + return EFFORT_VARIANT_MAP[normalized] || variant +} + +export function formatVariantLabel(variant?: string): string { + if (!variant) return '默认' + return normalizeVariant(variant) +} + +export function resolveSupportedVariant( + variant: string | undefined, + supportedVariants: string[] +): string | undefined { + const nextVariant = typeof variant === 'string' ? variant.trim() : '' + if (!nextVariant) { + return undefined + } + + const exactMatch = supportedVariants.find(item => item === nextVariant) + if (exactMatch) { + return exactMatch + } + + const normalized = normalizeVariant(nextVariant) + return supportedVariants.find(item => normalizeVariant(item) === normalized) +} + +// 检查模型是否支持特定的思考强度 +export function isVariantSupported(variant: string, supportedVariants: string[]): boolean { + return Boolean(resolveSupportedVariant(variant, supportedVariants)) +} + +export type { ChatPermissionRequest, ChatTodoItem } diff --git a/web/src/composables/chat-stream-utils.ts b/web/src/composables/chat-stream-utils.ts new file mode 100644 index 0000000..39d0b7b --- /dev/null +++ b/web/src/composables/chat-stream-utils.ts @@ -0,0 +1,16 @@ +export function parseEventSeq(lastEventId: string | undefined): number | null { + const seq = Number.parseInt(lastEventId || '', 10) + return Number.isFinite(seq) && seq > 0 ? seq : null +} + +export function resolveReplaySince( + currentSessionId: string | null, + nextSessionId: string, + lastEventSeq: number | null +): number | null { + if (currentSessionId !== nextSessionId) { + return null + } + + return typeof lastEventSeq === 'number' && lastEventSeq > 0 ? lastEventSeq : null +} diff --git a/web/src/composables/onboarding-platforms.ts b/web/src/composables/onboarding-platforms.ts new file mode 100644 index 0000000..84c52d0 --- /dev/null +++ b/web/src/composables/onboarding-platforms.ts @@ -0,0 +1,26 @@ +/** + * 首次安装引导用到的平台元数据 + * + * 仅做"挑一个先接入"的展示用途,不与 Platforms.vue 强耦合: + * - id 用于路由跳转后的 query / hash(platforms?focus=feishu) + * - icon 单字符 emoji,避免引入额外图标包 + * - label 中文标签(i18n 通过运行时覆盖层翻译为英文) + * - desc 一句话功能描述 + */ +export interface OnboardingPlatform { + id: 'feishu' | 'discord' | 'wecom' | 'weixin' | 'dingtalk' | 'telegram' | 'qq' | 'whatsapp' + icon: string + label: string + desc: string +} + +export const ONBOARDING_PLATFORMS: readonly OnboardingPlatform[] = [ + { id: 'feishu', icon: '🪶', label: '飞书', desc: '企业 IM,支持机器人回调与流式回复' }, + { id: 'discord', icon: '🎮', label: 'Discord', desc: '社区聊天平台,原生 Bot Token 接入' }, + { id: 'wecom', icon: '💼', label: '企业微信', desc: '企业群机器人 Webhook 双向消息' }, + { id: 'weixin', icon: '💬', label: '个人微信', desc: '基于网关的扫码登录与消息桥接' }, + { id: 'dingtalk', icon: '📌', label: '钉钉', desc: '企业 IM,机器人 / Stream 模式接入' }, + { id: 'telegram', icon: '✈️', label: 'Telegram', desc: '官方 Bot API,全球可用' }, + { id: 'qq', icon: '🐧', label: 'QQ', desc: '官方 API + OneBot 双协议' }, + { id: 'whatsapp', icon: '🟢', label: 'WhatsApp', desc: 'Personal / Business 双模式' }, +] as const diff --git a/web/src/composables/useChatMessages.ts b/web/src/composables/useChatMessages.ts new file mode 100644 index 0000000..79f9c91 --- /dev/null +++ b/web/src/composables/useChatMessages.ts @@ -0,0 +1,336 @@ +import { ref, watch, type Ref } from 'vue' +import { chatApi, type ChatEvent, type ChatModelRef } from '../api' +import { + applyChatEvent, + buildConversationTurns, + createErrorAssistantMessage, + createOptimisticUserMessage, + extractTasksFromHistory, + finalizeStreamingMessages, + mergeChatMessages, + normalizeHistoryMessage, + type ChatMessageVm, + type ChatStreamState, + type ChatTodoItem, +} from './chat-model' +import { useChatStream } from './useChatStream' +import { usePermission } from './usePermission' + +const HISTORY_PAGE_SIZE = 30 + +export function useChatMessages(sessionId: Ref) { + const messages = ref([]) + const tasks = ref([]) + const loading = ref(false) + const loadingMore = ref(false) + const sending = ref(false) + const running = ref(false) + const lastError = ref(null) + const streamState = ref('disconnected') + const total = ref(0) + const totalTurns = ref(0) + const hasMore = ref(false) + const nextCursor = ref(null) + const permission = usePermission() + let requestVersion = 0 + + const stream = useChatStream(sessionId, { + onEvent(event: ChatEvent) { + applyIncomingEvent(event) + }, + }) + + async function fetchLatestMessages(currentVersion: number, targetSessionId: string): Promise { + const page = await chatApi.getMessages(targetSessionId, { limit: HISTORY_PAGE_SIZE }) + if (currentVersion !== requestVersion || targetSessionId !== sessionId.value) return + + const latestTasks = Array.isArray(page.tasks) ? page.tasks : [] + tasks.value = latestTasks.length > 0 ? latestTasks : extractTasksFromHistory(page.messages) + total.value = page.total + totalTurns.value = page.totalTurns ?? buildConversationTurns(page.messages.map(normalizeHistoryMessage)).length + hasMore.value = page.hasMore + nextCursor.value = page.nextCursor + messages.value = mergeChatMessages(page.messages.map(normalizeHistoryMessage), []) + } + + watch( + () => stream.state.value, + value => { + streamState.value = value + }, + { immediate: true } + ) + + watch( + () => stream.lastError.value, + value => { + if (value) lastError.value = value + } + ) + + watch( + sessionId, + async nextSessionId => { + requestVersion += 1 + const currentVersion = requestVersion + messages.value = [] + tasks.value = [] + // 切换会话时必须把所有"工作中"信号一并复位,否则上一会话残留的 + // sending=true 会让新会话误显示「中断」按钮(issue: 切换至其它会话有概率显示中断) + sending.value = false + running.value = false + lastError.value = null + total.value = 0 + totalTurns.value = 0 + hasMore.value = false + nextCursor.value = null + permission.reset() + + if (!nextSessionId) { + return + } + + loading.value = true + try { + await fetchLatestMessages(currentVersion, nextSessionId) + } catch (error) { + if (currentVersion !== requestVersion) return + lastError.value = error instanceof Error ? error.message : '加载会话消息失败' + } finally { + if (currentVersion === requestVersion) { + loading.value = false + } + } + }, + { immediate: true } + ) + + function applyIncomingEvent(event: ChatEvent): void { + switch (event.type) { + case 'task_update': + tasks.value = event.todos + return + + case 'permission_ask': + permission.enqueue(event.req) + return + + case 'permission_resolved': + permission.resolve(event.reqId) + return + + case 'session_status': + if (event.status === 'idle') { + sending.value = false + running.value = false + finalizeStreamingMessages(messages.value) + } else { + running.value = true + } + return + + case 'error': + lastError.value = event.message + sending.value = false + running.value = false + messages.value = [...messages.value, createErrorAssistantMessage(event.message)] + return + + case 'session_idle': + sending.value = false + running.value = false + finalizeStreamingMessages(messages.value) + return + + case 'message_end': + sending.value = false + running.value = false + applyChatEvent(messages.value, event) + return + + case 'message_start': + case 'text_delta': + case 'reasoning_delta': + case 'tool_start': + case 'tool_delta': + running.value = true + applyChatEvent(messages.value, event) + return + + default: + applyChatEvent(messages.value, event) + } + } + + async function loadMoreHistory(): Promise { + if (!sessionId.value || !nextCursor.value || loading.value || loadingMore.value) { + return + } + + const currentVersion = requestVersion + const currentSessionId = sessionId.value + loadingMore.value = true + try { + const page = await chatApi.getMessages(currentSessionId, { + limit: HISTORY_PAGE_SIZE, + cursor: nextCursor.value, + }) + if (currentVersion !== requestVersion || currentSessionId !== sessionId.value) return + const latestTasks = Array.isArray(page.tasks) ? page.tasks : [] + tasks.value = latestTasks.length > 0 ? latestTasks : tasks.value + total.value = page.total + totalTurns.value = page.totalTurns ?? totalTurns.value + hasMore.value = page.hasMore + nextCursor.value = page.nextCursor + messages.value = mergeChatMessages(page.messages.map(normalizeHistoryMessage), messages.value) + } catch (error) { + lastError.value = error instanceof Error ? error.message : '加载更多历史失败' + } finally { + loadingMore.value = false + } + } + + async function sendText(payload: { + sessionId: string + text: string + parts?: Array<{ type: 'text'; text: string } | { type: 'file'; mime: string; url: string; filename?: string }> + providerId?: string + modelId?: string + agent?: string + variant?: string + }): Promise { + const trimmed = payload.text.trim() + if (!trimmed && !payload.parts) return + + const model: ChatModelRef | undefined = payload.providerId && payload.modelId + ? { + providerId: payload.providerId, + modelId: payload.modelId, + } + : undefined + + messages.value = [...messages.value, createOptimisticUserMessage(trimmed, model, payload.parts)] + sending.value = true + running.value = true + lastError.value = null + + try { + await chatApi.sendPrompt({ + sessionId: payload.sessionId, + text: trimmed, + parts: payload.parts, + providerId: payload.providerId, + modelId: payload.modelId, + agent: payload.agent, + variant: payload.variant, + }) + } catch (error) { + sending.value = false + running.value = false + const message = error instanceof Error ? error.message : '发送消息失败' + lastError.value = message + messages.value = [...messages.value, createErrorAssistantMessage(message)] + } + } + + async function reload(): Promise { + if (!sessionId.value) { + messages.value = [] + tasks.value = [] + total.value = 0 + totalTurns.value = 0 + hasMore.value = false + nextCursor.value = null + return + } + + requestVersion += 1 + const currentVersion = requestVersion + loading.value = true + lastError.value = null + + try { + await fetchLatestMessages(currentVersion, sessionId.value) + } catch (error) { + if (currentVersion !== requestVersion) return + lastError.value = error instanceof Error ? error.message : '刷新会话消息失败' + } finally { + if (currentVersion === requestVersion) { + loading.value = false + } + } + } + + function discardFromMessage(messageId: string): void { + const index = messages.value.findIndex(message => message.id === messageId) + if (index < 0) return + + const removedCount = messages.value.length - index + messages.value = messages.value.slice(0, index) + total.value = Math.max(messages.value.length, total.value - removedCount) + totalTurns.value = Math.min(totalTurns.value, buildConversationTurns(messages.value).length) + sending.value = false + running.value = false + } + + function discardConversationFromMessage(messageId: string): void { + const turns = buildConversationTurns(messages.value) + const turnIndex = turns.findIndex(turn => { + if (turn.userMessage?.id === messageId) return true + return turn.assistantMessages.some(message => message.id === messageId) + }) + + if (turnIndex < 0) { + discardFromMessage(messageId) + return + } + + const retainedTurns = turns.slice(0, turnIndex) + const retainedIds = new Set() + for (const turn of retainedTurns) { + if (turn.userMessage?.id) retainedIds.add(turn.userMessage.id) + for (const assistantMessage of turn.assistantMessages) { + retainedIds.add(assistantMessage.id) + } + } + + messages.value = messages.value.filter(message => retainedIds.has(message.id)) + total.value = Math.min(total.value, messages.value.length) + totalTurns.value = Math.min(totalTurns.value, retainedTurns.length) + sending.value = false + running.value = false + } + + function retainMessages(messageIds: string[]): void { + const allowed = new Set(messageIds.filter(Boolean)) + messages.value = messages.value.filter(message => allowed.has(message.id)) + total.value = Math.min(total.value, messages.value.length) + totalTurns.value = Math.min(totalTurns.value, buildConversationTurns(messages.value).length) + sending.value = false + running.value = false + } + + return { + messages, + tasks, + loading, + loadingMore, + sending, + running, + lastError, + streamState, + total, + totalTurns, + hasMore, + permissionQueue: permission.queue, + activePermission: permission.activeRequest, + resolvePermissionRequest: permission.resolve, + reconnectStream: stream.reconnect, + loadMoreHistory, + sendText, + reload, + discardFromMessage, + discardConversationFromMessage, + retainMessages, + } +} diff --git a/web/src/composables/useChatSessions.ts b/web/src/composables/useChatSessions.ts new file mode 100644 index 0000000..dcfe70b --- /dev/null +++ b/web/src/composables/useChatSessions.ts @@ -0,0 +1,54 @@ +import { ref } from 'vue' +import { chatApi, type ChatSessionSummary } from '../api' + +export function useChatSessions() { + const sessions = ref([]) + const loading = ref(false) + + async function refresh(): Promise { + loading.value = true + try { + sessions.value = await chatApi.listSessions() + return sessions.value + } finally { + loading.value = false + } + } + + async function createSession(payload?: { title?: string; directory?: string }): Promise { + const session = await chatApi.createSession(payload) + sessions.value = [session, ...sessions.value.filter(item => item.id !== session.id)] + return session + } + + async function renameSession(sessionId: string, title: string): Promise { + await chatApi.renameSession(sessionId, title) + const target = sessions.value.find(item => item.id === sessionId) + if (target) { + target.title = title + target.updatedAt = Date.now() + } + } + + async function deleteSession(sessionId: string): Promise { + await chatApi.deleteSession(sessionId) + sessions.value = sessions.value.filter(item => item.id !== sessionId) + } + + function touchSession(sessionId: string): void { + const target = sessions.value.find(item => item.id === sessionId) + if (!target) return + target.updatedAt = Date.now() + sessions.value = [target, ...sessions.value.filter(item => item.id !== sessionId)] + } + + return { + sessions, + loading, + refresh, + createSession, + renameSession, + deleteSession, + touchSession, + } +} diff --git a/web/src/composables/useChatStream.ts b/web/src/composables/useChatStream.ts new file mode 100644 index 0000000..fc54b34 --- /dev/null +++ b/web/src/composables/useChatStream.ts @@ -0,0 +1,184 @@ +import { onBeforeUnmount, ref, watch, type Ref } from 'vue' +import type { ChatEvent } from '../api' +import type { ChatStreamState } from './chat-model' +import { parseEventSeq, resolveReplaySince } from './chat-stream-utils' + +const CHAT_EVENT_TYPES = [ + 'message_start', + 'text_delta', + 'reasoning_delta', + 'tool_start', + 'tool_delta', + 'tool_end', + 'message_end', + 'permission_ask', + 'permission_resolved', + 'task_update', + 'session_idle', + 'session_status', + 'error', + 'keepalive', +] as const + +/** Exponential backoff parameters */ +const RECONNECT_BASE_MS = 1000 +const RECONNECT_MAX_MS = 30000 +const RECONNECT_FACTOR = 2 + +export function useChatStream( + sessionId: Ref, + options: { + onEvent: (event: ChatEvent) => void + } +) { + const state = ref('disconnected') + const lastError = ref(null) + let source: EventSource | null = null + let reconnectTimer: ReturnType | null = null + let reconnectAttempt = 0 + let currentSessionId: string | null = null + let lastEventSeq: number | null = null + let intentionalClose = false + + function clearReconnectTimer(): void { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + } + + function close(): void { + intentionalClose = true + clearReconnectTimer() + if (!source) return + source.close() + source = null + state.value = 'disconnected' + } + + function scheduleReconnect(): void { + if (intentionalClose || !currentSessionId) return + clearReconnectTimer() + + const delay = Math.min( + RECONNECT_BASE_MS * Math.pow(RECONNECT_FACTOR, reconnectAttempt), + RECONNECT_MAX_MS + ) + reconnectAttempt++ + + console.log(`[ChatStream] 将在 ${delay}ms 后自动重连 (第 ${reconnectAttempt} 次)`) + reconnectTimer = setTimeout(() => { + reconnectTimer = null + if (currentSessionId && !intentionalClose) { + connect(currentSessionId) + } + }, delay) + } + + function connect(nextSessionId: string): void { + const sinceSeq = resolveReplaySince(currentSessionId, nextSessionId, lastEventSeq) + if (sinceSeq == null) { + lastEventSeq = null + } + + // Close previous without triggering reconnect + intentionalClose = true + clearReconnectTimer() + if (source) { + source.close() + source = null + } + intentionalClose = false + currentSessionId = nextSessionId + + const params = new URLSearchParams({ + session_id: nextSessionId, + }) + if (sinceSeq != null) { + params.set('since', String(sinceSeq)) + } + + state.value = 'connecting' + lastError.value = null + source = new EventSource(`/api/chat/events?${params.toString()}`) + + source.addEventListener('connected', () => { + state.value = 'connected' + reconnectAttempt = 0 // Reset backoff on successful connection + }) + + for (const eventType of CHAT_EVENT_TYPES) { + source.addEventListener(eventType, payload => { + try { + const messageEvent = payload as MessageEvent + const eventSeq = parseEventSeq(messageEvent.lastEventId) + if (eventSeq != null) { + lastEventSeq = eventSeq + } + + const event = JSON.parse(messageEvent.data) as ChatEvent + if (event.type === 'keepalive') return + if (event.type === 'session_idle') { + state.value = 'idle' + } else if (event.type === 'session_status' && event.status === 'idle') { + state.value = 'idle' + } else if (event.type === 'message_start' || event.type === 'text_delta') { + state.value = 'connected' + } + options.onEvent(event) + } catch (error) { + lastError.value = error instanceof Error ? error.message : '解析流事件失败' + } + }) + } + + source.addEventListener('error', payload => { + const messageEvent = payload as MessageEvent + if (typeof messageEvent.data === 'string' && messageEvent.data) { + try { + options.onEvent(JSON.parse(messageEvent.data) as ChatEvent) + } catch { + lastError.value = '解析错误事件失败' + } + return + } + + // EventSource auto-reconnects on transient errors, but if readyState is CLOSED + // the browser gave up — we need to manually reconnect with backoff. + if (source && source.readyState === EventSource.CLOSED) { + lastError.value = '流式连接已断开,正在自动重连…' + state.value = 'disconnected' + source.close() + source = null + scheduleReconnect() + } + }) + } + + watch( + sessionId, + nextSessionId => { + reconnectAttempt = 0 // Reset backoff on session switch + if (!nextSessionId) { + close() + currentSessionId = null + lastEventSeq = null + return + } + connect(nextSessionId) + }, + { immediate: true } + ) + + onBeforeUnmount(close) + + return { + state, + lastError, + reconnect: () => { + reconnectAttempt = 0 + if (sessionId.value) connect(sessionId.value) + }, + close, + } +} diff --git a/web/src/composables/useOnboarding.ts b/web/src/composables/useOnboarding.ts new file mode 100644 index 0000000..72fe876 --- /dev/null +++ b/web/src/composables/useOnboarding.ts @@ -0,0 +1,94 @@ +import { ref } from 'vue' +import { configApi } from '../api/index' + +/** + * 首次安装引导(onboarding)状态管理 + * + * 设计: + * - 后端持久化为权威源(admin_meta.onboarding_completed) + * - 前端 localStorage 仅作缓存,避免每次刷新都拉接口 + * - 完成 / 跳过 都会写入 true,避免重复打扰 + */ + +const LOCAL_STORAGE_KEY = 'opencode_bridge_onboarding_completed' + +const isCompleted = ref(readLocalCache()) +const loading = ref(false) +const visible = ref(false) + +function readLocalCache(): boolean | null { + if (typeof window === 'undefined') return null + const raw = window.localStorage.getItem(LOCAL_STORAGE_KEY) + if (raw === '1') return true + if (raw === '0') return false + return null +} + +function writeLocalCache(value: boolean): void { + if (typeof window === 'undefined') return + window.localStorage.setItem(LOCAL_STORAGE_KEY, value ? '1' : '0') +} + +async function fetchStatus(force = false): Promise { + if (!force && isCompleted.value !== null) return isCompleted.value + loading.value = true + try { + const { completed } = await configApi.getOnboardingStatus() + isCompleted.value = completed + writeLocalCache(completed) + return completed + } catch { + // 后端不可达时,按本地缓存(若无则按未完成)渲染,避免首次访问空白 + const fallback = readLocalCache() ?? false + isCompleted.value = fallback + return fallback + } finally { + loading.value = false + } +} + +async function markCompleted(): Promise { + isCompleted.value = true + writeLocalCache(true) + visible.value = false + try { + await configApi.setOnboardingStatus(true) + } catch { + // 后端写入失败不影响本次会话;下次启动会再次拉取,必要时重新展示 + } +} + +/** 用户主动跳过引导,与 markCompleted 行为一致(不再打扰) */ +async function markSkipped(): Promise { + await markCompleted() +} + +/** 启动入口:拉取后端状态,未完成则展示引导浮层 */ +async function bootstrap(): Promise { + const done = await fetchStatus(false) + if (!done) { + visible.value = true + } +} + +function show(): void { + visible.value = true +} + +function hide(): void { + visible.value = false +} + +export function useOnboarding() { + return { + isCompleted, + loading, + visible, + bootstrap, + fetchStatus, + markCompleted, + markSkipped, + show, + hide, + } +} diff --git a/web/src/composables/usePermission.ts b/web/src/composables/usePermission.ts new file mode 100644 index 0000000..3a7da84 --- /dev/null +++ b/web/src/composables/usePermission.ts @@ -0,0 +1,29 @@ +import { computed, ref } from 'vue' +import type { ChatPermissionRequest } from '../api' + +export function usePermission() { + const queue = ref([]) + + const activeRequest = computed(() => queue.value[0] ?? null) + + function enqueue(request: ChatPermissionRequest): void { + if (queue.value.some(item => item.id === request.id)) return + queue.value = [...queue.value, request] + } + + function resolve(requestId: string): void { + queue.value = queue.value.filter(item => item.id !== requestId) + } + + function reset(): void { + queue.value = [] + } + + return { + queue, + activeRequest, + enqueue, + resolve, + reset, + } +} diff --git a/web/src/i18n/packs/en.ts b/web/src/i18n/packs/en.ts new file mode 100644 index 0000000..50965b3 --- /dev/null +++ b/web/src/i18n/packs/en.ts @@ -0,0 +1,907 @@ +export const EN_TRANSLATION_MAP: Record = { + // ── 首次安装引导 + '欢迎使用 OpenCode Bridge。先选择一下你习惯的界面语言,随时可以在系统设置里切换。': + 'Welcome to OpenCode Bridge. Pick your preferred interface language — you can change it anytime from System Settings.', + '挑一个最常用的平台先接入,后续可以在「平台接入」里继续配置其它平台。也可以现在跳过这一步。': + 'Pick the platform you use most to wire up first. You can configure the others later from "Platform Integrations", or skip this step now.', + '最后用一分钟熟悉一下左侧导航:每一项都是后续配置 / 排错的入口。点击「开始导览」会逐一高亮讲解。': + 'Take a minute to get familiar with the left navigation — each entry is a gate for further configuration / troubleshooting. Click "Start Tour" to walk through them one by one.', + '系统状态:服务概览、版本、运行时长': 'System Status: service overview, version and uptime', + 'AI 工作区:直接发送 Prompt 调试 OpenCode': 'AI Workspace: send prompts directly to debug OpenCode', + '平台接入:八个 IM 平台的开关与凭据': 'Platform Integrations: switches & credentials for the 8 IM platforms', + 'Session / OpenCode 对接 / 高可用 / 核心行为:精细化能力': 'Session / OpenCode / Reliability / Core Behavior: fine-grained controls', + 'Cron / 日志 / 系统设置:运行期监控与系统级开关': 'Cron / Logs / System Settings: runtime monitoring and system-level switches', + '想跳过导览也没关系,之后可以在左下角的「帮助」里再次访问对应文档。': + 'Skipping the tour is fine — relevant docs are always reachable from the "Help" entry at the bottom-left.', + '步骤 1 / 3 · 选择界面语言': 'Step 1 / 3 · Choose interface language', + '步骤 2 / 3 · 选择首个接入平台': 'Step 2 / 3 · Pick your first platform', + '步骤 3 / 3 · 熟悉左侧导航': 'Step 3 / 3 · Get familiar with left navigation', + '简体中文界面': 'Simplified Chinese UI', + 'English interface': 'English interface', + '跳过引导': 'Skip onboarding', + '上一步': 'Back', + '下一步': 'Next', + '开始导览': 'Start Tour', + '下一项': 'Next', + '上一项': 'Previous', + '完成': 'Done', + // ── 平台说明(用于引导第二步的卡片) + '飞书': 'Feishu', + '企业微信': 'WeCom', + '个人微信': 'WeChat', + '钉钉': 'DingTalk', + '企业 IM,支持机器人回调与流式回复': 'Enterprise IM, bot callbacks and streaming replies', + '社区聊天平台,原生 Bot Token 接入': 'Community chat with native Bot Token integration', + '企业群机器人 Webhook 双向消息': 'Group-bot Webhook two-way messaging', + '基于网关的扫码登录与消息桥接': 'Gateway-based QR login and message bridging', + '企业 IM,机器人 / Stream 模式接入': 'Enterprise IM, bot / Stream mode integration', + '官方 Bot API,全球可用': 'Official Bot API, globally available', + '官方 API + OneBot 双协议': 'Official API + OneBot dual protocol', + 'Personal / Business 双模式': 'Personal / Business dual mode', + // ── 引导导览(driver.js popover)描述 + '服务版本、运行时长、依赖健康度都在这里。': 'Service version, uptime and dependency health all live here.', + '不绑定任何 IM 也能直接调用 OpenCode 跑 Prompt 与工具调用。': 'Trigger OpenCode prompts and tool calls directly without binding any IM.', + '飞书 / Discord / 微信 / 钉钉 / Telegram / QQ / WhatsApp 共八个平台开关与凭据。': + 'Toggles and credentials for all 8 platforms: Feishu / Discord / WeChat / DingTalk / Telegram / QQ / WhatsApp.', + '查看 / 解绑各平台的会话绑定与轮询状态。': + 'Inspect / unbind per-platform session bindings and polling state.', + 'OpenCode 服务安装、启动方式与连接参数。': + 'OpenCode service installation, startup mode and connection parameters.', + '心跳 / 救援策略 / 失败阈值 / 冷却窗口等可靠性参数。': + 'Heartbeat / repair policy / failure threshold / cooldown — all reliability knobs.', + '群聊触发条件、思考链与工具链显隐、默认工作目录等核心开关。': + 'Group-chat triggers, thinking / tool chain visibility, default work directory — core behaviour switches.', + '定时触发的提醒 / 巡检 / 自动化任务,可在这里新建启停。': + 'Scheduled reminders / health checks / automation jobs — create and toggle them here.', + '运行日志检索与清理,错误条数会在菜单上以红色徽标提示。': + 'Search and clear runtime logs; error counts appear as red badges on the menu.', + '界面语言、版本升级、Bridge 重启等系统级操作。': + 'Interface language, version upgrade, Bridge restart — system-level operations.', + + // ── 帮助菜单 + '帮助': 'Help', + 'README(项目说明)': 'README (Project Overview)', + 'README · English': 'README · English', + '平台接入指南': 'Platform Integration Guides', + 'OpenCode 服务部署': 'OpenCode Service Deployment', + '高可用与心跳策略': 'Reliability & Heartbeat', + '提交 Issue / 反馈': 'Open Issue / Feedback', + + '登录': 'Login', + '修改密码': 'Change Password', + '系统状态': 'System Status', + 'AI 工作区': 'AI Workspace', + '平台接入': 'Platform Integrations', + 'Session 管理': 'Session Management', + 'OpenCode 对接': 'OpenCode Integration', + '高可用配置': 'Reliability', + '核心行为': 'Core Behavior', + 'Cron 任务管理': 'Cron Jobs', + '日志管理': 'Logs', + '系统设置': 'System Settings', + 'Bridge 服务': 'Bridge Service', + 'OpenCode 管理': 'OpenCode Management', + '登录设置': 'Login Settings', + '版本升级': 'Version Upgrade', + '界面语言': 'Interface Language', + '当前语言:': 'Current Language:', + '中文(默认)': 'Chinese (Default)', + '中文': 'Chinese', + '英文': 'English', + '退出': 'Logout', + '英文翻译通过外挂语言包覆盖,切换后立即生效。': 'English is provided by an external language pack and takes effect immediately.', + '状态:': 'Status:', + '运行中': 'Running', + '已停止': 'Stopped', + '安装状态:': 'Installation:', + '已安装': 'Installed', + '未安装': 'Not Installed', + '服务端口:': 'Service Port:', + '已启动': 'Running', + '未响应': 'Not Responding', + '版本检查:': 'Version Check:', + '检查中...': 'Checking...', + '最新版本:': 'Latest Version:', + '获取失败': 'Unavailable', + '检查失败': 'Check Failed', + '有新版本': 'Update Available', + '已是最新': 'Up to Date', + '刷新': 'Refresh', + '刷新成功': 'Refreshed', + '重启 Bridge': 'Restart Bridge', + '终止服务': 'Shutdown Service', + '安装 OpenCode': 'Install OpenCode', + '升级 OpenCode': 'Upgrade OpenCode', + '可视化启动': 'Visual Start', + '后台启动': 'Background Start', + '启动方式': 'Start Mode', + '启动 OpenCode': 'Start OpenCode', + '终止 OpenCode': 'Stop OpenCode', + '登录超时:': 'Login Timeout:', + '分钟(0 表示不限制)': 'minutes (0 = unlimited)', + '保存设置': 'Save Settings', + '设置登录后无操作自动退出时间,0 表示永不超时。': 'Set the auto logout timeout after login. 0 means no timeout.', + '当前版本:': 'Current Version:', + '一键升级': 'One-click Upgrade', + '升级将从 Git 拉取最新代码并重新构建,完成后需重启服务。': 'Upgrade pulls the latest code from Git and rebuilds the app. Restart is required after completion.', + '警告': 'Warning', + '未知': 'Unknown', + '仅关闭 Bridge': 'Stop Bridge Only', + '终止所有服务': 'Stop All Services', + '确认重启服务': 'Confirm Restart', + '重启成功': 'Restart Successful', + '重启失败': 'Restart Failed', + '前往系统设置': 'Open System Settings', + '立即重启': 'Restart Now', + '稍后手动重启': 'Restart Later', + '以下配置需要重启服务才能生效:': 'The following settings require a service restart:', + '待生效配置:': 'Pending settings:', + '重启将中断当前所有连接,服务将在 1 秒后退出(需配合 PM2/systemd 自动拉起)。': 'Restart will interrupt all current connections. The service will exit in 1 second and should be brought back by PM2/systemd.', + '终止服务将停止 Bridge 和 OpenCode 所有进程,Web 面板也将无法访问。': 'Stopping the service will terminate all Bridge and OpenCode processes, and the web panel will become unavailable.', + '确定要终止 OpenCode 服务吗?这将断开所有 OpenCode 连接。': 'Stop the OpenCode service? This will disconnect all OpenCode sessions.', + '登录已超时,请重新登录': 'Login timed out. Please sign in again.', + '重启指令已发送,服务即将退出...': 'Restart request sent. The service will exit shortly...', + '重启失败,请手动执行': 'Restart failed. Please restart manually.', + 'Bridge 正在终止...': 'Bridge is shutting down...', + '服务正在终止...': 'Services are shutting down...', + '登录超时设置已保存': 'Login timeout saved', + '确定终止': 'Confirm Stop', + '取消': 'Cancel', + '确定': 'OK', + '保存': 'Save', + '保存失败': 'Save failed', + '删除': 'Delete', + '复制': 'Copy', + '绑定': 'Bind', + '回退': 'Undo', + '发送': 'Send', + '中止': 'Abort', + '重新连接': 'Reconnect', + '会话 ID': 'Session ID', + '新建项目': 'New Project', + '新建项目会话': 'New Project Session', + '会话标题': 'Session Title', + '不填写则使用新对话': 'Leave empty to use the default new chat title', + '选择项目文件夹': 'Select Project Folder', + '输入路径后按回车或点击 GO 浏览': 'Enter a path and press Enter or click GO to browse', + '正在读取目录...': 'Loading directory...', + '当前目录没有子文件夹': 'This directory has no subfolders', + '输入路径后按回车或点击 GO 开始浏览': 'Enter a path and press Enter or click GO to start browsing', + '未选择目录': 'No folder selected', + '选择此文件夹': 'Use This Folder', + '运行 ': 'Up ', + '用户': 'User', + '生成中': 'Generating', + '出错': 'Error', + '失败': 'Failed', + '已完成': 'Completed', + '正在生成回复…': 'Generating response…', + '输入 ': 'Input ', + '输出 ': 'Output ', + '推理 ': 'Reasoning ', + '费用 ': 'Cost ', + '流式连接已断开': 'Streaming connection lost', + '历史消息仍可查看,但新回复可能无法实时推送。': 'History is still available, but new replies may not stream in real time.', + '模型': 'Model', + '默认模型': 'Default Model', + '当前模型不支持思考强度': 'This model does not support reasoning effort', + '思考强度': 'Reasoning Effort', + '默认强度': 'Default Effort', + '代理人': 'Agent', + '默认代理人': 'Default Agent', + '输入你的问题。输入 / 可查看 OpenCode 或 Bridge 内置命令,Enter 发送,Ctrl + Enter 或 Shift + Enter 换行。': 'Type your message. Use / to view OpenCode or Bridge commands. Press Enter to send, Ctrl+Enter or Shift+Enter for a new line.', + '/ 命令': '/ Commands', + '正在读取命令...': 'Loading commands...', + '未找到命令。可继续输入筛选,或检查 OpenCode command.list API。': 'No command found. Keep typing to filter, or check the OpenCode command.list API.', + '内置': 'Built-in', + '技能': 'Skill', + '当前消息没有可复制内容': 'This message has no content to copy', + '已复制消息内容': 'Message copied', + '当前轮次没有可复制内容': 'This turn has no content to copy', + '已复制轮次内容': 'Turn copied', + '复制失败': 'Copy failed', + '已请求中断当前会话': 'Abort request sent for the current session', + '当前没有可回退的对话': 'There is no conversation to undo', + '已回退上一轮': 'Undid the last turn', + '正在重新连接会话流': 'Reconnecting the session stream', + '权限请求已处理': 'Permission request processed', + '权限请求已过期': 'Permission request expired', + '权限处理失败': 'Failed to process permission request', + '会话列表加载失败': 'Failed to load sessions', + '工作区列表加载失败': 'Failed to load workspaces', + '模型列表加载失败': 'Failed to load models', + '请先选择项目目录': 'Select a project directory first', + '请先选择项目文件夹': 'Select a project folder first', + '请输入一个有效路径': 'Enter a valid path', + '输入路径必须位于所选项目文件夹内': 'The input path must be inside the selected project folder', + '已创建新会话': 'New session created', + '会话标题已更新': 'Session title updated', + '会话已删除': 'Session deleted', + '已回退到所选消息': 'Reverted to the selected message', + '重命名失败': 'Rename failed', + '创建会话失败': 'Failed to create session', + '删除失败': 'Delete failed', + '中断失败': 'Abort failed', + '回退失败': 'Undo failed', + '读取目录失败': 'Failed to read the directory', + '当前没有工作目录。先绑定会话目录,或在配置里设置 `DEFAULT_WORK_DIRECTORY`。': 'No working directory is set. Bind a session directory first, or configure `DEFAULT_WORK_DIRECTORY`.', + '正在连接 ': 'Connecting ', + '输入命令...': 'Type a command...', + 'Enter 执行,Shift/Ctrl + Enter 换行,↑/↓ 历史。': 'Press Enter to run, Shift/Ctrl+Enter for a new line, Up/Down for history.', + '终端连接失败': 'Terminal connection failed', + '命令执行失败': 'Command execution failed', + '仓库概览': 'Repository Overview', + '当前分支': 'Current Branch', + 'HEAD状态': 'HEAD State', + '当前分支正常': 'On Branch', + '上游': 'Upstream', + '未绑定': 'Unbound', + '前进 / 落后': 'Ahead / Behind', + '最近提交': 'Latest Commit', + '提交全部 / 推送 / Pull': 'Commit All / Push / Pull', + '提交说明。会自动 add -A 后提交当前目录下全部变更。': 'Commit message. All changes under the current directory will be added with `add -A` and committed.', + '提交全部': 'Commit All', + '推送': 'Push', + '分支': 'Branches', + '历史版本': 'History', + '变更详情': 'Changes', + '选择分支': 'Select Branch', + '切换': 'Switch', + '新增': 'New', + '新增分支': 'Create Branch', + '删除分支确认': 'Delete Branch', + '切换历史版本确认': 'Checkout Commit', + '新增分支会基于当前版本创建并自动切换;当前分支不能删除,删除前会先做二次确认。': 'New branches are created from the current revision and switched to automatically. The current branch cannot be deleted.', + '正在读取历史版本...': 'Loading history...', + '当前仓库还没有可展示的历史版本。': 'This repository has no commits to display yet.', + '正在读取版本详情...': 'Loading commit details...', + '一键切换': 'Checkout', + '切换后会进入 Detached HEAD,可再回到“分支”中切回任意分支。': 'Checkout will move the repo to detached HEAD. You can switch back to any branch from the branch section.', + '变更摘要': 'Change Summary', + '当前版本没有可展示的 diff。': 'No diff is available for this commit.', + '选择一个历史版本查看详情。': 'Select a commit to view details.', + '为避免覆盖当前工作内容,工作区有未提交变更时会禁用历史版本切换。': 'History checkout is disabled while the working tree has uncommitted changes.', + '工作区干净,没有待处理的变更。': 'Working tree is clean.', + 'Diff': 'Diff', + '工作区': 'Working Tree', + '已暂存': 'Staged', + '正在加载 diff...': 'Loading diff...', + '未跟踪文件暂不支持 Git diff,可切到文件面板查看内容。': 'Git diff is unavailable for untracked files. Open the file panel to view the content.', + '当前文件没有已暂存的 diff。': 'This file has no staged diff.', + '当前文件只有已暂存 diff,请切换到“已暂存”查看。': 'This file only has a staged diff. Switch to "Staged" to view it.', + '当前选择没有 diff 输出。': 'No diff output is available for the current selection.', + '选择一个变更文件查看 diff。': 'Select a changed file to view the diff.', + 'Git 状态已刷新': 'Git status refreshed', + '历史版本已刷新': 'History refreshed', + 'Git 操作失败': 'Git action failed', + '已完成 pull': 'Pull completed', + '已完成 push': 'Push completed', + '提交完成': 'Commit completed', + '请输入新分支名称。将基于当前版本创建,并在创建后自动切换到该分支。': 'Enter a new branch name. It will be created from the current revision and checked out automatically.', + '创建并切换': 'Create and Switch', + '分支名称只能包含字母、数字、点、下划线、中划线和斜杠': 'Branch names may only contain letters, numbers, dots, underscores, hyphens, and slashes', + '已创建并切换到 ': 'Created and switched to ', + '确定要删除本地分支 ': 'Delete local branch ', + ' 吗?此操作不会自动恢复。': '? This action cannot be undone automatically.', + '确定要切换到历史版本 ': 'Checkout commit ', + ' 吗?这会进入 Detached HEAD 状态,可稍后在“分支”中切回任意分支。': '? This will enter detached HEAD. You can switch back from the branch section later.', + '已切换到历史版本 ': 'Checked out commit ', + '已删除分支 ': 'Deleted branch ', + 'Git 仓库初始化完成': 'Git repository initialized', + '初始化Git仓库失败': 'Failed to initialize the Git repository', + '权限不足:无法在当前目录创建Git仓库,请检查目录权限': 'Permission denied: unable to create a Git repository in the current directory', + '目录不存在:请先创建目标目录': 'Directory not found: create the target directory first', + '当前没有建立git仓库': 'No Git repository found', + '当前目录不是 Git 仓库': 'Current directory is not a Git repository', + '干净': 'Clean', + '有变更': 'Dirty', + '冲突': 'Conflicts', + '暂存 ': 'Staged ', + '未跟踪 ': 'Untracked ', + '工作区 ': 'Working ', + '密码已重置,请重新设置密码': 'Password has been reset. Please set it again.', + '登录成功': 'Login successful', + '已清除旧登录缓存,请重新登录': 'Old login cache cleared. Please log in again.', + '请输入管理密码': 'Enter admin password', + '密码设置成功,正在跳转...': 'Password set successfully. Redirecting...', + '密码修改成功,正在跳转...': 'Password changed successfully. Redirecting...', + '密码长度至少 8 位': 'Password must be at least 8 characters', + '操作失败,请重试': 'Operation failed. Please try again.', + '未知错误': 'Unknown error', + '健康检测失败: ': 'Health check failed: ', + '修复完成: ': 'Repair completed: ', + '修复失败: ': 'Repair failed: ', + '加载日志失败: ': 'Failed to load logs: ', + '确定要清空所有日志吗?此操作不可撤销。': 'Clear all logs? This action cannot be undone.', + '确认清空': 'Confirm Clear', + '确认清空所有日志': 'Confirm Clear Logs', + '日志已清空': 'Logs cleared', + '清空失败: ': 'Failed to clear logs: ', + '导出成功': 'Export successful', + '日志级别': 'Log Level', + '搜索关键词...': 'Search keywords...', + '绑定状态': 'Binding Status', + '平台': 'Platform', + '选择 OpenCode Session': 'Select OpenCode Session', + '选择平台': 'Select Platform', + '请选择平台': 'Please select a platform', + '请先选择平台': 'Select a platform first', + '搜索 Session ID / 标题...': 'Search Session ID / title...', + '会话标题(可选)': 'Session title (optional)', + '工作目录路径(可选)': 'Working directory (optional)', + '创建者 ID(可选)': 'Creator ID (optional)', + '全部': 'All', + '暂无日志': 'No logs', + '时间': 'Time', + '级别': 'Level', + '来源': 'Source', + '消息': 'Message', + '暂无运行时 Cron 任务': 'No runtime Cron jobs', + '任务名称 / ID': 'Task Name / ID', + 'Cron 表达式': 'Cron Expression', + '平台 / 会话': 'Platform / Session', + '上次执行': 'Last Run', + '下次触发': 'Next Run', + '最近错误': 'Recent Error', + '操作': 'Actions', + '暂停': 'Pause', + '恢复': 'Resume', + '从未执行': 'Never', + '新建 Cron 任务': 'Create Cron Job', + '任务名称(可选)': 'Task Name (Optional)', + '例如:每日提醒': 'e.g. Daily reminder', + '请选择会话': 'Select a session', + '提示词(可选)': 'Prompt (Optional)', + '例如:请读取 HEARTBEAT.md 文件并回复 HEARTBEAT_OK': 'e.g. Read HEARTBEAT.md and reply HEARTBEAT_OK', + '任务已暂停': 'Task paused', + '任务已恢复': 'Task resumed', + '任务已删除': 'Task deleted', + '请填写 Cron 表达式和会话 ID': 'Fill in the Cron expression and session ID', + '任务创建成功': 'Task created', + '历史记录': 'History', + '刷新历史': 'Refresh History', + '正在加载...': 'Loading...', + '暂无历史记录': 'No history yet', + '加载详情...': 'Loading details...', + '读取 Git 状态失败': 'Failed to read Git status', + '读取历史版本失败': 'Failed to load commit history', + '读取历史版本详情失败': 'Failed to load commit details', + '加载 diff 失败': 'Failed to load diff', + '输入提交说明...': 'Enter commit message...', + '确认删除': 'Confirm Delete', + '当前没有工作目录': 'No working directory', + '当前没有工作目录。': 'No working directory.', + '正常': 'Normal', + '工作区干净': 'Working tree is clean', + '在该目录下新建会话': 'Create a session in this folder', + '更多操作': 'More', + '已复制对话信息': 'Conversation info copied', + '文件': 'Files', + '看板': 'Board', + '终端': 'Terminal', + '默认': 'Default', + '启用': 'Enable', + '禁用': 'Disable', + '开启': 'On', + '关闭': 'Off', + '显示': 'Show', + '隐藏': 'Hide', + '允许': 'Allow', + '禁止': 'Disallow', + '继承': 'Inherit', + '开': 'On', + '关': 'Off', + '是': 'Yes', + '否': 'No', + '可选': 'Optional', + '实验性': 'Experimental', + '推荐': 'Recommended', + '已启用': 'Enabled', + '已禁用': 'Disabled', + '已连接': 'Connected', + '未连接': 'Disconnected', + '连接错误': 'Connection Error', + '等待连接': 'Waiting for Connection', + '待扫码': 'Waiting for QR Scan', + '扫码登录': 'Scan QR to Sign In', + '风险等级': 'Risk Level', + '权限确认': 'Permission Request', + '等待确认': 'Awaiting Approval', + '拒绝': 'Deny', + '允许一次': 'Allow Once', + '始终允许': 'Always Allow', + '会话': 'Sessions', + '没有匹配的会话': 'No matching sessions', + '还没有会话': 'No sessions yet', + '尝试清空搜索,或者直接创建新的会话。': 'Try clearing the search, or create a new session directly.', + '从左上角创建一个新会话,或者直接在主区输入问题开始。': 'Create a new session from the top-left, or start typing in the main area.', + '立即新建': 'Create Now', + '输入新的会话标题': 'Enter a new session title', + '重命名会话': 'Rename Session', + '默认工作区': 'Default Workspace', + '新对话': 'New Chat', + '未绑定目录': 'No directory bound', + '中断中': 'Aborting', + '响应中': 'Responding', + '空闲': 'Idle', + '连接中': 'Connecting', + '就绪': 'Ready', + '思维链': 'Reasoning', + '工具链': 'Tool Calls', + '等待工具输出…': 'Waiting for tool output…', + '没有输出内容': 'No output', + '没有可展示的推理内容': 'No reasoning content available', + '没有工具调用': 'No tool calls', + '登录失败:': 'Login failed: ', + '管理密码': 'Admin Password', + '清除旧登录缓存': 'Clear old login cache', + '首次使用请设置管理密码': 'Set the admin password before first use', + '请输入管理密码登录': 'Enter the admin password to sign in', + '请输入密码': 'Enter a password', + '密码错误,请重试': 'Incorrect password. Try again.', + '服务未启动,请通过托盘菜单启动服务后重试': 'The service is not running. Start it from the tray menu and try again.', + '首次使用 - 设置管理密码': 'First Use - Set Admin Password', + '首次登录 - 请修改密码': 'First Sign-in - Change Password', + '设置密码': 'Set Password', + '安全提示': 'Security Notice', + '首次使用需要设置管理密码,设置完成后即可访问管理面板。': 'You need to set an admin password before first use. After that, the admin panel will be available.', + '密码长度至少 8 位。': 'Password must be at least 8 characters.', + '为了账户安全,首次登录必须修改密码后才能继续使用。': 'For account security, you must change the password on first sign-in before continuing.', + '新密码长度至少 8 位。': 'The new password must be at least 8 characters.', + '原密码': 'Current Password', + '新密码': 'New Password', + '确认密码': 'Confirm Password', + '请输入当前密码': 'Enter the current password', + '请输入新密码(至少 8 位)': 'Enter a new password (at least 8 characters)', + '请再次输入新密码': 'Enter the new password again', + '确认设置': 'Confirm Setup', + '确认修改': 'Confirm Change', + '两次输入的密码不一致': 'The two passwords do not match', + '请输入原密码': 'Enter the current password', + '请输入新密码': 'Enter a new password', + '请确认新密码': 'Confirm the new password', + '原密码错误,请重新输入': 'Incorrect current password. Re-enter it.', + '查看服务运行状态、配置概览与统计信息': 'View service status, configuration summary, and metrics', + '服务版本': 'Service Version', + '运行时长': 'Uptime', + '配置存储': 'Config Store', + '启动时间': 'Start Time', + '管理任务': 'Manage Jobs', + '全部任务': 'Total Jobs', + '健康状态检测': 'Health Check', + '检测': 'Check', + '修复': 'Repair', + '配置摘要': 'Configuration Summary', + '路由模式': 'Routing Mode', + '会话绑定': 'Session Binding', + '显示思维链': 'Show Reasoning', + '显示工具链': 'Show Tool Calls', + '工作目录白名单': 'Working Directory Allowlist', + '工具白名单': 'Tool Allowlist', + '未配置': 'Not configured', + '未检测': 'Not checked', + '数据库': 'Database', + '飞书': 'Feishu', + '企业微信': 'WeCom', + '无需修复': 'No repair needed', + 'OpenCode 对接配置': 'OpenCode Integration Settings', + '配置 OpenCode 服务连接地址、认证凭证与自动启动行为': 'Configure the OpenCode service endpoint, credentials, and auto-start behavior', + '服务连接': 'Service Connection', + 'Basic Auth 认证': 'Basic Auth', + '当 OpenCode 服务端开启了 OPENCODE_SERVER_PASSWORD,此处必须配置相同凭据,否则将出现 401 认证失败': 'If OPENCODE_SERVER_PASSWORD is enabled on the OpenCode server, the same credentials must be configured here or requests will fail with 401.', + '用户名(OPENCODE_SERVER_USERNAME)': 'Username (OPENCODE_SERVER_USERNAME)', + '密码(OPENCODE_SERVER_PASSWORD)': 'Password (OPENCODE_SERVER_PASSWORD)', + 'Basic Auth 用户名,默认值为 opencode': 'Basic Auth username. Default: opencode', + 'Basic Auth 密码,需与 OpenCode 服务端配置一致': 'Basic Auth password. Must match the OpenCode server configuration', + '留空则不启用认证': 'Leave blank to disable authentication', + '自动启动 OpenCode': 'Auto-start OpenCode', + '开启后,Bridge 启动时会自动执行下方命令拉起 OpenCode 后台进程': 'When enabled, Bridge will automatically run the command below to start OpenCode in the background.', + '默认模型配置': 'Default Model Settings', + '配置默认使用的 AI 模型供应商和模型名称(不启用路由模式时生效)': 'Configure the default AI provider and model name used when routing mode is disabled.', + '请选择供应商': 'Select a provider', + '请选择模型': 'Select a model', + '选择模型供应商': 'Choose a model provider', + '选择要使用的具体模型': 'Choose the specific model to use', + '供应商(DEFAULT_PROVIDER)': 'Provider (DEFAULT_PROVIDER)', + '模型名称(DEFAULT_MODEL)': 'Model Name (DEFAULT_MODEL)', + 'OpenCode 服务地址(OPENCODE_HOST)': 'OpenCode Host (OPENCODE_HOST)', + 'OpenCode 端口(OPENCODE_PORT)': 'OpenCode Port (OPENCODE_PORT)', + 'OpenCode 配置文件路径(OPENCODE_CONFIG_FILE)': 'OpenCode Config Path (OPENCODE_CONFIG_FILE)', + 'OpenCode 服务监听的主机名或 IP,默认 localhost': 'Hostname or IP listened to by OpenCode. Default: localhost', + 'OpenCode 服务监听的端口,默认 4096': 'Port listened to by OpenCode. Default: 4096', + '宕机救援时用于备份/回退的 OpenCode 配置文件路径': 'Path to the OpenCode configuration used for backup/rollback during recovery', + 'OpenCode 启动命令(OPENCODE_AUTO_START_CMD)': 'OpenCode Start Command (OPENCODE_AUTO_START_CMD)', + '默认为 ': 'Default: ', + '(headless 后台模式),可自定义完整命令': ' (headless background mode). You can provide a custom full command.', + '平台接入配置': 'Platform Integration Settings', + '配置飞书、Discord、企业微信、个人微信、钉钉、Telegram、QQ 与 WhatsApp 机器人的核心凭证和接入参数': 'Configure the core credentials and connection settings for Feishu, Discord, WeCom, personal WeChat, DingTalk, Telegram, QQ, and WhatsApp bots.', + '飞书(Lark)配置': 'Feishu (Lark) Settings', + 'Discord 配置': 'Discord Settings', + '企业微信(WeCom)配置': 'WeCom Settings', + '个人微信配置': 'Personal WeChat Settings', + 'Telegram 配置': 'Telegram Settings', + 'QQ 配置': 'QQ Settings', + 'WhatsApp 配置': 'WhatsApp Settings', + '钉钉配置': 'DingTalk Settings', + '启用飞书': 'Enable Feishu', + '启用 Discord': 'Enable Discord', + '启用企业微信': 'Enable WeCom', + '启用个人微信': 'Enable Personal WeChat', + '启用 Telegram': 'Enable Telegram', + '启用 QQ': 'Enable QQ', + '启用 WhatsApp': 'Enable WhatsApp', + '启用钉钉': 'Enable DingTalk', + '留空则不加密': 'Leave blank to disable encryption', + '留空则跳过验证': 'Leave blank to skip verification', + '纯数字 ID,逗号分隔': 'Numeric IDs, comma-separated', + '允许的 Bot ID 列表(可选)': 'Allowed Bot IDs (Optional)', + '协议类型': 'Protocol Type', + 'NapCat (推荐)': 'NapCat (Recommended)', + '推荐使用 NapCat,更稳定': 'NapCat is recommended and more stable', + 'WebSocket 地址': 'WebSocket Address', + 'NapCat/go-cqhttp 的 WebSocket 地址': 'WebSocket address of NapCat/go-cqhttp', + '状态': 'Status', + '从 @BotFather 获取': 'Get it from @BotFather', + 'Telegram Bot Token,格式:123456789:ABCdefGHI...': 'Telegram Bot Token, format: 123456789:ABCdefGHI...', + '官方 API (推荐)': 'Official API (Recommended)', + '官方 API 更稳定,OneBot 支持传统 QQ 群': 'The official API is more stable. OneBot supports legacy QQ groups.', + 'QQ 开放平台应用 ID': 'QQ Open Platform App ID', + 'QQ 开放平台应用密钥': 'QQ Open Platform App Secret', + '从 QQ 机器人开放平台获取': 'Get it from the QQ bot open platform', + '官方 API 使用 WebSocket 连接接收消息,无需配置回调地址。': 'The official API receives messages over WebSocket and does not require a callback URL.', + '模式': 'Mode', + '个人版 (扫码登录)': 'Personal (QR Sign-in)', + '个人版免费但有风控风险': 'The personal mode is free but has account-risk controls.', + 'Session 存储路径': 'Session Storage Path', + 'WhatsApp 扫码登录': 'WhatsApp QR Sign-in', + '正在连接 WhatsApp...': 'Connecting to WhatsApp...', + '正在生成二维码...': 'Generating QR code...', + '打开手机 WhatsApp → 设置 → 已关联的设备 → 关联设备': 'Open WhatsApp on your phone → Settings → Linked Devices → Link a Device', + '确定删除此账号?': 'Delete this account?', + '暂无已登录账号': 'No signed-in accounts', + '获取二维码中...': 'Fetching QR code...', + '扫码登录新账号': 'Sign in with a new account', + '微信扫码登录': 'WeChat QR Sign-in', + '添加钉钉账号': 'Add DingTalk Account', + '暂无钉钉账号': 'No DingTalk accounts', + '编辑钉钉账号': 'Edit DingTalk Account', + '名称(可选)': 'Name (Optional)', + '账号显示名称': 'Account Display Name', + '账号标识': 'Account Identifier', + 'API 端点': 'API Endpoint', + '普通消息也响应': 'Respond to normal messages', + '必须 @ 机器人才响应': 'Only respond when mentioned', + '平台白名单(ENABLED_PLATFORMS)': 'Platform Allowlist (ENABLED_PLATFORMS)', + '用户白名单(ALLOWED_USERS)': 'User Allowlist (ALLOWED_USERS)', + '群聊触发策略(GROUP_REQUIRE_MENTION)': 'Group Trigger Policy (GROUP_REQUIRE_MENTION)', + '留空 = 全部平台启用': 'Leave blank = enable all platforms', + '选择允许的用户': 'Select allowed users', + '建议至少启用并配置一个平台': 'At least one platform should be enabled and configured', + '飞书 (feishu)': 'Feishu (feishu)', + '钉钉 (dingtalk)': 'DingTalk (dingtalk)', + '企业微信 (wecom)': 'WeCom (wecom)', + '个人微信 (weixin)': 'Personal WeChat (weixin)', + '飞书会话': 'Feishu Sessions', + '企业微信会话': 'WeCom Sessions', + 'Telegram 会话': 'Telegram Sessions', + 'QQ 会话': 'QQ Sessions', + 'WhatsApp 会话': 'WhatsApp Sessions', + '微信会话': 'WeChat Sessions', + 'Discord 频道': 'Discord Channels', + '未启用': 'Disabled', + '待配置': 'Needs Setup', + '已配置': 'Configured', + '待配置 (官方 API)': 'Needs Setup (Official API)', + '已配置 (官方 API)': 'Configured (Official API)', + '待配置 (OneBot)': 'Needs Setup (OneBot)', + '已配置 (OneBot)': 'Configured (OneBot)', + '待配置 (Business API)': 'Needs Setup (Business API)', + '已配置 (Business API)': 'Configured (Business API)', + '连接中...': 'Connecting...', + '请扫描二维码': 'Please scan the QR code', + '已扫描,请在手机确认': 'Scanned. Confirm on your phone', + '登录失败': 'Login failed', + '二维码已过期': 'QR code expired', + '已取消': 'Cancelled', + '未知状态': 'Unknown Status', + '账号已启用': 'Account enabled', + '账号已禁用': 'Account disabled', + '账号已删除': 'Account deleted', + '账号保存成功': 'Account saved', + '请填写账号 ID 和 Client ID': 'Fill in the account ID and Client ID', + '请填写 Client Secret': 'Fill in the Client Secret', + '加载微信账号失败': 'Failed to load WeChat accounts', + '加载钉钉账号失败': 'Failed to load DingTalk accounts', + '获取二维码失败': 'Failed to fetch the QR code', + '轮询登录状态失败': 'Failed to poll login status', + '获取 WhatsApp 状态失败': 'Failed to fetch WhatsApp status', + 'WhatsApp 已连接': 'WhatsApp connected', + '操作失败': 'Operation failed', + 'Encrypt Key(可选)': 'Encrypt Key (Optional)', + 'Verification Token(可选)': 'Verification Token (Optional)', + 'OneBot 协议存在风控风险,建议仅用于个人测试。推荐使用 NapCat(NTQQ 官方协议)。': 'The OneBot protocol has account-risk controls. Use it only for personal testing. NapCat (the official NTQQ protocol) is recommended.', + 'WhatsApp Web 协议存在风控风险,可能导致号码被封。建议使用专用测试号码。': 'The WhatsApp Web protocol has account-risk controls and may cause number bans. Use a dedicated test number.', + '钉钉使用 Stream 模式连接,无需配置回调地址。请确保已在钉钉开发者后台创建企业内部应用机器人。': 'DingTalk uses Stream mode and does not require a callback URL. Make sure an internal app bot has been created in the DingTalk developer console.', + '钉钉开发者后台 → 应用详情 → AppKey': 'DingTalk Developer Console → App Details → AppKey', + '钉钉开发者后台 → 应用详情 → AppSecret': 'DingTalk Developer Console → App Details → AppSecret', + '通常使用默认值即可': 'The default value is usually sufficient', + '企业内部应用的 AppKey': 'AppKey of the internal enterprise app', + '企业内部应用的 AppSecret': 'AppSecret of the internal enterprise app', + '配置 Cron 调度、心跳探活与宕机救援策略': 'Configure Cron scheduling, heartbeat checks, and recovery strategies.', + 'Cron 调度器': 'Cron Scheduler', + 'Cron 任务持久化文件(RELIABILITY_CRON_JOBS_FILE)': 'Cron Job Storage File (RELIABILITY_CRON_JOBS_FILE)', + '运行时动态 Cron 任务的持久化存储路径,默认 ~/cron/jobs.json': 'Persistent storage path for runtime Cron jobs. Default: ~/cron/jobs.json', + '自动清理僵尸 Cron(RELIABILITY_CRON_ORPHAN_AUTO_CLEANUP)': 'Auto-clean Orphaned Cron Jobs (RELIABILITY_CRON_ORPHAN_AUTO_CLEANUP)', + '启用后,群解散/频道删除时自动清理关联的 Cron 任务': 'When enabled, related Cron jobs are cleaned up automatically after a group or channel is removed.', + '窗口失效时转发私聊(RELIABILITY_CRON_FORWARD_TO_PRIVATE)': 'Forward to Direct Chat When Window Expires (RELIABILITY_CRON_FORWARD_TO_PRIVATE)', + '原聊天窗口失效时,允许 Cron 结果转发到私聊或备用窗口': 'Allow Cron results to be forwarded to a direct chat or fallback window when the original chat expires.', + '飞书备用 Chat ID(RELIABILITY_CRON_FALLBACK_FEISHU_CHAT_ID)': 'Feishu Fallback Chat ID (RELIABILITY_CRON_FALLBACK_FEISHU_CHAT_ID)', + 'Discord 备用会话 ID(RELIABILITY_CRON_FALLBACK_DISCORD_CONVERSATION_ID)': 'Discord Fallback Conversation ID (RELIABILITY_CRON_FALLBACK_DISCORD_CONVERSATION_ID)', + '原窗口失效时,Cron 消息转发的备用飞书 chat_id': 'Fallback Feishu chat_id used when the original chat becomes unavailable', + '原窗口失效时,Cron 消息转发的备用 Discord 频道/私聊 ID': 'Fallback Discord channel/direct-chat ID used when the original chat becomes unavailable', + '频道ID或私聊ID': 'Channel ID or direct-chat ID', + '端口(RELIABILITY_CRON_API_PORT)': 'Port (RELIABILITY_CRON_API_PORT)', + '监听地址(RELIABILITY_CRON_API_HOST)': 'Host (RELIABILITY_CRON_API_HOST)', + '留空则不需要认证': 'Leave blank to disable authentication', + '仅 localhost': 'Localhost only', + '允许远程': 'Allow remote', + '告警推送 Chat(RELIABILITY_HEARTBEAT_ALERT_CHATS)': 'Alert Chat Targets (RELIABILITY_HEARTBEAT_ALERT_CHATS)', + 'companion(可选)': 'companion (optional)', + '心跳探活': 'Heartbeat Checks', + '主动心跳定时器(RELIABILITY_PROACTIVE_HEARTBEAT_ENABLED)': 'Proactive Heartbeat Timer (RELIABILITY_PROACTIVE_HEARTBEAT_ENABLED)', + '入站触发心跳(RELIABILITY_INBOUND_HEARTBEAT_ENABLED)': 'Inbound-triggered Heartbeat (RELIABILITY_INBOUND_HEARTBEAT_ENABLED)', + '心跳间隔(RELIABILITY_HEARTBEAT_INTERVAL_MS)': 'Heartbeat Interval (RELIABILITY_HEARTBEAT_INTERVAL_MS)', + '心跳 Agent(RELIABILITY_HEARTBEAT_AGENT)': 'Heartbeat Agent (RELIABILITY_HEARTBEAT_AGENT)', + '心跳提示词(RELIABILITY_HEARTBEAT_PROMPT)': 'Heartbeat Prompt (RELIABILITY_HEARTBEAT_PROMPT)', + '宕机救援策略': 'Recovery Strategy', + '可靠性模式(RELIABILITY_MODE)': 'Reliability Mode (RELIABILITY_MODE)', + 'observe — 观察模式(仅记录,不干预)': 'observe — observation mode (log only, no intervention)', + 'shadow — 影子模式(记录+模拟决策)': 'shadow — shadow mode (log plus simulated decisions)', + 'active — 主动救援模式': 'active — active recovery mode', + '连续失败阈值(RELIABILITY_FAILURE_THRESHOLD)': 'Consecutive Failure Threshold (RELIABILITY_FAILURE_THRESHOLD)', + '失败统计窗口(RELIABILITY_WINDOW_MS)': 'Failure Window (RELIABILITY_WINDOW_MS)', + '救援冷却时间(RELIABILITY_COOLDOWN_MS)': 'Recovery Cooldown (RELIABILITY_COOLDOWN_MS)', + '救援预算(RELIABILITY_REPAIR_BUDGET)': 'Recovery Budget (RELIABILITY_REPAIR_BUDGET)', + '仅本地救援(RELIABILITY_LOOPBACK_ONLY)': 'Loopback-only Recovery (RELIABILITY_LOOPBACK_ONLY)', + '核心行为配置': 'Core Behavior Settings', + '配置路由模式、输出显示、工作目录、工具白名单等核心行为参数': 'Configure routing mode, output display, working directories, and tool allowlists.', + '路由模式与会话': 'Routing Mode and Sessions', + '路由模式(ROUTER_MODE)': 'Routing Mode (ROUTER_MODE)', + 'legacy — 稳定模式(推荐生产使用)': 'legacy — stable mode (recommended for production)', + 'dual — 双轨对比模式(记录新旧路由差异日志)': 'dual — dual-track mode (records old/new routing diffs)', + 'router — 新路由模式(实验性)': 'router — new routing mode (experimental)', + '稳定模式(推荐生产使用)': 'Stable mode (recommended for production)', + '双轨对比模式(记录新旧路由差异日志)': 'Dual-track comparison mode (records diff logs between old and new routing)', + '新路由模式(实验性)': 'New routing mode (experimental)', + '允许手动绑定已有会话(ENABLE_MANUAL_SESSION_BIND)': 'Allow Manual Binding to Existing Sessions (ENABLE_MANUAL_SESSION_BIND)', + '控制消息路由引擎,legacy 是默认稳定模式;dual 可用于灰度验证新路由': 'Controls the message routing engine. legacy is the default stable mode; dual can be used to validate the new router in shadow traffic.', + '开启后用户可以通过 /session <sessionId> 绑定已有 OpenCode 会话,': 'When enabled, users can bind an existing OpenCode session via /session <sessionId>,', + '关闭后建群卡片仅显示「新建会话」': 'when disabled, the group-create card only shows "New Session".', + '💡 提示:默认模型配置请在 ': 'Tip: configure the default model on the ', + ' 页面中设置': ' page.', + '输出显示控制': 'Output Display Controls', + '全局:显示思维链(SHOW_THINKING_CHAIN)': 'Global: Show Reasoning (SHOW_THINKING_CHAIN)', + '全局:显示工具调用链(SHOW_TOOL_CHAIN)': 'Global: Show Tool Calls (SHOW_TOOL_CHAIN)', + '全局默认值,控制是否在消息中显示 AI 的思维链(thinking 内容块)': 'Global default that controls whether AI reasoning blocks are shown in messages.', + '全局默认值,控制是否显示工具调用过程(如 Read、Glob 等)': 'Global default that controls whether tool call traces are shown, such as Read and Glob.', + '平台独立配置(覆盖全局,未设置则继承全局)': 'Platform-specific overrides (inherit the global setting when unset)', + '工作目录与项目': 'Working Directories and Projects', + '允许的目录白名单(ALLOWED_DIRECTORIES)': 'Allowed Directory Allowlist (ALLOWED_DIRECTORIES)', + '默认工作目录(DEFAULT_WORK_DIRECTORY)': 'Default Working Directory (DEFAULT_WORK_DIRECTORY)', + '项目别名映射(PROJECT_ALIASES)': 'Project Alias Mapping (PROJECT_ALIASES)', + 'Git Root 归一化(GIT_ROOT_NORMALIZATION)': 'Git Root Normalization (GIT_ROOT_NORMALIZATION)', + '自动归一': 'Auto-normalize', + '逗号分隔的绝对路径列表。未配置时禁止用户自定义路径,也无法使用 /send 发送任意路径文件': 'Comma-separated absolute paths. If unset, custom user paths are disabled, and /send cannot send arbitrary-path files.', + '最低优先级兜底目录,未配置则跟随 OpenCode 服务端默认目录': 'Lowest-priority fallback directory. If unset, the OpenCode server default directory is used.', + 'JSON 格式,短名 → 绝对路径。用户可通过 /session new 短名 快速创建会话': 'JSON format: alias → absolute path. Users can quickly create sessions with /session new alias.', + '开启后用户指定子目录时自动提升到 Git 仓库根目录': 'When enabled, a user-selected subdirectory is automatically promoted to the Git repository root.', + '工具与权限': 'Tools and Permissions', + '工具白名单(TOOL_WHITELIST)': 'Tool Allowlist (TOOL_WHITELIST)', + '权限请求超时(PERMISSION_REQUEST_TIMEOUT_MS)': 'Permission Request Timeout (PERMISSION_REQUEST_TIMEOUT_MS)', + '输出刷新间隔(OUTPUT_UPDATE_INTERVAL)': 'Output Refresh Interval (OUTPUT_UPDATE_INTERVAL)', + '延迟响应超时(MAX_DELAYED_RESPONSE_WAIT_MS)': 'Delayed Response Timeout (MAX_DELAYED_RESPONSE_WAIT_MS)', + '附件大小上限(ATTACHMENT_MAX_SIZE)': 'Attachment Size Limit (ATTACHMENT_MAX_SIZE)', + '逗号分隔的工具名称,列在此处的工具权限请求会被桥接层自动放行,无需用户确认': 'Comma-separated tool names. Permission requests for listed tools are automatically allowed by the bridge without user confirmation.', + '0(不超时)': '0 (no timeout)', + '权限请求在桥接侧保留的最长时长(毫秒)。<= 0 表示无限等待用户回复': 'Maximum time a permission request is kept on the bridge side in milliseconds. <= 0 means wait indefinitely for user input.', + '流式输出卡片刷新频率(毫秒),默认 3000ms': 'Refresh interval of streaming output cards in milliseconds. Default: 3000ms.', + '延迟响应模式下最长等待时间(毫秒),默认 120000(2分钟)': 'Maximum wait time in delayed-response mode in milliseconds. Default: 120000 (2 minutes).', + '单个附件的最大字节数,默认 52428800(50MB)': 'Maximum bytes per attachment. Default: 52428800 (50MB).', + '配置已保存': 'Configuration saved', + '保存配置': 'Save Config', + '取消修改': 'Discard Changes', + '导出配置': 'Export Config', + '导入配置': 'Import Config', + '配置已导出': 'Configuration exported', + '导入配置将覆盖当前页面的所有配置项,是否继续?': 'Importing will overwrite all configuration items on this page. Continue?', + '确认导入': 'Confirm Import', + '配置已导入': 'Configuration imported', + '配置文件格式错误: ': 'Invalid configuration file: ', + '管理各平台会话与 OpenCode Session 之间的绑定关系': 'Manage bindings between platform conversations and OpenCode sessions', + '新增绑定': 'New Binding', + '已绑定': 'Bound', + '绑定关系数': 'Binding Count', + 'OpenCode 服务不可用': 'OpenCode Service Unavailable', + '无法连接到 OpenCode 服务,仅显示本地绑定数据。部分功能可能受限。': 'Unable to connect to OpenCode. Only local binding data is shown and some features may be limited.', + '全部平台': 'All Platforms', + '已选择 ': 'Selected ', + ' 项': ' item(s)', + '批量解绑': 'Batch Unbind', + '批量删除': 'Batch Delete', + '取消选择': 'Clear Selection', + '暂无会话数据': 'No session data', + '标题': 'Title', + '未命名会话': 'Untitled Session', + '工作目录': 'Working Directory', + '仅本地': 'Local Only', + '绑定详情': 'Binding Details', + '私聊': 'Direct Chat', + '群聊': 'Group Chat', + '频道': 'Channel', + '更改': 'Change', + '绑定平台会话': 'Bind Platform Conversation', + '飞书 API 限制:仅支持扫描群聊,无法获取私聊列表': 'Feishu API limitation: only group chats can be scanned; direct chats are unavailable.', + '选择平台聊天': 'Select a platform chat', + '该平台暂无可用聊天,请手动输入会话 ID': 'No chats are available for this platform. Enter a session ID manually.', + '选择平台聊天或手动输入会话 ID': 'Select a platform chat or enter the session ID manually.', + '会话类型': 'Session Type', + '选择聊天后自动识别': 'Detected automatically after selecting a chat', + '未指定': 'Unspecified', + '创建者 ID': 'Creator ID', + '请选择 Session': 'Select a session', + '请选择或输入会话 ID': 'Select or enter a session ID', + '获取平台聊天列表失败,请手动输入会话 ID': 'Failed to load platform chats. Enter the session ID manually.', + '绑定已创建': 'Binding created', + 'OpenCode 服务不可用,仅显示本地绑定数据': 'OpenCode is unavailable. Only local binding data is shown.', + '此 Session 仅存在于本地,确定要清除吗?': 'This session only exists locally. Remove it?', + '确定要删除此 Session 吗?': 'Delete this session?', + '此 Session 仅存在于本地绑定,OpenCode 中已不存在。确定要清除 ': 'This session only exists in local bindings and no longer exists in OpenCode. Remove ', + '这将同时解除 ': 'This will also remove ', + ' 个绑定关系。': ' bindings.', + ' 个绑定关系吗?': ' bindings?', + '确定要解除以下所有绑定吗?': 'Remove all bindings below?', + '获取平台聊天列表失败:': 'Failed to load platform chats:', + '解除绑定失败: ': 'Failed to unbind: ', + '确认解除绑定': 'Confirm Unbind', + 'Session 已删除': 'Session deleted', + '选中的 Session 没有绑定关系': 'The selected sessions have no bindings', + '批量解绑失败: ': 'Batch unbind failed: ', + '批量删除失败: ': 'Batch delete failed: ', + 'Agent 列表加载失败': 'Failed to load agents', + '当前还没有 TodoWrite 任务。触发相关工具后会在这里同步展示。': 'There are no TodoWrite tasks yet. They will appear here after the relevant tool runs.', + '搜索 (Ctrl+K)': 'Search (Ctrl+K)', + '条': 'items', + '字': 'chars', + '已复制': 'Copied', + '收起推理过程': 'Collapse Reasoning', +} + +export const EN_TRANSLATION_PATTERNS: Array<{ + regex: RegExp + replace: (...args: string[]) => string +}> = [ + { + regex: /^(\d+)\s*秒$/, + replace: (_match, value) => `${value} sec`, + }, + { + regex: /^(\d+)\s*分钟$/, + replace: (_match, value) => `${value} min`, + }, + { + regex: /^(\d+)\s*小时$/, + replace: (_match, value) => `${value} hr`, + }, + { + regex: /^(\d+)\s*天$/, + replace: (_match, value) => `${value} d`, + }, + { + regex: /^(\d+)\s*天(\d+)\s*小时$/, + replace: (_match, days, hours) => `${days} d ${hours} hr`, + }, + { + regex: /^(\d+)\s*分钟前$/, + replace: (_match, value) => `${value} min ago`, + }, + { + regex: /^(\d+)\s*分钟后$/, + replace: (_match, value) => `in ${value} min`, + }, + { + regex: /^(\d+)\s*小时前$/, + replace: (_match, value) => `${value} hr ago`, + }, + { + regex: /^(\d+)\s*小时后$/, + replace: (_match, value) => `in ${value} hr`, + }, + { + regex: /^(\d+)\s*天前$/, + replace: (_match, value) => `${value} d ago`, + }, + { + regex: /^(\d+)\s*条$/, + replace: (_match, value) => `${value} items`, + }, + { + regex: /^(\d+)\s*字$/, + replace: (_match, value) => `${value} chars`, + }, + { + regex: /^(\d+)\s*项调用$/, + replace: (_match, value) => `${value} calls`, + }, + { + regex: /^(\d+)\s*运行中$/, + replace: (_match, value) => `${value} running`, + }, + { + regex: /^(\d+)\s*已完成$/, + replace: (_match, value) => `${value} completed`, + }, + { + regex: /^(\d+)\s*失败$/, + replace: (_match, value) => `${value} failed`, + }, + { + regex: /^已选择\s*(\d+)\s*项$/, + replace: (_match, value) => `${value} selected`, + }, + { + regex: /^已解除\s*(\d+)\s*个绑定$/, + replace: (_match, value) => `Removed ${value} bindings`, + }, + { + regex: /^已删除\s*(\d+)\s*个 Session$/, + replace: (_match, value) => `Deleted ${value} sessions`, + }, + { + regex: /^创建失败:\s*/, + replace: () => 'Create failed: ', + }, + { + regex: /^检查更新失败:\s*/, + replace: () => 'Update check failed: ', + }, + { + regex: /^重启失败:\s*/, + replace: () => 'Restart failed: ', + }, + { + regex: /^保存失败:\s*/, + replace: () => 'Save failed: ', + }, + { + regex: /^安装失败:\s*/, + replace: () => 'Install failed: ', + }, + { + regex: /^升级失败:\s*/, + replace: () => 'Upgrade failed: ', + }, + { + regex: /^启动失败:\s*/, + replace: () => 'Start failed: ', + }, + { + regex: /^终止失败:\s*/, + replace: () => 'Stop failed: ', + }, + { + regex: /^终止 Bridge 失败:\s*/, + replace: () => 'Failed to stop Bridge: ', + }, + { + regex: /^删除失败:\s*/, + replace: () => 'Delete failed: ', + }, + { + regex: /^加载数据失败:\s*/, + replace: () => 'Failed to load data: ', + }, + { + regex: /^操作失败:\s*/, + replace: () => 'Operation failed: ', + }, +] diff --git a/web/src/i18n/runtime.ts b/web/src/i18n/runtime.ts new file mode 100644 index 0000000..1962524 --- /dev/null +++ b/web/src/i18n/runtime.ts @@ -0,0 +1,256 @@ +import { computed, ref, watch } from 'vue' +import enUS from 'element-plus/es/locale/lang/en' +import zhCN from 'element-plus/es/locale/lang/zh-cn' +import { EN_TRANSLATION_MAP, EN_TRANSLATION_PATTERNS } from './packs/en' + +export type AppLocale = 'zh-CN' | 'en-US' + +const LOCALE_STORAGE_KEY = 'opencode_bridge_locale' +const TEXT_SKIP_SELECTOR = [ + '[data-i18n-skip]', + '.markdown-body', + '.code-block', + '.code-content', + '.streaming-message', + '.tree-label', + '.create-dialog-path', + '.branch-name', + '.branch-option', + '.tree-item__path', + '.timeline-node__msg', + '.detail-card__title', + '.info-value--mono', + '.job-id', + '.job-name', + '.log-message', + '.log-source', + '.session-meta', + '.session-dir', + '.title-text', + '.wxid', + 'pre', + 'code', + '.terminal-stream', + '.prompt-command', + '.prompt-path', +].join(', ') + +const ATTRIBUTE_SKIP_SELECTOR = [ + '[data-i18n-skip]', + '.markdown-body', + '.code-block', + '.code-content', + '.create-dialog-path', + '.job-id', + '.job-name', + '.log-message', + '.log-source', + '.session-meta', + '.session-dir', + '.title-text', + '.wxid', + '.terminal-stream', +].join(', ') + +const TRANSLATABLE_ATTRIBUTES = ['placeholder', 'title', 'aria-label', 'alt'] as const +const sortedTranslationEntries = Object.entries(EN_TRANSLATION_MAP).sort((left, right) => right[0].length - left[0].length) + +const textOriginals = new WeakMap() +const attributeOriginals = new WeakMap>() + +function loadInitialLocale(): AppLocale { + if (typeof window === 'undefined') { + return 'zh-CN' + } + + const saved = window.localStorage.getItem(LOCALE_STORAGE_KEY) + return saved === 'en-US' ? 'en-US' : 'zh-CN' +} + +export const appLocale = ref(loadInitialLocale()) + +export const elementPlusLocale = computed(() => appLocale.value === 'en-US' ? enUS : zhCN) + +export function setAppLocale(nextLocale: AppLocale): void { + appLocale.value = nextLocale === 'en-US' ? 'en-US' : 'zh-CN' +} + +export function getActiveDateLocale(): string { + return appLocale.value === 'en-US' ? 'en-US' : 'zh-CN' +} + +export function isEnglishLocale(): boolean { + return appLocale.value === 'en-US' +} + +function translateToEnglish(source: string): string { + if (!source) { + return source + } + + let output = source + for (const [zhText, enText] of sortedTranslationEntries) { + if (output.includes(zhText)) { + output = output.split(zhText).join(enText) + } + } + + for (const pattern of EN_TRANSLATION_PATTERNS) { + output = output.replace(pattern.regex, (...args) => pattern.replace(...(args as string[]))) + } + + return output +} + +export function translateUiText(source: string): string { + return appLocale.value === 'en-US' ? translateToEnglish(source) : source +} + +function syncDocumentLanguage(): void { + if (typeof document === 'undefined') { + return + } + + document.documentElement.lang = appLocale.value === 'en-US' ? 'en' : 'zh-CN' +} + +function shouldSkipTextNode(node: Text): boolean { + const parent = node.parentElement + return Boolean(parent?.closest(TEXT_SKIP_SELECTOR)) +} + +function shouldSkipAttribute(element: HTMLElement): boolean { + return Boolean(element.closest(ATTRIBUTE_SKIP_SELECTOR)) +} + +function translateTextNode(node: Text): void { + if (shouldSkipTextNode(node)) { + return + } + + const currentValue = node.nodeValue ?? '' + const previousOriginal = textOriginals.get(node) + if (!previousOriginal || currentValue !== translateToEnglish(previousOriginal)) { + textOriginals.set(node, currentValue) + } + + const original = textOriginals.get(node) ?? currentValue + const nextValue = translateUiText(original) + if (currentValue !== nextValue) { + node.nodeValue = nextValue + } +} + +function translateElementAttributes(element: HTMLElement): void { + if (shouldSkipAttribute(element)) { + return + } + + let originals = attributeOriginals.get(element) + if (!originals) { + originals = new Map() + attributeOriginals.set(element, originals) + } + + for (const attribute of TRANSLATABLE_ATTRIBUTES) { + const currentValue = element.getAttribute(attribute) + if (currentValue == null) { + originals.delete(attribute) + continue + } + + const previousOriginal = originals.get(attribute) + if (!previousOriginal || currentValue !== translateToEnglish(previousOriginal)) { + originals.set(attribute, currentValue) + } + + const original = originals.get(attribute) ?? currentValue + const nextValue = translateUiText(original) + if (currentValue !== nextValue) { + element.setAttribute(attribute, nextValue) + } + } +} + +function translateTree(root: Node): void { + if (root.nodeType === Node.TEXT_NODE) { + translateTextNode(root as Text) + return + } + + if (root.nodeType !== Node.ELEMENT_NODE) { + return + } + + const element = root as HTMLElement + translateElementAttributes(element) + for (const child of Array.from(element.childNodes)) { + translateTree(child) + } +} + +let observer: MutationObserver | null = null +let syncing = false +let initialized = false + +export function installRuntimeLocaleOverlay(): void { + if (typeof document === 'undefined' || initialized) { + return + } + + initialized = true + syncDocumentLanguage() + + const runSync = (target?: Node): void => { + syncing = true + try { + translateTree(target ?? document.body) + } finally { + syncing = false + } + } + + observer = new MutationObserver(records => { + if (syncing) { + return + } + + for (const record of records) { + if (record.type === 'characterData') { + runSync(record.target) + continue + } + + if (record.type === 'attributes') { + runSync(record.target) + continue + } + + if (record.type === 'childList') { + for (const addedNode of Array.from(record.addedNodes)) { + runSync(addedNode) + } + } + } + }) + + observer.observe(document.body, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeFilter: [...TRANSLATABLE_ATTRIBUTES], + }) + + watch( + appLocale, + locale => { + if (typeof window !== 'undefined') { + window.localStorage.setItem(LOCALE_STORAGE_KEY, locale) + } + syncDocumentLanguage() + runSync() + }, + { immediate: true } + ) +} diff --git a/web/src/main.ts b/web/src/main.ts index 76c8357..fa75831 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,20 +1,19 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' -import ElementPlus from 'element-plus' -import 'element-plus/dist/index.css' -import * as ElementPlusIconsVue from '@element-plus/icons-vue' +// EP component CSS is auto-imported per-component by unplugin-vue-components +// Only load the base/reset styles that EP needs globally +import 'element-plus/theme-chalk/el-message.css' +import 'element-plus/theme-chalk/el-message-box.css' +import 'element-plus/theme-chalk/base.css' import { router } from './router/index' import App from './App.vue' +import { installRuntimeLocaleOverlay } from './i18n/runtime' const app = createApp(App) const pinia = createPinia() app.use(pinia) app.use(router) -app.use(ElementPlus) - -for (const [key, component] of Object.entries(ElementPlusIconsVue)) { - app.component(key, component) -} app.mount('#app') +installRuntimeLocaleOverlay() diff --git a/web/src/router/index.ts b/web/src/router/index.ts index c927e3d..ab088a2 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -1,11 +1,10 @@ import { createRouter, createWebHistory } from 'vue-router' -import axios from 'axios' const routes = [ { path: '/', redirect: '/dashboard' }, - { path: '/login', component: () => import('../views/Login.vue'), meta: { title: '登录' } }, - { path: '/change-password', component: () => import('../views/ChangePassword.vue'), meta: { title: '修改密码' } }, { path: '/dashboard', component: () => import('../views/Dashboard.vue'), meta: { title: '系统状态' } }, + { path: '/chat', component: () => import('../views/chat/ChatWorkspace.vue'), meta: { title: 'AI 工作区' } }, + { path: '/chat/:sessionId', component: () => import('../views/chat/ChatWorkspace.vue'), meta: { title: 'AI 工作区' } }, { path: '/platforms', component: () => import('../views/Platforms.vue'), meta: { title: '平台接入' } }, { path: '/sessions', component: () => import('../views/Sessions.vue'), meta: { title: 'Session 管理' } }, { path: '/opencode', component: () => import('../views/OpenCode.vue'), meta: { title: 'OpenCode 对接' } }, @@ -13,89 +12,14 @@ const routes = [ { path: '/routing', component: () => import('../views/CoreRouting.vue'), meta: { title: '核心行为' } }, { path: '/cron', component: () => import('../views/CronJobs.vue'), meta: { title: 'Cron 任务管理' } }, { path: '/logs', component: () => import('../views/Logs.vue'), meta: { title: '日志管理' } }, + { path: '/resources', component: () => import('../views/resources/index.vue'), meta: { title: '资源管理' } }, { path: '/settings', component: () => import('../views/Settings.vue'), meta: { title: '系统设置' } }, + // 兼容旧书签:登录 / 修改密码相关路由已移除,统一重定向至 dashboard + { path: '/login', redirect: '/dashboard' }, + { path: '/change-password', redirect: '/dashboard' }, ] export const router = createRouter({ history: createWebHistory(), routes, }) - -// 检查密码状态(用于判断是否需要清除旧 token) -async function checkPasswordStatus(): Promise<{ hasPassword: boolean; needsPasswordChange: boolean }> { - const token = localStorage.getItem('admin_token') - try { - const http = axios.create({ - baseURL: '/api', - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }) - const { data } = await http.get('/admin/password-status') - return { hasPassword: data.hasPassword, needsPasswordChange: data.needsPasswordChange } - } catch { - return { hasPassword: true, needsPasswordChange: false } - } -} - -// 鉴权守卫 -router.beforeEach(async (to, _from, next) => { - const token = localStorage.getItem('admin_token') - - // 登录页 - if (to.path === '/login') { - if (token) { - // 有旧 token,检查密码是否已被重置 - const { hasPassword } = await checkPasswordStatus() - if (!hasPassword) { - // 密码已重置,清除旧 token,允许访问登录页 - localStorage.removeItem('admin_token') - next() - } else { - // 密码正常,跳转到 dashboard - next('/dashboard') - } - } else { - next() - } - return - } - - // 修改密码页 - if (to.path === '/change-password') { - // 首次设置密码模式(mode=setup)允许无 token 访问 - if (to.query.mode === 'setup') { - next() - return - } - if (!token) { - next('/login') - } else { - next() - } - return - } - - // 其他页面需要登录 - if (!token) { - next('/login') - return - } - - // 检查是否需要强制修改密码 - try { - const { needsPasswordChange, hasPassword } = await checkPasswordStatus() - // 密码已被重置,清除旧 token 并跳转到登录页 - if (!hasPassword) { - localStorage.removeItem('admin_token') - next('/login') - return - } - if (needsPasswordChange) { - next('/change-password') - return - } - } catch { - // 忽略错误,继续导航 - } - - next() -}) \ No newline at end of file diff --git a/web/src/stores/config.ts b/web/src/stores/config.ts index 855b22b..ad28e55 100644 --- a/web/src/stores/config.ts +++ b/web/src/stores/config.ts @@ -68,7 +68,8 @@ export const useConfigStore = defineStore('config', () => { const telegramSessions = (data.telegram || []).map(s => ({ ...s, platform: 'telegram' as const })) const qqSessions = (data.qq || []).map(s => ({ ...s, platform: 'qq' as const })) const whatsappSessions = (data.whatsapp || []).map(s => ({ ...s, platform: 'whatsapp' as const })) - sessions.value = [...feishuSessions, ...discordSessions, ...wecomSessions, ...telegramSessions, ...qqSessions, ...whatsappSessions] + const weixinSessions = (data.weixin || []).map(s => ({ ...s, platform: 'weixin' as const })) + sessions.value = [...feishuSessions, ...discordSessions, ...wecomSessions, ...telegramSessions, ...qqSessions, ...whatsappSessions, ...weixinSessions] } async function fetchModels() { diff --git a/web/src/stores/resources.ts b/web/src/stores/resources.ts new file mode 100644 index 0000000..b9e86fe --- /dev/null +++ b/web/src/stores/resources.ts @@ -0,0 +1,121 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { resourcesApi, type ResourceStats } from '../api/resources' + +export interface ResourceEvent { + kind: string + type?: string + name?: string + action?: string +} + +export const useResourcesStore = defineStore('resources', () => { + const resources = ref>({}) + const stats = ref({ + skills: 0, + mcp: 0, + agents: 0, + providers: 0, + }) + const loading = ref(false) + const initialized = ref(false) + const subscribed = ref(false) + + let eventSource: EventSource | null = null + let reconnectTimeout: ReturnType | null = null + + async function loadStats() { + loading.value = true + try { + const data = await resourcesApi.getStats() + stats.value = data + } catch (e) { + console.error('Failed to load stats:', e) + } finally { + loading.value = false + } + } + + function handleSSEMessage(event: MessageEvent) { + try { + const data: ResourceEvent = JSON.parse(event.data) + // Update stats when resource changes occur + if (['skill', 'mcp', 'agents', 'agent', 'provider'].includes(data.kind)) { + loadStats() + // Invalidate slash command cache when resources change + import('../views/chat/slash-command-cache.js').then(({ invalidateCommandsCache }) => { + invalidateCommandsCache() + }).catch(err => { + console.warn('Failed to invalidate slash command cache:', err) + }) + } + } catch (err) { + console.error('Failed to parse SSE event:', err) + } + } + + function handleSSEError() { + console.error('SSE error, reconnecting...') + // Reconnect after delay + if (reconnectTimeout) { + clearTimeout(reconnectTimeout) + } + reconnectTimeout = setTimeout(() => { + if (subscribed.value) { + setupEventSource() + } + }, 5000) + } + + function setupEventSource() { + if (eventSource) { + eventSource.close() + } + + try { + eventSource = new EventSource('/api/resources/events') + eventSource.addEventListener('message', handleSSEMessage) + eventSource.onerror = handleSSEError + subscribed.value = true + } catch (err) { + console.error('Failed to setup SSE:', err) + handleSSEError() + } + } + + function unsubscribe() { + subscribed.value = false + if (reconnectTimeout) { + clearTimeout(reconnectTimeout) + reconnectTimeout = null + } + if (eventSource) { + eventSource.close() + eventSource = null + } + } + + async function initialize() { + if (initialized.value) return + loading.value = true + try { + await loadStats() + setupEventSource() + initialized.value = true + } finally { + loading.value = false + } + } + + return { + resources, + stats, + loading, + initialized, + subscribed, + loadStats, + setupEventSource, + unsubscribe, + initialize, + } +}) diff --git a/web/src/types/markdown-it.d.ts b/web/src/types/markdown-it.d.ts new file mode 100644 index 0000000..6414e9d --- /dev/null +++ b/web/src/types/markdown-it.d.ts @@ -0,0 +1,4 @@ +declare module 'markdown-it' { + const MarkdownIt: any + export default MarkdownIt +} diff --git a/web/src/views/ChangePassword.vue b/web/src/views/ChangePassword.vue deleted file mode 100644 index abe24ef..0000000 --- a/web/src/views/ChangePassword.vue +++ /dev/null @@ -1,223 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/views/CoreRouting.vue b/web/src/views/CoreRouting.vue index f22c980..06dd407 100644 --- a/web/src/views/CoreRouting.vue +++ b/web/src/views/CoreRouting.vue @@ -242,14 +242,14 @@
- 逗号分隔的绝对路径列表。未配置时禁止用户自定义路径,也无法使用 /send 发送任意路径文件 + 逗号分隔的绝对路径列表。仅约束平台接入(Telegram/Discord/QQ/企业微信/微信/WhatsApp/钉钉/飞书 等外部消息入口以及 /send 文件下发);AI 工作区不受此限制,可浏览服务进程可访问的任意目录。未配置时禁止平台侧用户自定义路径
-
最低优先级兜底目录,未配置则跟随 OpenCode 服务端默认目录
+
平台接入的最低优先级兜底目录,未配置则跟随 OpenCode 服务端默认目录。AI 工作区不使用该兜底
@@ -327,6 +327,7 @@ diff --git a/web/src/views/Login.vue b/web/src/views/Login.vue deleted file mode 100644 index 7ac96b5..0000000 --- a/web/src/views/Login.vue +++ /dev/null @@ -1,174 +0,0 @@ - - - - - diff --git a/web/src/views/Logs.vue b/web/src/views/Logs.vue index db746c9..0dce582 100644 --- a/web/src/views/Logs.vue +++ b/web/src/views/Logs.vue @@ -123,6 +123,7 @@ import { ref, onMounted, onUnmounted, watch } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { Refresh, Download, Delete, Search } from '@element-plus/icons-vue' import { configApi, type LogEntry, type LogStats, type LogLevel } from '../api/index' +import { getActiveDateLocale } from '../i18n/runtime' const loading = ref(false) const logs = ref([]) @@ -227,7 +228,7 @@ function handleExport() { function formatTime(iso: string): string { const d = new Date(iso) - return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + return d.toLocaleTimeString(getActiveDateLocale(), { hour: '2-digit', minute: '2-digit', second: '2-digit' }) } function getLevelType(level: LogLevel): 'danger' | 'warning' | 'primary' | 'info' { @@ -281,4 +282,4 @@ function getRowClass({ row }: { row: LogEntry }): string { :deep(.row-error) { background-color: #fef0f0 !important; } :deep(.row-warn) { background-color: #fdf6ec !important; } - \ No newline at end of file + diff --git a/web/src/views/OpenCode.vue b/web/src/views/OpenCode.vue index fc7c5c6..df2d3b0 100644 --- a/web/src/views/OpenCode.vue +++ b/web/src/views/OpenCode.vue @@ -66,18 +66,24 @@ @change="form.OPENCODE_AUTO_START = autoStart ? 'true' : 'false'" /> - - 开启后,Bridge 启动时会自动执行下方命令拉起 OpenCode 后台进程 + + 开启后,Bridge 启动时会自动以后台无窗口模式拉起 opencode serve + (幂等:已运行则跳过) - - - - -
默认为 opencode serve(headless 后台模式),可自定义完整命令
-
-
-
+ + +
+
+ 同时打开前台窗口 + 后台启动成功后额外弹出 CMD 窗口执行 opencode attach http://localhost:{{ portNum }}(仅 Windows) +
+ +
@@ -90,7 +96,7 @@ - +
选择模型供应商
@@ -99,13 +105,155 @@ - +
选择要使用的具体模型
+ + + + + 当主模型不支持图片输入时,Bridge 自动借用下方指定的多模态 model 做 OCR / 图片描述, + 把识别结果作为文本注入后转发给主模型;主模型本身支持图片则直接透传,不走此路径。 + OCR 失败会自动降级为"直发原图"保持原有行为。 + + + + + + +
+ 下拉选项来自 opencode 已配置的、capabilities.input.image 为 true 的 model。 + 如列表为空,请先在 opencode 的 provider 配置中启用任一多模态模型。 +
+
+ + + +
留空将使用默认提示词。建议要求模型输出中文、尽量完整转录文字与图表结构。
+
+
+ + + + + + 支持按提供商折叠/展开,也支持按当前筛选结果全选。刷新按钮会重新读取 OpenCode 当前运行时配置与最新 provider/model 目录,便于核对当前表单是否与运行时配置一致,但不会自动改动你当前的勾选结果。 + + +
+ +
+ + 全选当前筛选结果 + + 清空 +
+
+ +
+ 已选 {{ selectedModelKeys.length }} / {{ totalSelectableModelCount }} 个模型 + +
+ +
+ 未找到匹配的 provider / 模型。 +
+ +
+
+
+ + {{ provider.name }} + +
+ + + + + + {{ isProviderCollapsed(provider.id) ? '展开' : '收起' }} + +
+
+ +
+ +
+ {{ model.name }} + {{ model.id }} +
+
+
+
+
+
@@ -124,70 +272,306 @@ @@ -252,6 +639,135 @@ function handleImportConfig(config: typeof form) { .field-tip { font-size: 12px; color: #999; margin-top: 4px; line-height: 1.4; } code { background: #f0f0f0; padding: 1px 4px; border-radius: 3px; font-size: 11px; } +.switch-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 12px 0 4px; + border-top: 1px solid #f0f0f0; + margin-top: 4px; +} +.switch-row.disabled { + opacity: 0.45; + pointer-events: none; +} +.switch-label { + display: flex; + flex-direction: column; + gap: 4px; +} +.switch-title { + font-size: 14px; + font-weight: 500; + color: #303133; +} +.switch-desc { + font-size: 12px; + color: #999; + line-height: 1.5; +} + +.model-selection-toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.model-search-input { + flex: 1; +} + +.model-selection-actions { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.model-selection-summary { + font-size: 12px; + color: #666; + margin-bottom: 14px; +} + +.provider-sections { + display: flex; + flex-direction: column; + gap: 14px; +} + +.provider-section { + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; +} + +.provider-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + background: #fafafa; + border-bottom: 1px solid #edf0f3; +} + +.provider-count { + font-size: 12px; + color: #8a8f98; +} + +.provider-header-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.provider-model-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0; +} + +.provider-model-list :deep(.el-checkbox) { + margin-right: 0; + padding: 10px 14px; + border-top: 1px solid #f3f4f6; +} + +.provider-model-list :deep(.el-checkbox:first-child), +.provider-model-list :deep(.el-checkbox:nth-child(2)) { + border-top: none; +} + +.model-option-content { + display: flex; + flex-direction: column; + gap: 3px; + line-height: 1.35; +} + +.model-option-name { + font-size: 13px; + color: #1f2937; +} + +.model-option-id { + font-size: 11px; + color: #8a8f98; +} + +.empty-model-state { + padding: 18px 0; + color: #8a8f98; + text-align: center; + border: 1px dashed #dcdfe6; + border-radius: 8px; +} + @media (max-width: 900px) { .page-layout { flex-direction: column; @@ -261,5 +777,18 @@ code { background: #f0f0f0; padding: 1px 4px; border-radius: 3px; font-size: 11p position: static; order: -1; } + .model-selection-toolbar { + flex-direction: column; + align-items: stretch; + } + .model-selection-actions { + justify-content: space-between; + } + .provider-model-list { + grid-template-columns: 1fr; + } + .provider-model-list :deep(.el-checkbox:nth-child(2)) { + border-top: 1px solid #f3f4f6; + } } diff --git a/web/src/views/Platforms.vue b/web/src/views/Platforms.vue index dda94bd..b3ac7ef 100644 --- a/web/src/views/Platforms.vue +++ b/web/src/views/Platforms.vue @@ -544,6 +544,7 @@ import { ref, reactive, computed, onMounted, watch } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { Loading } from '@element-plus/icons-vue' +import type { BridgeSettings } from '../api' import { useConfigStore } from '../stores/config' import { weixinApi, type WeixinAccount, whatsappApi, type WhatsAppConnectionStatus, dingtalkApi, type DingtalkAccount } from '../api' import ConfigActionBar from '../components/ConfigActionBar.vue' @@ -890,7 +891,7 @@ async function handleSave() { } } -function handleImportConfig(config: typeof form) { +function handleImportConfig(config: BridgeSettings) { Object.assign(form, config) // 同步开关状态 feishuEnabled.value = form.FEISHU_ENABLED === 'true' diff --git a/web/src/views/Reliability.vue b/web/src/views/Reliability.vue index 3cb1b41..09bac38 100644 --- a/web/src/views/Reliability.vue +++ b/web/src/views/Reliability.vue @@ -218,6 +218,7 @@ + + diff --git a/web/src/views/chat/ChatView.vue b/web/src/views/chat/ChatView.vue new file mode 100644 index 0000000..235632a --- /dev/null +++ b/web/src/views/chat/ChatView.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/web/src/views/chat/ChatWorkspace.vue b/web/src/views/chat/ChatWorkspace.vue new file mode 100644 index 0000000..8dd0c7c --- /dev/null +++ b/web/src/views/chat/ChatWorkspace.vue @@ -0,0 +1,1559 @@ + + + + + diff --git a/web/src/views/chat/MessageInput.vue b/web/src/views/chat/MessageInput.vue new file mode 100644 index 0000000..41b455e --- /dev/null +++ b/web/src/views/chat/MessageInput.vue @@ -0,0 +1,976 @@ + + + + + diff --git a/web/src/views/chat/MessageItem.vue b/web/src/views/chat/MessageItem.vue new file mode 100644 index 0000000..603aefd --- /dev/null +++ b/web/src/views/chat/MessageItem.vue @@ -0,0 +1,377 @@ + + + + + diff --git a/web/src/views/chat/MessageList.vue b/web/src/views/chat/MessageList.vue new file mode 100644 index 0000000..e98bbe0 --- /dev/null +++ b/web/src/views/chat/MessageList.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/web/src/views/chat/PermissionDialog.vue b/web/src/views/chat/PermissionDialog.vue new file mode 100644 index 0000000..560d417 --- /dev/null +++ b/web/src/views/chat/PermissionDialog.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/web/src/views/chat/SessionHeader.vue b/web/src/views/chat/SessionHeader.vue new file mode 100644 index 0000000..2adce3e --- /dev/null +++ b/web/src/views/chat/SessionHeader.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/web/src/views/chat/SessionSidebar.vue b/web/src/views/chat/SessionSidebar.vue new file mode 100644 index 0000000..cc50ab4 --- /dev/null +++ b/web/src/views/chat/SessionSidebar.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/web/src/views/chat/SessionTreeNode.vue b/web/src/views/chat/SessionTreeNode.vue new file mode 100644 index 0000000..45767a2 --- /dev/null +++ b/web/src/views/chat/SessionTreeNode.vue @@ -0,0 +1,381 @@ + + + + + diff --git a/web/src/views/chat/StreamingMessage.vue b/web/src/views/chat/StreamingMessage.vue new file mode 100644 index 0000000..841f5b9 --- /dev/null +++ b/web/src/views/chat/StreamingMessage.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/web/src/views/chat/TaskPanel.vue b/web/src/views/chat/TaskPanel.vue new file mode 100644 index 0000000..385ba1d --- /dev/null +++ b/web/src/views/chat/TaskPanel.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/web/src/views/chat/TaskPlanPreview.vue b/web/src/views/chat/TaskPlanPreview.vue new file mode 100644 index 0000000..d4d4829 --- /dev/null +++ b/web/src/views/chat/TaskPlanPreview.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/web/src/views/chat/TurnItem.vue b/web/src/views/chat/TurnItem.vue new file mode 100644 index 0000000..7f77ad9 --- /dev/null +++ b/web/src/views/chat/TurnItem.vue @@ -0,0 +1,465 @@ + + + + + diff --git a/web/src/views/chat/session-tree.ts b/web/src/views/chat/session-tree.ts new file mode 100644 index 0000000..d6dfb0e --- /dev/null +++ b/web/src/views/chat/session-tree.ts @@ -0,0 +1,13 @@ +import type { ChatSessionSummary } from '../../api' + +export interface SessionTreeNodeData { + id: string + type: 'folder' | 'session' + label: string + directory?: string + directoryLabel?: string + updatedAt?: number + count: number + session?: ChatSessionSummary + children: SessionTreeNodeData[] +} diff --git a/web/src/views/chat/side-panels/FileExplorer.vue b/web/src/views/chat/side-panels/FileExplorer.vue new file mode 100644 index 0000000..88c1c03 --- /dev/null +++ b/web/src/views/chat/side-panels/FileExplorer.vue @@ -0,0 +1,488 @@ + + + + + diff --git a/web/src/views/chat/side-panels/GitPanel.vue b/web/src/views/chat/side-panels/GitPanel.vue new file mode 100644 index 0000000..7d4fce4 --- /dev/null +++ b/web/src/views/chat/side-panels/GitPanel.vue @@ -0,0 +1,1641 @@ + + + + + diff --git a/web/src/views/chat/side-panels/TerminalPanel.vue b/web/src/views/chat/side-panels/TerminalPanel.vue new file mode 100644 index 0000000..74d42ef --- /dev/null +++ b/web/src/views/chat/side-panels/TerminalPanel.vue @@ -0,0 +1,527 @@ +