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
-[]()
+[]()
[](https://nodejs.org/)
[](https://www.typescriptlang.org/)
[](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).
+[](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
-[](https://github.com/HNGM-HP/opencode-bridge/blob/main)
+[](https://github.com/HNGM-HP/opencode-bridge/blob/main)
[](https://nodejs.org/)
[](https://www.typescriptlang.org/)
[](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 可视化界面截图(点击展开)
+



@@ -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 协议
+[](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