Merge branch 'sipeed:main' into main

This commit is contained in:
Nhat Tan 2026-03-25 11:37:11 +07:00 committed by GitHub
commit 9a1e27a3e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 885 additions and 181 deletions

View file

@ -0,0 +1,104 @@
> Back to [README](../../../README.md)
# WeCom
PicoClaw now exposes WeCom as a single `channels.wecom` channel built on the official WeCom AI Bot WebSocket API.
This replaces the legacy `wecom`, `wecom_app`, and `wecom_aibot` split with one configuration model.
## What This Channel Supports
- Direct chat and group chat delivery
- Channel-side streaming replies over WeCom's AI Bot protocol
- Incoming text, voice, image, file, video, and mixed messages
- Outbound text and media replies (`image`, `file`, `voice`, `video`)
- QR-based CLI onboarding with `picoclaw auth wecom`
- Shared allowlist and `reasoning_channel_id` routing
> No public webhook callback URL is required for this channel. PicoClaw opens an outbound WebSocket connection to WeCom.
## Quick Start
### Option 1: QR Login From CLI
Run:
```bash
picoclaw auth wecom
```
The command prints a QR code in the terminal, waits for confirmation in WeCom, and then writes the resulting
`bot_id` and `secret` into `channels.wecom`.
Use `--timeout` if you want to wait longer:
```bash
picoclaw auth wecom --timeout 10m
```
### Option 2: Configure Manually
```json
{
"channels": {
"wecom": {
"enabled": true,
"bot_id": "YOUR_BOT_ID",
"secret": "YOUR_SECRET",
"websocket_url": "wss://openws.work.weixin.qq.com",
"send_thinking_message": true,
"allow_from": [],
"reasoning_channel_id": ""
}
}
}
```
## Configuration
| Field | Type | Required | Description |
| ----- | ---- | -------- | ----------- |
| `enabled` | bool | No | Enables the WeCom channel. |
| `bot_id` | string | Yes | WeCom AI Bot identifier. Required when the channel is enabled. |
| `secret` | string | Yes | WeCom AI Bot secret. Required when the channel is enabled. |
| `websocket_url` | string | No | WebSocket endpoint. Defaults to `wss://openws.work.weixin.qq.com`. |
| `send_thinking_message` | bool | No | Sends an initial `Processing...` chunk before the final streamed reply. Defaults to `true`. |
| `allow_from` | array | No | Sender allowlist. Empty means allow all senders. |
| `reasoning_channel_id` | string | No | Optional destination for reasoning/thinking output. |
## Runtime Behavior
- PicoClaw keeps the active WeCom turn so normal replies can continue the same stream when possible.
- If streaming is no longer available, replies fall back to active push delivery to the resolved chat route.
- Incoming media is downloaded into the media store before being handed to the agent.
- Outbound media is uploaded to WeCom in temporary chunks and then sent as a regular media message.
## Migration Notes
This branch removes the old multi-channel WeCom model.
| Previous config | Now |
| --------------- | --- |
| `channels.wecom` webhook bot | Replace with `channels.wecom` using `bot_id` + `secret`. |
| `channels.wecom_app` | Remove it and use `channels.wecom`. |
| `channels.wecom_aibot` | Move the config to `channels.wecom`. |
| `token`, `encoding_aes_key`, `webhook_url`, `webhook_path` | No longer used by the WeCom channel. |
| `corp_id`, `corp_secret`, `agent_id` | No longer used by the WeCom channel. |
| `welcome_message`, `processing_message`, `max_steps` under WeCom | No longer part of the WeCom channel config. |
## Troubleshooting
### `picoclaw auth wecom` times out
- Re-run with a larger `--timeout`.
- Make sure the QR code was confirmed inside WeCom, not only scanned.
### WebSocket connection fails
- Verify `bot_id` and `secret`.
- Confirm the host can reach `wss://openws.work.weixin.qq.com`.
### Replies do not arrive
- Check whether `allow_from` blocks the sender.
- Check launcher or startup validation for missing `channels.wecom.bot_id` / `channels.wecom.secret`.

View file

@ -0,0 +1,104 @@
> 返回 [README](../../../README.zh.md)
# 企业微信
PicoClaw 现在将企业微信统一为一个 `channels.wecom` 渠道,并基于企业微信官方 AI Bot WebSocket 协议实现。
这取代了旧的 `wecom``wecom_app``wecom_aibot` 三套配置模型。
## 当前渠道能力
- 支持私聊和群聊
- 支持企业微信侧流式回复
- 支持接收文本、语音、图片、文件、视频和 mixed 消息
- 支持发送文本与媒体消息(`image``file``voice``video`
- 支持通过 `picoclaw auth wecom` 扫码写入配置
- 支持统一白名单与 `reasoning_channel_id`
> 这个渠道不再需要公网 webhook 回调地址。PicoClaw 会主动向企业微信发起 WebSocket 连接。
## 快速开始
### 方式 1命令行扫码登录
运行:
```bash
picoclaw auth wecom
```
该命令会在终端打印二维码,等待你在企业微信中确认,然后把生成的 `bot_id``secret` 写入
`channels.wecom`
如果需要更长等待时间,可以加 `--timeout`
```bash
picoclaw auth wecom --timeout 10m
```
### 方式 2手动配置
```json
{
"channels": {
"wecom": {
"enabled": true,
"bot_id": "YOUR_BOT_ID",
"secret": "YOUR_SECRET",
"websocket_url": "wss://openws.work.weixin.qq.com",
"send_thinking_message": true,
"allow_from": [],
"reasoning_channel_id": ""
}
}
}
```
## 配置字段
| 字段 | 类型 | 必填 | 说明 |
| ---- | ---- | ---- | ---- |
| `enabled` | bool | 否 | 是否启用企业微信渠道。 |
| `bot_id` | string | 是 | 企业微信 AI Bot 标识。渠道启用时必填。 |
| `secret` | string | 是 | 企业微信 AI Bot 密钥。渠道启用时必填。 |
| `websocket_url` | string | 否 | WebSocket 地址,默认 `wss://openws.work.weixin.qq.com`。 |
| `send_thinking_message` | bool | 否 | 是否在流式最终回复前先发送一段 `Processing...` 开场消息,默认 `true`。 |
| `allow_from` | array | 否 | 发送者白名单;空数组表示允许所有发送者。 |
| `reasoning_channel_id` | string | 否 | 可选的 reasoning/thinking 输出目标。 |
## 运行时行为
- PicoClaw 会保留当前会话对应的企业微信 turn优先继续同一个流式回复。
- 如果流式上下文已经失效,回复会自动回退到主动推送消息。
- 收到的媒体会先下载到 media store再交给 Agent 处理。
- 发出的媒体会先按分片上传到企业微信,再作为普通媒体消息发送。
## 迁移说明
这个分支移除了旧的多通道企业微信模型。
| 旧配置 | 现在怎么做 |
| ------ | ---------- |
| `channels.wecom` webhook 机器人 | 改为使用 `bot_id` + `secret``channels.wecom`。 |
| `channels.wecom_app` | 删除,统一迁移到 `channels.wecom`。 |
| `channels.wecom_aibot` | 配置迁移到 `channels.wecom`。 |
| `token``encoding_aes_key``webhook_url``webhook_path` | 企业微信渠道不再使用这些字段。 |
| `corp_id``corp_secret``agent_id` | 企业微信渠道不再使用这些字段。 |
| 企业微信下的 `welcome_message``processing_message``max_steps` | 不再属于企业微信渠道配置。 |
## 常见问题
### `picoclaw auth wecom` 超时
- 用更大的 `--timeout` 重新执行。
- 确认是在企业微信里完成了确认,而不只是扫描二维码。
### WebSocket 连接失败
- 检查 `bot_id``secret` 是否正确。
- 确认运行环境可以访问 `wss://openws.work.weixin.qq.com`
### 消息没有回到企业微信
- 检查 `allow_from` 是否拦截了发送者。
- 检查启动日志或 launcher 校验,确认 `channels.wecom.bot_id` / `channels.wecom.secret` 已填写。

View file

@ -6,7 +6,7 @@
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, or Pico (native protocol) Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, or Pico (native protocol)
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server. > **Note**: Channels that rely on HTTP callbacks share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Socket/stream-based channels such as Feishu, DingTalk, and WeCom do not rely on the shared webhook server for inbound delivery.
| Channel | Difficulty | Description | Documentation | | Channel | Difficulty | Description | Documentation |
| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
@ -19,7 +19,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
| **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](channels/qq/README.md) | | **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](channels/qq/README.md) |
| **DingTalk** | ⭐⭐ Medium | Stream mode (no public IP needed), enterprise | [Docs](channels/dingtalk/README.md) | | **DingTalk** | ⭐⭐ Medium | Stream mode (no public IP needed), enterprise | [Docs](channels/dingtalk/README.md) |
| **LINE** | ⭐⭐⭐ Advanced | HTTPS Webhook required | [Docs](channels/line/README.md) | | **LINE** | ⭐⭐⭐ Advanced | HTTPS Webhook required | [Docs](channels/line/README.md) |
| **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Group Bot (Webhook), custom App (API), AI Bot | [Bot](channels/wecom/wecom_bot/README.md) / [App](channels/wecom/wecom_app/README.md) / [AI Bot](channels/wecom/wecom_aibot/README.md) | | **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Official AI Bot over WebSocket, streaming + media | [Docs](channels/wecom/README.md) |
| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](channels/feishu/README.md) | | **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](channels/feishu/README.md) |
| **IRC** | ⭐⭐ Medium | Server + TLS configuration | [Docs](#irc) | | **IRC** | ⭐⭐ Medium | Server + TLS configuration | [Docs](#irc) |
| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](channels/onebot/README.md) | | **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](channels/onebot/README.md) |
@ -380,102 +380,34 @@ picoclaw gateway
<details> <details>
<summary><b>WeCom (企业微信)</b></summary> <summary><b>WeCom (企业微信)</b></summary>
PicoClaw supports three types of WeCom integration: PicoClaw now exposes WeCom as a single AI Bot channel over WebSocket.
No public webhook callback URL is required.
**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats See [WeCom Configuration Guide](channels/wecom/README.md) for the full configuration reference and migration notes.
**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only
**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat
See [WeCom AI Bot Configuration Guide](channels/wecom/wecom_aibot/README.md) for detailed setup instructions. **Quick Setup - Recommended**
**Quick Setup - WeCom Bot:** **1. Authenticate**
**1. Create a bot** ```bash
picoclaw auth wecom
```
* Go to WeCom Admin Console → Group Chat → Add Group Bot This command shows a QR code, waits for approval in WeCom, and writes `bot_id` + `secret` into `channels.wecom`.
* Copy the webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
**2. Configure** **2. Configure manually if needed**
```json ```json
{ {
"channels": { "channels": {
"wecom": { "wecom": {
"enabled": true, "enabled": true,
"token": "YOUR_TOKEN", "bot_id": "YOUR_BOT_ID",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY", "secret": "YOUR_SECRET",
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "websocket_url": "wss://openws.work.weixin.qq.com",
"webhook_path": "/webhook/wecom", "send_thinking_message": true,
"allow_from": []
}
}
}
```
> WeCom webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
**Quick Setup - WeCom App:**
**1. Create an app**
* Go to WeCom Admin Console → App Management → Create App
* Copy **AgentId** and **Secret**
* Go to "My Company" page, copy **CorpID**
**2. Configure receive message**
* In App details, click "Receive Message" → "Set API"
* Set URL to `http://your-server:18790/webhook/wecom-app`
* Generate **Token** and **EncodingAESKey**
**3. Configure**
```json
{
"channels": {
"wecom_app": {
"enabled": true,
"corp_id": "wwxxxxxxxxxxxxxxxx",
"corp_secret": "YOUR_CORP_SECRET",
"agent_id": 1000002,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-app",
"allow_from": []
}
}
}
```
**4. Run**
```bash
picoclaw gateway
```
> **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS.
**Quick Setup - WeCom AI Bot:**
**1. Create an AI Bot**
* Go to WeCom Admin Console → App Management → AI Bot
* In the AI Bot settings, configure callback URL: `http://your-server:18790/webhook/wecom-aibot`
* Copy **Token** and click "Random Generate" for **EncodingAESKey**
**2. Configure**
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [], "allow_from": [],
"welcome_message": "Hello! How can I help you?", "reasoning_channel_id": ""
"processing_message": "⏳ Processing, please wait. The results will be sent shortly."
} }
} }
} }
@ -487,7 +419,7 @@ picoclaw gateway
picoclaw gateway picoclaw gateway
``` ```
> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery. > Legacy `wecom_app` and `wecom_aibot` entries are replaced by the unified `channels.wecom` config in this branch.
</details> </details>

View file

@ -6,7 +6,7 @@
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。 PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
> **注意**: 所有 Webhook 类渠道LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。注意飞书Feishu使用 WebSocket/SDK 模式,不通过该共享 HTTP webhook 服务器接收消息。 > **注意**: 依赖 HTTP 回调的渠道共用同一个 Gateway HTTP 服务器`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。飞书、钉钉、企业微信这类 Socket/Stream 模式渠道不依赖共享 webhook 服务器来接收入站消息。
### 核心渠道 ### 核心渠道
@ -21,7 +21,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **QQ** | ⭐⭐ 中等 | 官方机器人 API适合国内社群 | [查看文档](../channels/qq/README.zh.md) | | **QQ** | ⭐⭐ 中等 | 官方机器人 API适合国内社群 | [查看文档](../channels/qq/README.zh.md) |
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](../channels/dingtalk/README.zh.md) | | **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](../channels/dingtalk/README.zh.md) |
| **LINE** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](../channels/line/README.zh.md) | | **LINE** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](../channels/line/README.zh.md) |
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](../channels/wecom/wecom_bot/README.zh.md) / [App 文档](../channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](../channels/wecom/wecom_aibot/README.zh.md) | | **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 官方 AI Bot WebSocket 接入,支持流式回复和媒体消息 | [查看文档](../channels/wecom/README.zh.md) |
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](../channels/feishu/README.zh.md) | | **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](../channels/feishu/README.zh.md) |
| **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | [查看文档](#irc) | | **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | [查看文档](#irc) |
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) | | **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) |
@ -492,102 +492,34 @@ picoclaw gateway
<details> <details>
<summary><b>企业微信 (WeCom)</b></summary> <summary><b>企业微信 (WeCom)</b></summary>
PicoClaw 支持三种企业微信集成方式: PicoClaw 现在将企业微信统一为一个基于 WebSocket 的 AI Bot 渠道。
它不再需要公网 webhook 回调地址。
**方式 1: 群机器人 (Bot)** — 设置简单,支持群聊 完整配置说明和迁移说明请参考 [企业微信配置指南](../channels/wecom/README.zh.md)。
**方式 2: 自建应用 (App)** — 功能更多,支持主动推送,仅私聊
**方式 3: 智能机器人 (AI Bot)** — 官方 AI Bot流式回复支持群聊和私聊
详细设置请参考 [企业微信 AI Bot 配置指南](../channels/wecom/wecom_aibot/README.zh.md)。 **推荐快速接入**
**快速设置 — 群机器人:** **1. 认证**
**1. 创建 Bot** ```bash
picoclaw auth wecom
```
* 企业微信管理后台 → 群聊 → 添加群机器人 该命令会显示二维码,等待你在企业微信里确认,然后把 `bot_id``secret` 写入 `channels.wecom`
* 复制 Webhook URL格式`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`
**2. 配置** **2. 如需手动配置**
```json ```json
{ {
"channels": { "channels": {
"wecom": { "wecom": {
"enabled": true, "enabled": true,
"token": "YOUR_TOKEN", "bot_id": "YOUR_BOT_ID",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY", "secret": "YOUR_SECRET",
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "websocket_url": "wss://openws.work.weixin.qq.com",
"webhook_path": "/webhook/wecom", "send_thinking_message": true,
"allow_from": []
}
}
}
```
> WeCom Webhook 挂载在共享 Gateway 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。
**快速设置 — 自建应用:**
**1. 创建应用**
* 企业微信管理后台 → 应用管理 → 创建应用
* 复制 **AgentId** 和 **Secret**
* 前往"我的企业"页面,复制 **CorpID**
**2. 配置接收消息**
* 在应用详情中,点击"接收消息" → "设置 API"
* 设置 URL 为 `http://your-server:18790/webhook/wecom-app`
* 生成 **Token** 和 **EncodingAESKey**
**3. 配置**
```json
{
"channels": {
"wecom_app": {
"enabled": true,
"corp_id": "wwxxxxxxxxxxxxxxxx",
"corp_secret": "YOUR_CORP_SECRET",
"agent_id": 1000002,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-app",
"allow_from": []
}
}
}
```
**4. 运行**
```bash
picoclaw gateway
```
> **注意**: WeCom Webhook 回调挂载在 Gateway 端口(默认 18790。使用反向代理配置 HTTPS。
**快速设置 — 智能机器人 (AI Bot)**
**1. 创建 AI Bot**
* 企业微信管理后台 → 应用管理 → AI Bot
* 在 AI Bot 设置中配置回调 URL`http://your-server:18790/webhook/wecom-aibot`
* 复制 **Token** 并点击"随机生成" **EncodingAESKey**
**2. 配置**
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [], "allow_from": [],
"welcome_message": "你好!有什么可以帮你的?", "reasoning_channel_id": ""
"processing_message": "⏳ Processing, please wait. The results will be sent shortly."
} }
} }
} }
@ -599,7 +531,7 @@ picoclaw gateway
picoclaw gateway picoclaw gateway
``` ```
> **注意**: 企业微信 AI Bot 使用流式拉取协议,无回复超时问题。长任务(>30 秒)会自动切换到 `response_url` 推送投递 > 这个分支中旧的 `wecom_app``wecom_aibot` 配置已经被统一的 `channels.wecom` 替代
</details> </details>

View file

@ -381,7 +381,7 @@ type TypingConfig struct {
// PlaceholderConfig controls placeholder message behavior (Phase 10). // PlaceholderConfig controls placeholder message behavior (Phase 10).
type PlaceholderConfig struct { type PlaceholderConfig struct {
Enabled bool `json:"enabled,omitempty"` Enabled bool `json:"enabled"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
} }
@ -861,6 +861,10 @@ type ModelConfig struct {
secModelName string secModelName string
apiKeys []string apiKeys []string
secDirty bool secDirty bool
// isVirtual marks this model as a virtual model generated from multi-key expansion.
// Virtual models should not be persisted to config files.
isVirtual bool
} }
// APIKey returns the first API key from apiKeys // APIKey returns the first API key from apiKeys
@ -871,6 +875,11 @@ func (c *ModelConfig) APIKey() string {
return "" return ""
} }
// IsVirtual returns true if this model was generated from multi-key expansion.
func (c *ModelConfig) IsVirtual() bool {
return c.isVirtual
}
// Validate checks if the ModelConfig has all required fields. // Validate checks if the ModelConfig has all required fields.
func (c *ModelConfig) Validate() error { func (c *ModelConfig) Validate() error {
if c.ModelName == "" { if c.ModelName == "" {
@ -1804,7 +1813,20 @@ func SaveConfig(path string, cfg *Config) error {
return err return err
} }
// Filter out virtual models before serializing to config file
nonVirtualModels := make([]*ModelConfig, 0, len(cfg.ModelList))
for _, m := range cfg.ModelList {
if !m.isVirtual {
nonVirtualModels = append(nonVirtualModels, m)
}
}
// Temporarily replace ModelList with filtered version for serialization
originalModelList := cfg.ModelList
cfg.ModelList = nonVirtualModels
data, err := json.MarshalIndent(cfg, "", " ") data, err := json.MarshalIndent(cfg, "", " ")
// Restore original ModelList after serialization
cfg.ModelList = originalModelList
if err != nil { if err != nil {
return err return err
} }
@ -2024,6 +2046,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
RequestTimeout: m.RequestTimeout, RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel, ThinkingLevel: m.ThinkingLevel,
ExtraBody: m.ExtraBody, ExtraBody: m.ExtraBody,
isVirtual: true,
} }
expanded = append(expanded, additionalEntry) expanded = append(expanded, additionalEntry)
fallbackNames = append(fallbackNames, expandedName) fallbackNames = append(fallbackNames, expandedName)

View file

@ -360,6 +360,96 @@ func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
} }
} }
func TestSaveConfig_PreservesDisabledTelegramPlaceholder(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "config.json")
cfg := DefaultConfig()
cfg.Channels.Telegram.Placeholder.Enabled = false
if err := SaveConfig(path, cfg); err != nil {
t.Fatalf("SaveConfig failed: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if !strings.Contains(string(data), `"placeholder": {`) {
t.Fatalf("saved config should include telegram placeholder config, got: %s", string(data))
}
if !strings.Contains(string(data), `"enabled": false`) {
t.Fatalf("saved config should persist placeholder.enabled=false, got: %s", string(data))
}
loaded, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
if loaded.Channels.Telegram.Placeholder.Enabled {
t.Fatal("telegram placeholder should remain disabled after SaveConfig/LoadConfig round-trip")
}
}
// TestSaveConfig_FiltersVirtualModels verifies that SaveConfig does not write
// virtual models (generated by expandMultiKeyModels) to the config file.
func TestSaveConfig_FiltersVirtualModels(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "config.json")
cfg := DefaultConfig()
// Manually add a virtual model to ModelList (simulating what expandMultiKeyModels does)
primaryModel := &ModelConfig{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
apiKeys: []string{"key1"},
}
virtualModel := &ModelConfig{
ModelName: "gpt-4__key_1",
Model: "openai/gpt-4o",
apiKeys: []string{"key2"},
isVirtual: true,
}
cfg.ModelList = []*ModelConfig{primaryModel, virtualModel}
// SaveConfig should filter out virtual models
if err := SaveConfig(path, cfg); err != nil {
t.Fatalf("SaveConfig failed: %v", err)
}
// Reload and verify
reloaded, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
// Should only have the primary model, not the virtual one
if len(reloaded.ModelList) != 1 {
t.Fatalf("expected 1 model after reload, got %d", len(reloaded.ModelList))
}
if reloaded.ModelList[0].ModelName != "gpt-4" {
t.Errorf("expected model_name 'gpt-4', got %q", reloaded.ModelList[0].ModelName)
}
// Verify virtual model was not persisted
for _, m := range reloaded.ModelList {
if m.ModelName == "gpt-4__key_1" {
t.Errorf("virtual model gpt-4__key_1 should not have been saved")
}
}
// Verify the saved file does not contain the virtual model name
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if strings.Contains(string(data), "gpt-4__key_1") {
t.Errorf("saved config should not contain virtual model name 'gpt-4__key_1'")
}
}
// TestConfig_Complete verifies all config fields are set // TestConfig_Complete verifies all config fields are set
func TestConfig_Complete(t *testing.T) { func TestConfig_Complete(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()

View file

@ -232,6 +232,78 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
} }
} }
func TestExpandMultiKeyModels_IsVirtualFlag(t *testing.T) {
models := []*ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
apiKeys: []string{"key1", "key2", "key3"},
},
}
result := expandMultiKeyModels(models)
// Should expand to 3 models
if len(result) != 3 {
t.Fatalf("expected 3 models, got %d", len(result))
}
// Primary model should NOT be virtual
primary := result[2]
if primary.isVirtual {
t.Errorf("primary model should not be virtual")
}
if primary.ModelName != "gpt-4" {
t.Errorf("expected primary model_name 'gpt-4', got %q", primary.ModelName)
}
// Virtual models should have isVirtual = true
virtual1 := result[0]
if !virtual1.isVirtual {
t.Errorf("gpt-4__key_1 should be virtual")
}
if virtual1.ModelName != "gpt-4__key_1" {
t.Errorf("expected virtual model_name 'gpt-4__key_1', got %q", virtual1.ModelName)
}
virtual2 := result[1]
if !virtual2.isVirtual {
t.Errorf("gpt-4__key_2 should be virtual")
}
if virtual2.ModelName != "gpt-4__key_2" {
t.Errorf("expected virtual model_name 'gpt-4__key_2', got %q", virtual2.ModelName)
}
// IsVirtual() method should work
if !virtual1.IsVirtual() {
t.Errorf("IsVirtual() should return true for virtual model")
}
if primary.IsVirtual() {
t.Errorf("IsVirtual() should return false for primary model")
}
}
func TestExpandMultiKeyModels_SingleKey_NotVirtual(t *testing.T) {
models := []*ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
apiKeys: []string{"single-key"},
},
}
result := expandMultiKeyModels(models)
if len(result) != 1 {
t.Fatalf("expected 1 model, got %d", len(result))
}
// Single key model should NOT be virtual
if result[0].isVirtual {
t.Errorf("single key model should not be virtual")
}
}
func TestMergeAPIKeys(t *testing.T) { func TestMergeAPIKeys(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View file

@ -6,6 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"regexp" "regexp"
"strings"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
@ -16,6 +17,7 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/config", h.handleGetConfig) mux.HandleFunc("GET /api/config", h.handleGetConfig)
mux.HandleFunc("PUT /api/config", h.handleUpdateConfig) mux.HandleFunc("PUT /api/config", h.handleUpdateConfig)
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig) mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
} }
// handleGetConfig returns the complete system configuration. // handleGetConfig returns the complete system configuration.
@ -179,6 +181,70 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
} }
// handleTestCommandPatterns tests a command against whitelist and blacklist patterns.
//
// POST /api/config/test-command-patterns
func (h *Handler) handleTestCommandPatterns(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req struct {
AllowPatterns []string `json:"allow_patterns"`
DenyPatterns []string `json:"deny_patterns"`
Command string `json:"command"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
lower := strings.ToLower(strings.TrimSpace(req.Command))
type result struct {
Allowed bool `json:"allowed"`
Blocked bool `json:"blocked"`
MatchedWhitelist *string `json:"matched_whitelist,omitempty"`
MatchedBlacklist *string `json:"matched_blacklist,omitempty"`
}
resp := result{Allowed: false, Blocked: false}
// Check whitelist first
for _, pattern := range req.AllowPatterns {
re, err := regexp.Compile(pattern)
if err != nil {
continue // skip invalid patterns
}
if re.MatchString(lower) {
resp.Allowed = true
resp.MatchedWhitelist = &pattern
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
}
// Check blacklist
for _, pattern := range req.DenyPatterns {
re, err := regexp.Compile(pattern)
if err != nil {
continue
}
if re.MatchString(lower) {
resp.Blocked = true
resp.MatchedBlacklist = &pattern
break
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// validateConfig checks the config for common errors before saving. // validateConfig checks the config for common errors before saving.
// Returns a list of human-readable error strings; empty means valid. // Returns a list of human-readable error strings; empty means valid.
func validateConfig(cfg *config.Config) []string { func validateConfig(cfg *config.Config) []string {

View file

@ -282,3 +282,170 @@ func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisable
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
} }
} }
// testCommandPatterns is a helper that sets up a handler and sends a test-command-patterns request.
func testCommandPatterns(t *testing.T, configPath string, body string) *httptest.ResponseRecorder {
t.Helper()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPost, "/api/config/test-command-patterns", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func TestHandleTestCommandPatterns_MatchesWhitelist(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
rec := testCommandPatterns(t, configPath, `{
"allow_patterns": ["^echo\\s+hello"],
"deny_patterns": ["^rm\\s+-rf"],
"command": "echo hello world"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=true, body=%s", rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
t.Fatalf("expected blocked=false when whitelist matches, body=%s", rec.Body.String())
}
}
func TestHandleTestCommandPatterns_MatchesBlacklistNotWhitelist(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
rec := testCommandPatterns(t, configPath, `{
"allow_patterns": ["^echo\\s+hello"],
"deny_patterns": ["^rm\\s+-rf"],
"command": "rm -rf /tmp"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
t.Fatalf("expected blocked=true, body=%s", rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=false when blacklist matches but not whitelist, body=%s", rec.Body.String())
}
}
func TestHandleTestCommandPatterns_MatchesNeither(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
rec := testCommandPatterns(t, configPath, `{
"allow_patterns": ["^echo\\s+hello"],
"deny_patterns": ["^rm\\s+-rf"],
"command": "ls -la"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=false, body=%s", rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
t.Fatalf("expected blocked=false, body=%s", rec.Body.String())
}
}
func TestHandleTestCommandPatterns_CaseInsensitiveWithGoFlag(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
rec := testCommandPatterns(t, configPath, `{
"allow_patterns": ["(?i)^ECHO"],
"deny_patterns": [],
"command": "echo hello"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=true with Go (?i) flag, body=%s", rec.Body.String())
}
}
func TestHandleTestCommandPatterns_EmptyPatterns(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
rec := testCommandPatterns(t, configPath, `{
"allow_patterns": [],
"deny_patterns": [],
"command": "rm -rf /tmp"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=false with empty patterns, body=%s", rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
t.Fatalf("expected blocked=false with empty patterns, body=%s", rec.Body.String())
}
}
func TestHandleTestCommandPatterns_InvalidRegexSkipped(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
rec := testCommandPatterns(t, configPath, `{
"allow_patterns": ["([[", "^echo"],
"deny_patterns": [],
"command": "echo hello"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
t.Fatalf("expected allowed=true, invalid pattern skipped and valid one matched, body=%s", rec.Body.String())
}
}
func TestHandleTestCommandPatterns_ReturnsMatchedPattern(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
rec := testCommandPatterns(t, configPath, `{
"allow_patterns": [],
"deny_patterns": ["\\$(?i)[a-zA-Z_]*(SECRET|KEY|PASSWORD|TOKEN|AUTH)[a-zA-Z0-9_]*"],
"command": "echo $GITHUB_API_KEY"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
t.Fatalf("expected blocked=true, body=%s", rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`matched_blacklist`)) {
t.Fatalf("expected matched_blacklist field, body=%s", rec.Body.String())
}
}
func TestHandleTestCommandPatterns_InvalidJSON(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(
http.MethodPost,
"/api/config/test-command-patterns",
bytes.NewBufferString(`{invalid json}`),
)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}

View file

@ -42,6 +42,7 @@ type modelResponse struct {
// Meta // Meta
Configured bool `json:"configured"` Configured bool `json:"configured"`
IsDefault bool `json:"is_default"` IsDefault bool `json:"is_default"`
IsVirtual bool `json:"is_virtual"`
} }
// handleListModels returns all model_list entries with masked API keys. // handleListModels returns all model_list entries with masked API keys.
@ -86,6 +87,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
ExtraBody: m.ExtraBody, ExtraBody: m.ExtraBody,
Configured: configured[i], Configured: configured[i],
IsDefault: m.ModelName == defaultModel, IsDefault: m.ModelName == defaultModel,
IsVirtual: m.IsVirtual(),
}) })
} }
@ -202,8 +204,13 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
} else { } else {
mc.ModelConfig.SetAPIKey(mc.APIKey) mc.ModelConfig.SetAPIKey(mc.APIKey)
} }
// Preserve existing ExtraBody when omitted (nil), but clear it when
// the frontend sends an empty object {} to indicate the field should
// be removed.
if mc.ExtraBody == nil { if mc.ExtraBody == nil {
mc.ExtraBody = cfg.ModelList[idx].ExtraBody mc.ExtraBody = cfg.ModelList[idx].ExtraBody
} else if len(mc.ExtraBody) == 0 {
mc.ExtraBody = nil
} }
cfg.ModelList[idx] = &mc.ModelConfig cfg.ModelList[idx] = &mc.ModelConfig
@ -288,11 +295,13 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request)
return return
} }
// Verify the model_name exists in model_list // Verify the model_name exists in model_list and is not a virtual model
found := false found := false
isVirtual := false
for _, m := range cfg.ModelList { for _, m := range cfg.ModelList {
if m.ModelName == req.ModelName { if m.ModelName == req.ModelName {
found = true found = true
isVirtual = m.IsVirtual()
break break
} }
} }
@ -300,6 +309,10 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request)
http.Error(w, fmt.Sprintf("Model %q not found in model_list", req.ModelName), http.StatusNotFound) http.Error(w, fmt.Sprintf("Model %q not found in model_list", req.ModelName), http.StatusNotFound)
return return
} }
if isVirtual {
http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest)
return
}
cfg.Agents.Defaults.ModelName = req.ModelName cfg.Agents.Defaults.ModelName = req.ModelName

View file

@ -356,6 +356,46 @@ func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
} }
} }
// TestHandleSetDefaultModel_RejectsNonexistentModel tests that setting a non-existent
// model as default returns 404. This covers the case where virtual models (which are
// filtered by SaveConfig) cannot be set as default.
func TestHandleSetDefaultModel_RejectsNonexistentModel(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
// First save a valid config with a primary model
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []*config.ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4o"},
}
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
// Try to set a non-existent model (like a virtual model name) as default
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/models/default", bytes.NewBufferString(`{
"model_name": "gpt-4__key_1"
}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
// Should return 404 because the virtual model doesn't exist in the persisted config
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "not found") {
t.Fatalf("error message should mention 'not found', got: %s", rec.Body.String())
}
}
func TestMaskAPIKey(t *testing.T) { func TestMaskAPIKey(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View file

@ -21,6 +21,7 @@ export interface ModelInfo {
// Meta // Meta
configured: boolean configured: boolean
is_default: boolean is_default: boolean
is_virtual: boolean
} }
interface ModelsListResponse { interface ModelsListResponse {

View file

@ -1,3 +1,4 @@
import { useState } from "react"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
@ -7,6 +8,7 @@ import {
type LauncherForm, type LauncherForm,
} from "@/components/config/form-model" } from "@/components/config/form-model"
import { Field, SwitchCardField } from "@/components/shared-form" import { Field, SwitchCardField } from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import { import {
Card, Card,
CardContent, CardContent,
@ -201,6 +203,56 @@ interface ExecSectionProps {
export function ExecSection({ form, onFieldChange }: ExecSectionProps) { export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [testCommand, setTestCommand] = useState("")
const [testResult, setTestResult] = useState<{
allowed: boolean
blocked: boolean
matchedWhitelist: string | null
matchedBlacklist: string | null
} | null>(null)
const [isLoading, setIsLoading] = useState(false)
const testPatterns = async () => {
if (!testCommand.trim()) {
setTestResult(null)
return
}
const allowPatterns = form.customAllowPatternsText
.split("\n")
.map((p) => p.trim())
.filter((p) => p.length > 0)
const denyPatterns = form.enableDenyPatterns
? form.customDenyPatternsText
.split("\n")
.map((p) => p.trim())
.filter((p) => p.length > 0)
: []
setIsLoading(true)
try {
const res = await fetch("/api/config/test-command-patterns", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
allow_patterns: allowPatterns,
deny_patterns: denyPatterns,
command: testCommand,
}),
})
const data = await res.json()
setTestResult({
allowed: data.allowed,
blocked: data.blocked,
matchedWhitelist: data.matched_whitelist ?? null,
matchedBlacklist: data.matched_blacklist ?? null,
})
} catch {
setTestResult(null)
} finally {
setIsLoading(false)
}
}
return ( return (
<ConfigSectionCard title={t("pages.config.sections.exec")}> <ConfigSectionCard title={t("pages.config.sections.exec")}>
@ -266,6 +318,50 @@ export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
/> />
</Field> </Field>
<Field
label={t("pages.config.pattern_detector_title")}
hint={t("pages.config.pattern_detector_hint")}
layout="setting-row"
controlClassName="md:max-w-md"
>
<div className="flex w-full flex-col gap-2">
<div className="flex gap-2">
<Input
value={testCommand}
placeholder={t(
"pages.config.pattern_detector_input_placeholder",
)}
onChange={(e) => setTestCommand(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
testPatterns()
}
}}
/>
<Button onClick={testPatterns} disabled={isLoading}>
{t("pages.config.pattern_detector_test_button")}
</Button>
</div>
{testResult && (
<div
className={`rounded-md p-2 text-sm ${
testResult.allowed
? "bg-green-500/10 text-green-600"
: testResult.blocked
? "bg-red-500/10 text-red-600"
: "bg-muted text-muted-foreground"
}`}
>
{testResult.allowed
? `${t("pages.config.pattern_detector_result_allowed")}${testResult.matchedWhitelist ? ` (${testResult.matchedWhitelist})` : ""}`
: testResult.blocked
? `${t("pages.config.pattern_detector_result_blocked")}${testResult.matchedBlacklist ? ` (${testResult.matchedBlacklist})` : ""}`
: t("pages.config.pattern_detector_result_no_match")}
</div>
)}
</div>
</Field>
<Field <Field
label={t("pages.config.exec_timeout_seconds")} label={t("pages.config.exec_timeout_seconds")}
hint={t("pages.config.exec_timeout_seconds_hint")} hint={t("pages.config.exec_timeout_seconds_hint")}

View file

@ -12,6 +12,7 @@ import {
} from "@/components/shared-form" } from "@/components/shared-form"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -34,6 +35,7 @@ interface AddForm {
maxTokensField: string maxTokensField: string
requestTimeout: string requestTimeout: string
thinkingLevel: string thinkingLevel: string
extraBody: string
} }
const EMPTY_ADD_FORM: AddForm = { const EMPTY_ADD_FORM: AddForm = {
@ -49,6 +51,7 @@ const EMPTY_ADD_FORM: AddForm = {
maxTokensField: "", maxTokensField: "",
requestTimeout: "", requestTimeout: "",
thinkingLevel: "", thinkingLevel: "",
extraBody: "",
} }
interface AddModelSheetProps { interface AddModelSheetProps {
@ -100,7 +103,7 @@ export function AddModelSheet({
} }
const setField = const setField =
(key: keyof AddForm) => (e: React.ChangeEvent<HTMLInputElement>) => { (key: keyof AddForm) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setForm((f) => ({ ...f, [key]: e.target.value })) setForm((f) => ({ ...f, [key]: e.target.value }))
if (fieldErrors[key]) { if (fieldErrors[key]) {
setFieldErrors((prev) => ({ ...prev, [key]: undefined })) setFieldErrors((prev) => ({ ...prev, [key]: undefined }))
@ -129,6 +132,9 @@ export function AddModelSheet({
? Number(form.requestTimeout) ? Number(form.requestTimeout)
: undefined, : undefined,
thinking_level: form.thinkingLevel.trim() || undefined, thinking_level: form.thinkingLevel.trim() || undefined,
extra_body: form.extraBody.trim()
? JSON.parse(form.extraBody.trim())
: undefined,
}) })
if (setAsDefault) { if (setAsDefault) {
await setDefaultModel(modelName) await setDefaultModel(modelName)
@ -305,6 +311,18 @@ export function AddModelSheet({
placeholder="max_completion_tokens" placeholder="max_completion_tokens"
/> />
</Field> </Field>
<Field
label={t("models.field.extraBody")}
hint={t("models.field.extraBodyHint")}
>
<Textarea
value={form.extraBody}
onChange={setField("extraBody")}
placeholder='{"key": "value"}'
rows={3}
/>
</Field>
</AdvancedSection> </AdvancedSection>
{serverError && ( {serverError && (

View file

@ -12,6 +12,7 @@ import {
} from "@/components/shared-form" } from "@/components/shared-form"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -32,6 +33,7 @@ interface EditForm {
maxTokensField: string maxTokensField: string
requestTimeout: string requestTimeout: string
thinkingLevel: string thinkingLevel: string
extraBody: string
} }
interface EditModelSheetProps { interface EditModelSheetProps {
@ -59,6 +61,7 @@ export function EditModelSheet({
maxTokensField: "", maxTokensField: "",
requestTimeout: "", requestTimeout: "",
thinkingLevel: "", thinkingLevel: "",
extraBody: "",
}) })
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [setAsDefault, setSetAsDefault] = useState(false) const [setAsDefault, setSetAsDefault] = useState(false)
@ -79,6 +82,9 @@ export function EditModelSheet({
? String(model.request_timeout) ? String(model.request_timeout)
: "", : "",
thinkingLevel: model.thinking_level ?? "", thinkingLevel: model.thinking_level ?? "",
extraBody: model.extra_body
? JSON.stringify(model.extra_body, null, 2)
: "",
}) })
setSetAsDefault(model.is_default) setSetAsDefault(model.is_default)
setError("") setError("")
@ -86,7 +92,7 @@ export function EditModelSheet({
}, [model]) }, [model])
const setField = const setField =
(key: keyof EditForm) => (e: React.ChangeEvent<HTMLInputElement>) => (key: keyof EditForm) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm((f) => ({ ...f, [key]: e.target.value })) setForm((f) => ({ ...f, [key]: e.target.value }))
const handleSave = async () => { const handleSave = async () => {
@ -109,6 +115,9 @@ export function EditModelSheet({
? Number(form.requestTimeout) ? Number(form.requestTimeout)
: undefined, : undefined,
thinking_level: form.thinkingLevel || undefined, thinking_level: form.thinkingLevel || undefined,
extra_body: form.extraBody.trim()
? JSON.parse(form.extraBody.trim())
: {},
}) })
if (setAsDefault && !model.is_default) { if (setAsDefault && !model.is_default) {
await setDefaultModel(model.model_name) await setDefaultModel(model.model_name)
@ -273,6 +282,18 @@ export function EditModelSheet({
placeholder="max_completion_tokens" placeholder="max_completion_tokens"
/> />
</Field> </Field>
<Field
label={t("models.field.extraBody")}
hint={t("models.field.extraBodyHint")}
>
<Textarea
value={form.extraBody}
onChange={setField("extraBody")}
placeholder='{"key": "value"}'
rows={3}
/>
</Field>
</AdvancedSection> </AdvancedSection>
{error && ( {error && (

View file

@ -28,7 +28,7 @@ export function ModelCard({
}: ModelCardProps) { }: ModelCardProps) {
const { t } = useTranslation() const { t } = useTranslation()
const isOAuth = model.auth_method === "oauth" const isOAuth = model.auth_method === "oauth"
const canSetDefault = model.configured && !model.is_default const canSetDefault = model.configured && !model.is_default && !model.is_virtual
return ( return (
<div <div
@ -64,6 +64,11 @@ export function ModelCard({
{t("models.badge.default")} {t("models.badge.default")}
</span> </span>
)} )}
{model.is_virtual && (
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-[10px] leading-none font-medium">
{t("models.badge.virtual")}
</span>
)}
</div> </div>
<div className="flex shrink-0 items-center gap-0.5"> <div className="flex shrink-0 items-center gap-0.5">

View file

@ -154,7 +154,8 @@
"unconfigured": "Not configured" "unconfigured": "Not configured"
}, },
"badge": { "badge": {
"default": "Default" "default": "Default",
"virtual": "Virtual"
}, },
"action": { "action": {
"edit": "Edit API key", "edit": "Edit API key",
@ -208,7 +209,9 @@
"thinkingLevel": "Thinking Level", "thinkingLevel": "Thinking Level",
"thinkingLevelHint": "Extended thinking budget: off, low, medium, high, xhigh, adaptive.", "thinkingLevelHint": "Extended thinking budget: off, low, medium, high, xhigh, adaptive.",
"maxTokensField": "Max Tokens Field", "maxTokensField": "Max Tokens Field",
"maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens." "maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens.",
"extraBody": "Extra Body",
"extraBodyHint": "Additional JSON fields to inject into the request body, e.g. {\"reasoning_split\": true}."
}, },
"edit": { "edit": {
"title": "Configure {{name}}", "title": "Configure {{name}}",
@ -432,6 +435,13 @@
"custom_allow_patterns": "Command Whitelist", "custom_allow_patterns": "Command Whitelist",
"custom_allow_patterns_hint": "Add extra command-allow rules, one regular expression per line. A command matching any rule here skips blacklist matching, but other safety limits still apply.", "custom_allow_patterns_hint": "Add extra command-allow rules, one regular expression per line. A command matching any rule here skips blacklist matching, but other safety limits still apply.",
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b", "custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
"pattern_detector_title": "Pattern Detection Tool",
"pattern_detector_hint": "Enter a command to test if it matches any blacklist or whitelist patterns.",
"pattern_detector_input_placeholder": "Enter a command to test, e.g., rm -rf /tmp",
"pattern_detector_test_button": "Test",
"pattern_detector_result_allowed": "Allowed (matches whitelist)",
"pattern_detector_result_blocked": "Blocked (matches blacklist)",
"pattern_detector_result_no_match": "No match (will use default rules)",
"allow_shell_execution": "Allow Scheduled Commands", "allow_shell_execution": "Allow Scheduled Commands",
"allow_shell_execution_hint": "Allow scheduled tasks to run commands by default. When disabled, users must pass command_confirm=true to schedule a command task.", "allow_shell_execution_hint": "Allow scheduled tasks to run commands by default. When disabled, users must pass command_confirm=true to schedule a command task.",
"cron_exec_timeout": "Scheduled Command Timeout (minutes)", "cron_exec_timeout": "Scheduled Command Timeout (minutes)",

View file

@ -154,7 +154,8 @@
"unconfigured": "未配置" "unconfigured": "未配置"
}, },
"badge": { "badge": {
"default": "默认" "default": "默认",
"virtual": "虚拟"
}, },
"action": { "action": {
"edit": "编辑 API Key", "edit": "编辑 API Key",
@ -208,7 +209,9 @@
"thinkingLevel": "思考级别", "thinkingLevel": "思考级别",
"thinkingLevelHint": "扩展思考预算off、low、medium、high、xhigh、adaptive。", "thinkingLevelHint": "扩展思考预算off、low、medium、high、xhigh、adaptive。",
"maxTokensField": "Max Tokens 字段名", "maxTokensField": "Max Tokens 字段名",
"maxTokensFieldHint": "覆盖请求中 max_tokens 的字段名,例如 max_completion_tokens。" "maxTokensFieldHint": "覆盖请求中 max_tokens 的字段名,例如 max_completion_tokens。",
"extraBody": "Extra Body",
"extraBodyHint": "要注入到请求体中的额外 JSON 字段,例如 {\"reasoning_split\": true}。"
}, },
"edit": { "edit": {
"title": "配置 {{name}}", "title": "配置 {{name}}",
@ -432,6 +435,13 @@
"custom_allow_patterns": "命令白名单", "custom_allow_patterns": "命令白名单",
"custom_allow_patterns_hint": "用于补充额外的命令放行规则,每行一个正则表达式。命中任意一条规则的命令会跳过黑名单检查,但仍受其他安全限制约束。", "custom_allow_patterns_hint": "用于补充额外的命令放行规则,每行一个正则表达式。命中任意一条规则的命令会跳过黑名单检查,但仍受其他安全限制约束。",
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b", "custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
"pattern_detector_title": "规则检测工具",
"pattern_detector_hint": "输入命令以检测其是否匹配黑名单或白名单规则。",
"pattern_detector_input_placeholder": "输入要检测的命令,例如 rm -rf /tmp",
"pattern_detector_test_button": "检测",
"pattern_detector_result_allowed": "允许(匹配白名单)",
"pattern_detector_result_blocked": "阻止(匹配黑名单)",
"pattern_detector_result_no_match": "无匹配(将使用默认规则)",
"allow_shell_execution": "允许定时任务运行命令", "allow_shell_execution": "允许定时任务运行命令",
"allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务。", "allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务。",
"cron_exec_timeout": "定时命令超时(分钟)", "cron_exec_timeout": "定时命令超时(分钟)",