Compare commits
13 Commits
7f54256ac0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 060a8f132d | |||
| 19b3fb60f7 | |||
| 2c9b44aeca | |||
| bcf7cbbeea | |||
| f3a44a901e | |||
| 066383de83 | |||
| f004cfc937 | |||
| 334152417b | |||
| 6709c4ed77 | |||
| d104442e6b | |||
| a9024ff6f8 | |||
| 29319ee2d3 | |||
| a0a90f558c |
@@ -62,6 +62,7 @@
|
||||
- **远程执行:** 支持远程agent执行任务,展示执行结果
|
||||
- **多语言支持:** 深度集成 Mise,支持几乎所有主流编程语言的动态安装、多版本切换及依赖管理
|
||||
- **内建助手库:** **(New)** 为 Python/Node.js 提供零配置助手库,简单 import 即可实现一键推信,无需手动管理 API Token 和 URL
|
||||
- **MCP Server:** **(New)** 内置 MCP Server(`taskpool mcp`),Hermes / OpenClaw / Cursor 等 AI Agent 可完整接管任务、脚本、日志与环境变量
|
||||
|
||||
## 功能特性
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package cmd
|
||||
import (
|
||||
"github.com/engigu/taskpool/cmd/builtininstall"
|
||||
"github.com/engigu/taskpool/cmd/depinstall"
|
||||
mcpcmd "github.com/engigu/taskpool/cmd/mcp"
|
||||
"github.com/engigu/taskpool/cmd/reposync"
|
||||
"github.com/engigu/taskpool/cmd/resetpwd"
|
||||
"github.com/engigu/taskpool/cmd/restore"
|
||||
@@ -22,6 +23,7 @@ var Handlers = map[string]CommandHandler{
|
||||
"restore": restore.Run,
|
||||
"builtininstall": builtininstall.Run,
|
||||
"task": task.Run,
|
||||
"mcp": mcpcmd.Run,
|
||||
"webui": webui.Run,
|
||||
"version": version.Run,
|
||||
"-v": version.Run,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/engigu/taskpool/internal/mcp"
|
||||
imc "github.com/engigu/taskpool/internal/mcp"
|
||||
)
|
||||
|
||||
// Run 启动 TaskPool MCP Server(stdio 模式)
|
||||
// 环境变量(可选,默认使用本地服务器配置):
|
||||
// TASKPOOL_URL 面板地址
|
||||
// TASKPOOL_TOKEN OpenAPI Token
|
||||
func Run(args []string) {
|
||||
for _, a := range args {
|
||||
if a == "-h" || a == "--help" {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 使用内部 OpenAPI 客户端(自动获取服务器地址和 Token)
|
||||
client := imc.GetOpenAPIClient()
|
||||
fmt.Fprintf(os.Stderr, "[taskpool-mcp] stdio mode, url=%s token=%s\n", client.BaseURL, maskToken(client.Token))
|
||||
|
||||
s := mcp.GetServer()
|
||||
// 设置外部客户端(用于连接远程服务器)
|
||||
if client.Token != "" {
|
||||
mcp.SetExternalClient(client)
|
||||
}
|
||||
|
||||
if err := mcp.ServeStdio(s); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[taskpool-mcp] error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func maskToken(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
return "(empty)"
|
||||
}
|
||||
if len(t) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
return t[:4] + "****" + t[len(t)-4:]
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
TaskPool MCP Server
|
||||
|
||||
MCP Server 已内置在后端服务中:
|
||||
HTTP 端点: http://your-server:8052/mcp
|
||||
|
||||
此命令仅用于 stdio 模式(Claude Desktop / Cursor 等)。
|
||||
|
||||
用法:
|
||||
taskpool mcp
|
||||
|
||||
环境变量(可选):
|
||||
TASKPOOL_URL 面板地址(默认使用本机)
|
||||
TASKPOOL_TOKEN OpenAPI Token(默认从系统设置读取)
|
||||
|
||||
Agent 配置:
|
||||
HTTP 模式(Hermes / OpenClaw):
|
||||
{
|
||||
"mcpServers": {
|
||||
"taskpool": {
|
||||
"url": "http://your-server:8052/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer 你的Token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stdio 模式(Claude Desktop / Cursor):
|
||||
{
|
||||
"mcpServers": {
|
||||
"taskpool": {
|
||||
"command": "taskpool",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
前置条件:
|
||||
系统设置 → 启用 OpenAPI → 生成 Token
|
||||
`)
|
||||
}
|
||||
@@ -15,7 +15,8 @@ export default defineConfig({
|
||||
nav: [
|
||||
{ text: '快速开始', link: '/guide/introduction' },
|
||||
{ text: '部署指南', link: '/guide/deployment' },
|
||||
{ text: 'API 文档', link: '/guide/api' }
|
||||
{ text: 'API 文档', link: '/guide/api' },
|
||||
{ text: 'MCP', link: '/guide/mcp' }
|
||||
],
|
||||
|
||||
sidebar: [
|
||||
@@ -25,7 +26,8 @@ export default defineConfig({
|
||||
{ text: '项目介绍', link: '/guide/introduction' },
|
||||
{ text: '部署说明', link: '/guide/deployment' },
|
||||
{ text: '开始使用', link: '/guide/getting-started' },
|
||||
{ text: 'API 文档', link: '/guide/api' }
|
||||
{ text: 'API 文档', link: '/guide/api' },
|
||||
{ text: 'MCP Server', link: '/guide/mcp' }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# MCP Server(Hermes / OpenClaw)
|
||||
|
||||
TaskPool 提供官方 **MCP Server**,让 Hermes、OpenClaw、Cursor 等 AI Agent **完整接管**任务池:任务、脚本、环境变量、执行与日志。
|
||||
|
||||
> OpenAPI ≠ MCP
|
||||
> - **OpenAPI**:`/open2api/v1` REST 接口(设置页「启用 OpenAPI」)
|
||||
> - **MCP Server**:`/mcp` HTTP 端点,把 OpenAPI 封装成 Agent 可调用的 Tools
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. 启动后端服务(Docker 或 `taskpool server`)
|
||||
2. 系统设置 → **启用 OpenAPI** → **生成 Token**
|
||||
3. Agent 能访问面板地址(如 `http://your-server:8052`)
|
||||
|
||||
## 内置 MCP 端点
|
||||
|
||||
MCP Server 已**内置在后端服务中**,无需单独进程。启用 OpenAPI 后,MCP 端点自动可用:
|
||||
|
||||
```
|
||||
http://your-server:8052/mcp
|
||||
```
|
||||
|
||||
## Agent 配置
|
||||
|
||||
### HTTP 模式(推荐)
|
||||
|
||||
远程 Agent 直接访问内置端点,无需本地安装。
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"taskpool": {
|
||||
"url": "http://your-server:8052/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer 你的OpenAPI_Token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
适用于:Hermes、OpenClaw 等支持 HTTP URL 的 Agent。
|
||||
|
||||
### stdio 模式
|
||||
|
||||
本地 Agent 需要安装 `taskpool` 二进制。
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"taskpool": {
|
||||
"command": "taskpool",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
适用于:Claude Desktop、Cursor 等只支持 stdio 的本地 Agent。
|
||||
|
||||
## 工具清单(Tools)
|
||||
|
||||
| 分类 | Tool | 说明 |
|
||||
|------|------|------|
|
||||
| 连通 | `ping_api` | 校验 URL + Token |
|
||||
| 任务 | `list_tasks` | 分页列表/筛选 |
|
||||
| 任务 | `get_task` | 详情 |
|
||||
| 任务 | `create_task` | 创建 |
|
||||
| 任务 | `update_task` | 更新 |
|
||||
| 任务 | `delete_task` | 删除 |
|
||||
| 任务 | `list_task_tags` | 标签 |
|
||||
| 执行 | `run_task` | 立即执行 |
|
||||
| 执行 | `stop_task` | 按 log_id 停止 |
|
||||
| 执行 | `get_last_results` | 最近结果 |
|
||||
| 日志 | `list_logs` | 日志列表 |
|
||||
| 日志 | `get_log` | 日志详情(含输出) |
|
||||
| 脚本 | `list_scripts` / `get_script` / `create_script` / `update_script` / `delete_script` | 脚本 CRUD |
|
||||
| 环境变量 | `list_envs` / `list_all_envs` / `get_env` / `create_env` / `update_env` / `delete_env` | 环境变量 CRUD |
|
||||
|
||||
## Prompts
|
||||
|
||||
- `ops_overview`:巡检任务与失败日志
|
||||
- `run_and_check`:执行指定任务并检查日志(参数 `task_id`)
|
||||
|
||||
## Skill(可选)
|
||||
|
||||
仓库内提供 Agent Skill 文档,便于模型按固定流程操作:
|
||||
|
||||
- [`skills/taskpool/SKILL.md`](../../skills/taskpool/SKILL.md)
|
||||
|
||||
Skill 是说明书;**真正执行依赖 MCP Tools**。两者一起用效果最好。
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. Token 等同管理员 OpenAPI 权限,勿提交到 Git
|
||||
2. 删除任务/脚本/环境变量前应人工确认
|
||||
3. 不要对不可信网络暴露面板与 Token
|
||||
4. 生产建议独立 Token,并定期轮换
|
||||
|
||||
## 典型对话
|
||||
|
||||
- 「列出所有启用的任务」→ `list_tasks`
|
||||
- 「跑一下备份任务并看日志」→ `list_tasks` → `run_task` → `list_logs` → `get_log`
|
||||
- 「创建一个每天凌晨执行的清理任务」→ `create_task`
|
||||
- 「最近失败的任务是什么原因」→ `list_logs(status=failed)` → `get_log`
|
||||
|
||||
## 与项目能力的对应
|
||||
|
||||
参考 [项目介绍](./introduction.md) 中的能力边界:
|
||||
|
||||
| 面板能力 | MCP 是否覆盖 |
|
||||
|----------|--------------|
|
||||
| 任务调度 / 手动执行 | ✅ |
|
||||
| 脚本管理 | ✅ |
|
||||
| 环境变量 | ✅ |
|
||||
| 执行日志 | ✅ |
|
||||
| 在线终端 | ❌(WebSocket,不适合 MCP 工具化) |
|
||||
| 语言环境 Mise 安装 | ❌(可后续扩展) |
|
||||
| 消息推送配置 | ❌(可后续扩展) |
|
||||
| 远程 Agent 节点 | 间接(任务上指定 agent 后可执行) |
|
||||
|
||||
## 故障排查
|
||||
|
||||
| 现象 | 处理 |
|
||||
|------|------|
|
||||
| `OpenAPI Token 未配置` | 设置页启用 OpenAPI 并生成 Token |
|
||||
| `无效的 OpenAPI 令牌` | 设置页重新生成 Token |
|
||||
| 连接失败 | 检查服务器地址、防火墙、容器网络 |
|
||||
| Agent 看不到 tools | 确认配置正确,Token 有效 |
|
||||
@@ -14,6 +14,8 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/yamux v0.1.2
|
||||
github.com/klauspost/compress v1.19.0
|
||||
github.com/mark3labs/mcp-go v0.57.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/rs/xid v1.6.0
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
@@ -81,6 +83,7 @@ require (
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gohugoio/hashstructure v0.6.0 // indirect
|
||||
github.com/gohugoio/hugo v0.163.3 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hairyhenderson/go-codeowners v0.7.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@@ -91,7 +94,6 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.19.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
@@ -110,6 +112,7 @@ require (
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.2.1 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
@@ -122,6 +125,7 @@ require (
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
|
||||
@@ -141,6 +141,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -245,6 +247,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
@@ -307,6 +311,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r
|
||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
|
||||
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
|
||||
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
|
||||
github.com/mark3labs/mcp-go v0.57.0 h1:jzWKyCzdWnwnZt05cvcQQ+ngiUl2RnixXJa7Kj4qP1E=
|
||||
github.com/mark3labs/mcp-go v0.57.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
@@ -452,6 +458,8 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
||||
@@ -32,6 +32,10 @@ var Commands = []CommandInfo{
|
||||
Name: "task",
|
||||
Description: "系统级任务的列表查询、触发运行、启停控制及状态查看",
|
||||
},
|
||||
{
|
||||
Name: "mcp",
|
||||
Description: "启动 MCP Server,供 Hermes / OpenClaw 等 AI Agent 接管任务池",
|
||||
},
|
||||
{
|
||||
Name: "depinstall",
|
||||
Description: "一键补全指定任务执行日志中的缺失依赖包",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/engigu/taskpool/internal/mcp"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type MCPController struct{}
|
||||
|
||||
func NewMCPController() *MCPController {
|
||||
return &MCPController{}
|
||||
}
|
||||
|
||||
// HandleMCP 处理 MCP 请求(StreamableHTTP)
|
||||
func (c *MCPController) HandleMCP(ctx *gin.Context) {
|
||||
// 获取 MCP HTTP Handler 并处理请求
|
||||
handler := mcp.GetHTTPHandler()
|
||||
handler.ServeHTTP(ctx.Writer, ctx.Request)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/engigu/taskpool/internal/constant"
|
||||
"github.com/engigu/taskpool/internal/models/vo"
|
||||
"github.com/engigu/taskpool/internal/services"
|
||||
)
|
||||
|
||||
var (
|
||||
openAPIClient *Client
|
||||
openAPIClientOnce sync.Once
|
||||
)
|
||||
|
||||
// GetOpenAPIClient 获取 OpenAPI 客户端(单例)
|
||||
// 优先使用环境变量 TASKPOOL_URL 和 TASKPOOL_TOKEN
|
||||
// 否则使用本地服务器地址和系统设置中的 OpenAPI Token
|
||||
func GetOpenAPIClient() *Client {
|
||||
openAPIClientOnce.Do(func() {
|
||||
// 优先从环境变量读取(支持 stdio 模式连接远程服务器)
|
||||
envURL := strings.TrimSpace(os.Getenv("TASKPOOL_URL"))
|
||||
envToken := strings.TrimSpace(os.Getenv("TASKPOOL_TOKEN"))
|
||||
if envURL == "" {
|
||||
envURL = strings.TrimSpace(os.Getenv("BAIHU_URL"))
|
||||
}
|
||||
if envToken == "" {
|
||||
envToken = strings.TrimSpace(os.Getenv("BAIHU_TOKEN"))
|
||||
}
|
||||
|
||||
var baseURL, token string
|
||||
if envURL != "" && envToken != "" {
|
||||
// 使用环境变量配置
|
||||
baseURL = envURL
|
||||
token = envToken
|
||||
} else {
|
||||
// 使用本地服务器配置
|
||||
cfg := services.GetConfig()
|
||||
baseURL = fmt.Sprintf("http://127.0.0.1:%d", cfg.Server.Port)
|
||||
if cfg.Server.URLPrefix != "" {
|
||||
baseURL += cfg.Server.URLPrefix
|
||||
}
|
||||
settingsSvc := services.NewSettingsService()
|
||||
token = getOpenAPIToken(settingsSvc)
|
||||
}
|
||||
|
||||
openAPIClient = NewClient(baseURL, token)
|
||||
})
|
||||
return openAPIClient
|
||||
}
|
||||
|
||||
// RefreshClient 刷新客户端(当 OpenAPI Token 变更时调用)
|
||||
func RefreshClient() {
|
||||
settingsSvc := services.NewSettingsService()
|
||||
token := getOpenAPIToken(settingsSvc)
|
||||
if openAPIClient != nil {
|
||||
openAPIClient.Token = token
|
||||
}
|
||||
}
|
||||
|
||||
func getOpenAPIToken(settingsSvc *services.SettingsService) string {
|
||||
siteConfig := settingsSvc.GetSection(constant.SectionSite)
|
||||
tokenJson, ok := siteConfig[constant.KeyOpenapiToken]
|
||||
if !ok || tokenJson == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var tokenConfig vo.TokenConfig
|
||||
if err := json.Unmarshal([]byte(tokenJson), &tokenConfig); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !tokenConfig.Enabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
return tokenConfig.Token
|
||||
}
|
||||
|
||||
// Client 调用内部 OpenAPI
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
||||
Token: strings.TrimSpace(token),
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) openBase() string {
|
||||
return c.BaseURL + "/open2api/v1"
|
||||
}
|
||||
|
||||
func (c *Client) do(method, path string, query map[string]string, body any) (json.RawMessage, error) {
|
||||
if c.BaseURL == "" {
|
||||
return nil, fmt.Errorf("服务器地址未配置")
|
||||
}
|
||||
if c.Token == "" {
|
||||
return nil, fmt.Errorf("OpenAPI Token 未配置,请在系统设置中启用 OpenAPI 并生成 Token")
|
||||
}
|
||||
|
||||
u, err := url.Parse(c.openBase() + path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(query) > 0 {
|
||||
q := u.Query()
|
||||
for k, v := range query {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
q.Set(k, v)
|
||||
}
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, u.String(), reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(raw), 500))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
if envelope.Code != 200 {
|
||||
msg := envelope.Msg
|
||||
if msg == "" {
|
||||
msg = truncate(string(raw), 500)
|
||||
}
|
||||
return nil, fmt.Errorf("[%d] %s", envelope.Code, msg)
|
||||
}
|
||||
if len(envelope.Data) > 0 && string(envelope.Data) != "null" {
|
||||
return envelope.Data, nil
|
||||
}
|
||||
result := map[string]any{"code": envelope.Code, "msg": envelope.Msg}
|
||||
b, _ := json.Marshal(result)
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (c *Client) Get(path string, query map[string]string) (json.RawMessage, error) {
|
||||
return c.do(http.MethodGet, path, query, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Post(path string, body any) (json.RawMessage, error) {
|
||||
return c.do(http.MethodPost, path, nil, body)
|
||||
}
|
||||
|
||||
func (c *Client) Put(path string, body any) (json.RawMessage, error) {
|
||||
return c.do(http.MethodPut, path, nil, body)
|
||||
}
|
||||
|
||||
func (c *Client) Delete(path string) (json.RawMessage, error) {
|
||||
return c.do(http.MethodDelete, path, nil, nil)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
serverName = "taskpool"
|
||||
serverVersion = "1.0.0"
|
||||
)
|
||||
|
||||
var (
|
||||
mcpServer *server.MCPServer
|
||||
mcpHTTPServer *server.StreamableHTTPServer
|
||||
externalClient *Client // 外部客户端(用于 stdio 模式连接远程服务器)
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// GetServer 获取 MCP Server 实例(单例)
|
||||
func GetServer() *server.MCPServer {
|
||||
once.Do(func() {
|
||||
mcpServer = server.NewMCPServer(
|
||||
serverName,
|
||||
serverVersion,
|
||||
server.WithToolCapabilities(true),
|
||||
server.WithResourceCapabilities(false, false),
|
||||
server.WithPromptCapabilities(true),
|
||||
server.WithInstructions(instructions),
|
||||
server.WithRecovery(),
|
||||
)
|
||||
registerTools(mcpServer)
|
||||
registerPrompts(mcpServer)
|
||||
})
|
||||
return mcpServer
|
||||
}
|
||||
|
||||
// SetExternalClient 设置外部 OpenAPI 客户端(用于 stdio 模式)
|
||||
func SetExternalClient(c *Client) {
|
||||
externalClient = c
|
||||
}
|
||||
|
||||
// getClient 获取当前使用的客户端
|
||||
func getClient() *Client {
|
||||
if externalClient != nil {
|
||||
return externalClient
|
||||
}
|
||||
return GetOpenAPIClient()
|
||||
}
|
||||
|
||||
// GetHTTPHandler 获取 MCP HTTP Handler(用于集成到 Gin)
|
||||
func GetHTTPHandler() http.Handler {
|
||||
s := GetServer()
|
||||
if mcpHTTPServer == nil {
|
||||
mcpHTTPServer = server.NewStreamableHTTPServer(s,
|
||||
server.WithEndpointPath(""),
|
||||
)
|
||||
}
|
||||
return mcpHTTPServer
|
||||
}
|
||||
|
||||
// ServeStdio 启动 stdio 模式的 MCP Server
|
||||
func ServeStdio(s *server.MCPServer) error {
|
||||
return server.ServeStdio(s)
|
||||
}
|
||||
|
||||
const instructions = `你是 TaskPool(任务池)运维助手。通过 MCP tools 管理任务调度面板。
|
||||
|
||||
核心能力:
|
||||
- 任务:列表/详情/创建/更新/删除/启停执行
|
||||
- 脚本:列表/详情/创建/更新/删除
|
||||
- 环境变量:列表/详情/创建/更新/删除(secret 值为脱敏)
|
||||
- 日志:列表/详情、最近执行结果
|
||||
|
||||
使用原则:
|
||||
1. 先 list_tasks / get_task 确认目标,再 run_task
|
||||
2. 删除类操作前向用户确认
|
||||
3. 不要编造任务 ID;ID 以 list/get 返回为准
|
||||
4. 执行后用 list_logs 或 get_log 查看结果
|
||||
5. 创建任务时 command 必填(普通任务);schedule 使用标准 cron`
|
||||
|
||||
func registerTools(s *server.MCPServer) {
|
||||
// ---- tasks ----
|
||||
s.AddTool(mcp.NewTool("list_tasks",
|
||||
mcp.WithDescription("分页列出任务,可按名称/标签/启用状态筛选"),
|
||||
mcp.WithString("name", mcp.Description("按名称模糊筛选")),
|
||||
mcp.WithString("tags", mcp.Description("按标签筛选")),
|
||||
mcp.WithString("enabled", mcp.Description("true/false,是否启用")),
|
||||
mcp.WithNumber("page", mcp.Description("页码,默认 1")),
|
||||
mcp.WithNumber("page_size", mcp.Description("每页数量,默认 20")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
q := map[string]string{
|
||||
"name": strArg(req, "name"),
|
||||
"tags": strArg(req, "tags"),
|
||||
"enabled": strArg(req, "enabled"),
|
||||
"page": numArgStr(req, "page", "1"),
|
||||
"page_size": numArgStr(req, "page_size", "20"),
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Get("/tasks", q)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("get_task",
|
||||
mcp.WithDescription("获取单个任务详情"),
|
||||
mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
id, err := req.RequireString("id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Get("/tasks/"+id, nil)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("create_task",
|
||||
mcp.WithDescription("创建任务。普通任务必须提供 command;可选 schedule(cron)、tags、timeout、work_dir、remark、enabled 等"),
|
||||
mcp.WithString("name", mcp.Required(), mcp.Description("任务名称")),
|
||||
mcp.WithString("command", mcp.Description("执行命令,普通任务必填")),
|
||||
mcp.WithString("schedule", mcp.Description("Cron 表达式,如 0 0 * * *")),
|
||||
mcp.WithString("remark", mcp.Description("备注")),
|
||||
mcp.WithString("tags", mcp.Description("标签,逗号分隔")),
|
||||
mcp.WithString("work_dir", mcp.Description("工作目录")),
|
||||
mcp.WithNumber("timeout", mcp.Description("超时秒数")),
|
||||
mcp.WithString("type", mcp.Description("任务类型,默认 common;仓库同步为 repo")),
|
||||
mcp.WithString("config", mcp.Description("JSON 字符串配置(repo 任务用)")),
|
||||
mcp.WithString("envs", mcp.Description("环境变量 JSON 字符串")),
|
||||
mcp.WithString("trigger_type", mcp.Description("触发类型,如 cron")),
|
||||
mcp.WithNumber("retry_count", mcp.Description("重试次数")),
|
||||
mcp.WithNumber("retry_interval", mcp.Description("重试间隔秒")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
body := map[string]any{}
|
||||
setStr(body, "name", strArg(req, "name"))
|
||||
setStr(body, "command", strArg(req, "command"))
|
||||
setStr(body, "schedule", strArg(req, "schedule"))
|
||||
setStr(body, "remark", strArg(req, "remark"))
|
||||
setStr(body, "tags", strArg(req, "tags"))
|
||||
setStr(body, "work_dir", strArg(req, "work_dir"))
|
||||
setStr(body, "type", strArg(req, "type"))
|
||||
setStr(body, "config", strArg(req, "config"))
|
||||
setStr(body, "envs", strArg(req, "envs"))
|
||||
setStr(body, "trigger_type", strArg(req, "trigger_type"))
|
||||
if n, ok := numArg(req, "timeout"); ok {
|
||||
body["timeout"] = n
|
||||
}
|
||||
if n, ok := numArg(req, "retry_count"); ok {
|
||||
body["retry_count"] = n
|
||||
}
|
||||
if n, ok := numArg(req, "retry_interval"); ok {
|
||||
body["retry_interval"] = n
|
||||
}
|
||||
if body["name"] == nil || body["name"] == "" {
|
||||
return mcp.NewToolResultError("name 必填"), nil
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Post("/tasks", body)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("update_task",
|
||||
mcp.WithDescription("更新任务字段(只传需要改的字段)"),
|
||||
mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID")),
|
||||
mcp.WithString("name", mcp.Description("任务名称")),
|
||||
mcp.WithString("command", mcp.Description("命令")),
|
||||
mcp.WithString("schedule", mcp.Description("Cron")),
|
||||
mcp.WithString("remark", mcp.Description("备注")),
|
||||
mcp.WithString("tags", mcp.Description("标签")),
|
||||
mcp.WithString("work_dir", mcp.Description("工作目录")),
|
||||
mcp.WithString("enabled", mcp.Description("true/false")),
|
||||
mcp.WithNumber("timeout", mcp.Description("超时秒数")),
|
||||
mcp.WithString("envs", mcp.Description("环境变量 JSON 字符串")),
|
||||
mcp.WithString("type", mcp.Description("任务类型")),
|
||||
mcp.WithString("config", mcp.Description("配置 JSON 字符串")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
id, err := req.RequireString("id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
body := map[string]any{}
|
||||
setStr(body, "name", strArg(req, "name"))
|
||||
setStr(body, "command", strArg(req, "command"))
|
||||
setStr(body, "schedule", strArg(req, "schedule"))
|
||||
setStr(body, "remark", strArg(req, "remark"))
|
||||
setStr(body, "tags", strArg(req, "tags"))
|
||||
setStr(body, "work_dir", strArg(req, "work_dir"))
|
||||
setStr(body, "envs", strArg(req, "envs"))
|
||||
setStr(body, "type", strArg(req, "type"))
|
||||
setStr(body, "config", strArg(req, "config"))
|
||||
if e := strArg(req, "enabled"); e != "" {
|
||||
body["enabled"] = strings.EqualFold(e, "true") || e == "1"
|
||||
}
|
||||
if n, ok := numArg(req, "timeout"); ok {
|
||||
body["timeout"] = n
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Put("/tasks/"+id, body)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("delete_task",
|
||||
mcp.WithDescription("删除任务(危险操作,执行前请确认)"),
|
||||
mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
id, err := req.RequireString("id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Delete("/tasks/" + id)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("list_task_tags",
|
||||
mcp.WithDescription("获取所有任务标签"),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
raw, err := GetOpenAPIClient().Get("/tasks/tags", nil)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("run_task",
|
||||
mcp.WithDescription("立即执行指定任务。可选传入临时环境变量 envs_json"),
|
||||
mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID")),
|
||||
mcp.WithString("envs_json", mcp.Description("临时环境变量 JSON 对象字符串,如 {\"FOO\":\"bar\"}")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
id, err := req.RequireString("id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
var body any
|
||||
if ej := strArg(req, "envs_json"); ej != "" {
|
||||
var envs map[string]string
|
||||
if err := json.Unmarshal([]byte(ej), &envs); err != nil {
|
||||
return mcp.NewToolResultError("envs_json 不是合法 JSON 对象: " + err.Error()), nil
|
||||
}
|
||||
body = map[string]any{"envs": envs}
|
||||
} else {
|
||||
body = map[string]any{}
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Post("/execute/task/"+id, body)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("stop_task",
|
||||
mcp.WithDescription("停止正在运行的任务(需要执行日志 log_id)"),
|
||||
mcp.WithString("log_id", mcp.Required(), mcp.Description("运行日志 ID")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
logID, err := req.RequireString("log_id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Post("/tasks/stop/"+logID, map[string]any{})
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("get_last_results",
|
||||
mcp.WithDescription("获取最近若干次执行结果摘要"),
|
||||
mcp.WithNumber("count", mcp.Description("数量,默认 10")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
q := map[string]string{"count": numArgStr(req, "count", "10")}
|
||||
raw, err := GetOpenAPIClient().Get("/execute/results", q)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
// ---- logs ----
|
||||
s.AddTool(mcp.NewTool("list_logs",
|
||||
mcp.WithDescription("分页查询任务执行日志"),
|
||||
mcp.WithString("task_id", mcp.Description("任务 ID")),
|
||||
mcp.WithString("task_name", mcp.Description("任务名称模糊")),
|
||||
mcp.WithString("status", mcp.Description("状态筛选")),
|
||||
mcp.WithNumber("page", mcp.Description("页码,默认 1")),
|
||||
mcp.WithNumber("page_size", mcp.Description("每页数量,默认 20")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
q := map[string]string{
|
||||
"task_id": strArg(req, "task_id"),
|
||||
"task_name": strArg(req, "task_name"),
|
||||
"status": strArg(req, "status"),
|
||||
"page": numArgStr(req, "page", "1"),
|
||||
"page_size": numArgStr(req, "page_size", "20"),
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Get("/logs", q)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("get_log",
|
||||
mcp.WithDescription("获取单条执行日志详情(含输出)"),
|
||||
mcp.WithString("id", mcp.Required(), mcp.Description("日志 ID")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
id, err := req.RequireString("id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Get("/logs/"+id, nil)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
// ---- scripts ----
|
||||
s.AddTool(mcp.NewTool("list_scripts",
|
||||
mcp.WithDescription("列出脚本(不含内容)"),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
raw, err := GetOpenAPIClient().Get("/scripts", nil)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("get_script",
|
||||
mcp.WithDescription("获取脚本详情(含内容)"),
|
||||
mcp.WithString("id", mcp.Required(), mcp.Description("脚本 ID")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
id, err := req.RequireString("id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Get("/scripts/"+id, nil)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("create_script",
|
||||
mcp.WithDescription("创建脚本"),
|
||||
mcp.WithString("name", mcp.Required(), mcp.Description("脚本名称/路径")),
|
||||
mcp.WithString("content", mcp.Required(), mcp.Description("脚本内容")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
name, err := req.RequireString("name")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
content, err := req.RequireString("content")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Post("/scripts", map[string]any{"name": name, "content": content})
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("update_script",
|
||||
mcp.WithDescription("更新脚本"),
|
||||
mcp.WithString("id", mcp.Required(), mcp.Description("脚本 ID")),
|
||||
mcp.WithString("name", mcp.Description("名称")),
|
||||
mcp.WithString("content", mcp.Description("内容")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
id, err := req.RequireString("id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
body := map[string]any{}
|
||||
setStr(body, "name", strArg(req, "name"))
|
||||
setStr(body, "content", strArg(req, "content"))
|
||||
raw, err := GetOpenAPIClient().Put("/scripts/"+id, body)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("delete_script",
|
||||
mcp.WithDescription("删除脚本(危险操作)"),
|
||||
mcp.WithString("id", mcp.Required(), mcp.Description("脚本 ID")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
id, err := req.RequireString("id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Delete("/scripts/" + id)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
// ---- env ----
|
||||
s.AddTool(mcp.NewTool("list_envs",
|
||||
mcp.WithDescription("分页列出环境变量(secret 值已脱敏)"),
|
||||
mcp.WithString("name", mcp.Description("按名称筛选")),
|
||||
mcp.WithString("type", mcp.Description("类型筛选")),
|
||||
mcp.WithString("tags", mcp.Description("标签筛选")),
|
||||
mcp.WithNumber("page", mcp.Description("页码")),
|
||||
mcp.WithNumber("page_size", mcp.Description("每页数量")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
q := map[string]string{
|
||||
"name": strArg(req, "name"),
|
||||
"type": strArg(req, "type"),
|
||||
"tags": strArg(req, "tags"),
|
||||
"page": numArgStr(req, "page", "1"),
|
||||
"page_size": numArgStr(req, "page_size", "20"),
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Get("/env", q)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("list_all_envs",
|
||||
mcp.WithDescription("获取全部环境变量(不分页,secret 脱敏)"),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
raw, err := GetOpenAPIClient().Get("/env/all", nil)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("get_env",
|
||||
mcp.WithDescription("获取环境变量详情"),
|
||||
mcp.WithString("id", mcp.Required(), mcp.Description("环境变量 ID")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
id, err := req.RequireString("id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Get("/env/"+id, nil)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("create_env",
|
||||
mcp.WithDescription("创建环境变量"),
|
||||
mcp.WithString("name", mcp.Required(), mcp.Description("变量名")),
|
||||
mcp.WithString("value", mcp.Required(), mcp.Description("变量值")),
|
||||
mcp.WithString("type", mcp.Description("类型,如 text/secret")),
|
||||
mcp.WithString("remark", mcp.Description("备注")),
|
||||
mcp.WithString("tags", mcp.Description("标签")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
name, err := req.RequireString("name")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
value, err := req.RequireString("value")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
body := map[string]any{"name": name, "value": value}
|
||||
setStr(body, "type", strArg(req, "type"))
|
||||
setStr(body, "remark", strArg(req, "remark"))
|
||||
setStr(body, "tags", strArg(req, "tags"))
|
||||
raw, err := GetOpenAPIClient().Post("/env", body)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("update_env",
|
||||
mcp.WithDescription("更新环境变量"),
|
||||
mcp.WithString("id", mcp.Required(), mcp.Description("环境变量 ID")),
|
||||
mcp.WithString("name", mcp.Description("变量名")),
|
||||
mcp.WithString("value", mcp.Description("变量值")),
|
||||
mcp.WithString("type", mcp.Description("类型")),
|
||||
mcp.WithString("remark", mcp.Description("备注")),
|
||||
mcp.WithString("tags", mcp.Description("标签")),
|
||||
mcp.WithString("enabled", mcp.Description("true/false")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
id, err := req.RequireString("id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
body := map[string]any{}
|
||||
setStr(body, "name", strArg(req, "name"))
|
||||
setStr(body, "value", strArg(req, "value"))
|
||||
setStr(body, "type", strArg(req, "type"))
|
||||
setStr(body, "remark", strArg(req, "remark"))
|
||||
setStr(body, "tags", strArg(req, "tags"))
|
||||
if e := strArg(req, "enabled"); e != "" {
|
||||
body["enabled"] = strings.EqualFold(e, "true") || e == "1"
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Put("/env/"+id, body)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("delete_env",
|
||||
mcp.WithDescription("删除环境变量(危险操作)"),
|
||||
mcp.WithString("id", mcp.Required(), mcp.Description("环境变量 ID")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
id, err := req.RequireString("id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
raw, err := GetOpenAPIClient().Delete("/env/" + id)
|
||||
return toolResult(raw, err)
|
||||
})
|
||||
|
||||
// ---- meta ----
|
||||
s.AddTool(mcp.NewTool("ping_api",
|
||||
mcp.WithDescription("检查 OpenAPI 连通性与鉴权是否有效(通过 list_tasks page_size=1)"),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
raw, err := GetOpenAPIClient().Get("/tasks", map[string]string{"page": "1", "page_size": "1"})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("连通失败: " + err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText("OpenAPI 连通正常\n" + prettyRaw(raw)), nil
|
||||
})
|
||||
}
|
||||
|
||||
func registerPrompts(s *server.MCPServer) {
|
||||
s.AddPrompt(mcp.NewPrompt("ops_overview",
|
||||
mcp.WithPromptDescription("巡检任务池:列出任务与最近失败日志"),
|
||||
), func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
|
||||
return mcp.NewGetPromptResult(
|
||||
"TaskPool 巡检",
|
||||
[]mcp.PromptMessage{
|
||||
mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(
|
||||
"请使用 TaskPool MCP:1) list_tasks 查看任务概况 2) list_logs 筛选失败状态 3) 对重要失败 get_log 总结原因并给出是否建议 run_task 重跑。",
|
||||
)),
|
||||
},
|
||||
), nil
|
||||
})
|
||||
|
||||
s.AddPrompt(mcp.NewPrompt("run_and_check",
|
||||
mcp.WithPromptDescription("执行指定任务并检查日志"),
|
||||
mcp.WithArgument("task_id", mcp.RequiredArgument(), mcp.ArgumentDescription("任务 ID")),
|
||||
), func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
|
||||
taskID := ""
|
||||
if request.Params.Arguments != nil {
|
||||
taskID = request.Params.Arguments["task_id"]
|
||||
}
|
||||
text := fmt.Sprintf("请对 TaskPool 任务 %s:1) get_task 确认 2) run_task 执行 3) list_logs(task_id) 取最新日志 4) get_log 汇总输出与状态。", taskID)
|
||||
return mcp.NewGetPromptResult(
|
||||
"执行并检查",
|
||||
[]mcp.PromptMessage{
|
||||
mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(text)),
|
||||
},
|
||||
), nil
|
||||
})
|
||||
}
|
||||
|
||||
func toolResult(raw json.RawMessage, err error) (*mcp.CallToolResult, error) {
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(prettyRaw(raw)), nil
|
||||
}
|
||||
|
||||
func strArg(req mcp.CallToolRequest, key string) string {
|
||||
v, err := req.RequireString(key)
|
||||
if err != nil {
|
||||
if args, ok := req.Params.Arguments.(map[string]any); ok {
|
||||
if x, ok := args[key]; ok {
|
||||
return strings.TrimSpace(fmt.Sprint(x))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func numArg(req mcp.CallToolRequest, key string) (float64, bool) {
|
||||
if args, ok := req.Params.Arguments.(map[string]any); ok {
|
||||
if x, ok := args[key]; ok {
|
||||
switch t := x.(type) {
|
||||
case float64:
|
||||
return t, true
|
||||
case int:
|
||||
return float64(t), true
|
||||
case string:
|
||||
if t == "" {
|
||||
return 0, false
|
||||
}
|
||||
f, err := strconv.ParseFloat(t, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func numArgStr(req mcp.CallToolRequest, key, def string) string {
|
||||
if n, ok := numArg(req, key); ok {
|
||||
return strconv.FormatInt(int64(n), 10)
|
||||
}
|
||||
if s := strArg(req, key); s != "" {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func setStr(m map[string]any, k, v string) {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func prettyJSON(v any) string {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func prettyRaw(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return "null"
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
return prettyJSON(v)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/engigu/taskpool/internal/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// initMCPRoutes 初始化 MCP Server 路由
|
||||
// 使用 OpenAPI Token 进行鉴权
|
||||
func initMCPRoutes(root *gin.RouterGroup, c *Controllers) {
|
||||
// MCP 路由组 (使用 Bearer Token 鉴权,复用 OpenAPI 中间件)
|
||||
mcp := root.Group("/mcp")
|
||||
mcp.Use(middleware.OpenapiRequired())
|
||||
{
|
||||
// 所有 MCP 请求都通过同一个 handler 处理
|
||||
// StreamableHTTP 协议支持 POST 和 GET
|
||||
// 使用 /*action 匹配 /mcp 和 /mcp/ 以及 /mcp/xxx
|
||||
mcp.POST("/*action", c.MCP.HandleMCP)
|
||||
mcp.GET("/*action", c.MCP.HandleMCP)
|
||||
}
|
||||
|
||||
// 同时处理 /mcp 根路径(无尾部斜杠)
|
||||
mcpRoot := root.Group("/mcp")
|
||||
mcpRoot.Use(middleware.OpenapiRequired())
|
||||
{
|
||||
mcpRoot.POST("", c.MCP.HandleMCP)
|
||||
mcpRoot.GET("", c.MCP.HandleMCP)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ func RegisterControllers() *Controllers {
|
||||
Interconnect: controllers.NewInterconnectController(interconnectService),
|
||||
Data: controllers.NewDataController(taskController, envController),
|
||||
Install: controllers.NewInstallController(),
|
||||
MCP: controllers.NewMCPController(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ type Controllers struct {
|
||||
Interconnect *controllers.InterconnectController
|
||||
Data *controllers.DataController
|
||||
Install *controllers.InstallController
|
||||
MCP *controllers.MCPController
|
||||
}
|
||||
|
||||
func Setup(c *Controllers) *gin.Engine {
|
||||
@@ -79,6 +80,9 @@ func Setup(c *Controllers) *gin.Engine {
|
||||
initAgentAPIRoutes(root, c)
|
||||
initOpenAPIV1Routes(root, c)
|
||||
|
||||
// 5. [ location /mcp ] MCP Server 路由(供 AI Agent 使用)
|
||||
initMCPRoutes(root, c)
|
||||
|
||||
// =========================================================================
|
||||
// [ location / ] 全局 404 兜底与 SPA 渲染
|
||||
// 对应 Nginx: try_files $uri $uri/ /index.html;
|
||||
|
||||
@@ -52,7 +52,8 @@ func main() {
|
||||
}
|
||||
|
||||
if handler, ok := cmd.Handlers[commandName]; ok {
|
||||
if commandName != "-v" && commandName != "-V" && commandName != "version" {
|
||||
// mcp 是独立 stdio 进程,禁止初始化 DB/日志,避免污染协议流
|
||||
if commandName != "-v" && commandName != "-V" && commandName != "version" && commandName != "mcp" {
|
||||
bootstrap.InitBasicForCmd() // 专为命令行工具定制启动基础环境,屏蔽后台启动刷屏日志
|
||||
}
|
||||
handler(os.Args[2:])
|
||||
|
||||
+26
-5
@@ -9,11 +9,6 @@ server {
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
|
||||
gzip_min_length 1000;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy - 指向同一容器内的后端
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8052;
|
||||
@@ -24,6 +19,32 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# MCP Server proxy - AI Agent 接入
|
||||
location /mcp {
|
||||
proxy_pass http://127.0.0.1:8052;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# 支持 SSE 流式响应
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# OpenAPI proxy
|
||||
location /open2api/ {
|
||||
proxy_pass http://127.0.0.1:8052;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# TaskPool Agent Skill
|
||||
|
||||
你正在通过 **TaskPool MCP Server** 操作「任务池」自动化调度面板。
|
||||
真正执行依赖 MCP Tools;本 Skill 只规定操作流程与安全边界。
|
||||
|
||||
## 何时使用
|
||||
|
||||
用户提到:任务、定时任务、脚本、环境变量、执行日志、重跑失败任务、巡检 TaskPool/任务池 时,优先使用 `taskpool` MCP。
|
||||
|
||||
## 连接检查
|
||||
|
||||
1. 先调用 `ping_api`
|
||||
2. 失败则提示检查:
|
||||
- 面板是否启用 OpenAPI
|
||||
- `TASKPOOL_URL` / `TASKPOOL_TOKEN` 是否正确
|
||||
- 后端是否在运行
|
||||
|
||||
## 标准流程
|
||||
|
||||
### 巡检
|
||||
|
||||
1. `list_tasks`(可加 `enabled=true`)
|
||||
2. `list_logs` 查看最近失败(如有 status 参数则筛选 failed)
|
||||
3. 对关键失败调用 `get_log` 总结原因
|
||||
4. 仅在用户同意后 `run_task` 重跑
|
||||
|
||||
### 执行已有任务
|
||||
|
||||
1. `list_tasks` 或用户给出的 id → `get_task` 确认
|
||||
2. `run_task`(id 必填)
|
||||
3. `list_logs(task_id=...)` 取最新
|
||||
4. `get_log` 汇报状态与关键输出
|
||||
|
||||
### 创建定时任务
|
||||
|
||||
1. 向用户确认:名称、命令、cron、工作目录、超时
|
||||
2. `create_task`(name 必填;普通任务 command 必填)
|
||||
3. `get_task` 回读确认
|
||||
|
||||
### 改任务
|
||||
|
||||
1. `get_task`
|
||||
2. `update_task` 只传要改的字段
|
||||
3. 回读确认
|
||||
|
||||
### 脚本 / 环境变量
|
||||
|
||||
- 脚本:`list_scripts` → `get_script` → 必要时 create/update
|
||||
- 环境变量:`list_envs` / `get_env`;secret 值通常脱敏,不要臆造明文
|
||||
|
||||
## 安全规则
|
||||
|
||||
1. **删除**(`delete_task` / `delete_script` / `delete_env`)前必须明确征得用户同意
|
||||
2. 不要编造任务/日志 ID
|
||||
3. 不要把 Token、secret 环境变量原文写入回复
|
||||
4. 不要用 MCP 执行「任意系统破坏性命令」;只操作面板内已建模的任务
|
||||
|
||||
## 能力边界
|
||||
|
||||
| 可做 | 不可做(当前) |
|
||||
|------|----------------|
|
||||
| 任务 CRUD、执行、停跑 | 在线终端交互 |
|
||||
| 脚本 CRUD | Mise 语言安装 UI 全流程 |
|
||||
| 环境变量 CRUD | 消息渠道复杂配置向导 |
|
||||
| 日志查询 | 直接操作数据库 |
|
||||
|
||||
## 话术示例
|
||||
|
||||
- 「帮我看看任务池里有哪些启用任务」
|
||||
- 「把备份任务跑一遍并告诉我结果」
|
||||
- 「最近失败的任务是什么原因,要不要重跑?」
|
||||
- 「建一个每天 3 点执行的清理任务」
|
||||
@@ -211,9 +211,6 @@ export default function Layout() {
|
||||
{active ? <span className="ml-auto h-1.5 w-1.5 rounded-full bg-[var(--bg-accent)]" /> : null}
|
||||
</>
|
||||
)}
|
||||
{sidebarCollapsed && active ? (
|
||||
<span className="absolute right-1 top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full bg-[var(--bg-accent)]" />
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -6,9 +6,11 @@ export type BadgeTone = 'default' | 'success' | 'warn' | 'danger' | 'info'
|
||||
export function Badge({
|
||||
children,
|
||||
tone = 'default',
|
||||
size = 'md',
|
||||
}: {
|
||||
children: ReactNode
|
||||
tone?: BadgeTone
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}) {
|
||||
const tones: Record<BadgeTone, string> = {
|
||||
default: 'border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]',
|
||||
@@ -21,8 +23,13 @@ export function Badge({
|
||||
info:
|
||||
'border-[color-mix(in_srgb,var(--info)_25%,transparent)] bg-[color-mix(in_srgb,var(--info)_12%,transparent)] text-[var(--info)]',
|
||||
}
|
||||
const badgeSizes = {
|
||||
sm: 'px-1.5 py-px text-[10px]',
|
||||
md: 'px-2 py-0.5 text-[11px]',
|
||||
lg: 'px-2.5 py-1 text-[12px]',
|
||||
}
|
||||
return (
|
||||
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium', tones[tone])}>
|
||||
<span className={cn('inline-flex items-center rounded-md border font-medium', badgeSizes[size], tones[tone])}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ButtonHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'danger' | 'outline'
|
||||
export type ButtonSize = 'sm' | 'md' | 'icon'
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg'
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
|
||||
@@ -10,6 +10,7 @@ export { Badge } from './badge'
|
||||
export type { BadgeTone } from './badge'
|
||||
export { Modal } from './modal'
|
||||
export { Table, Th, Td } from './table'
|
||||
export type { TableSize } from './table'
|
||||
export { Field } from './field'
|
||||
export { PageHeader } from './page-header'
|
||||
export { Empty } from './empty'
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { InputHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input {...props} className={cn('ui-control h-9 px-3', className)} />
|
||||
const heights: Record<string, string> = {
|
||||
sm: 'h-[var(--ui-height-sm)] text-[length:var(--ui-font-sm)] px-[var(--ui-px-sm)]',
|
||||
md: 'h-[var(--ui-height-md)] text-[length:var(--ui-font-md)] px-[var(--ui-px-md)]',
|
||||
lg: 'h-[var(--ui-height-lg)] text-[length:var(--ui-font-lg)] px-[var(--ui-px-lg)]',
|
||||
}
|
||||
|
||||
export function Input({ className, size = 'md', ...props }: InputHTMLAttributes<HTMLInputElement> & { size?: 'sm' | 'md' | 'lg' }) {
|
||||
return <input {...props} className={cn('ui-control', heights[size], className)} />
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ export function Select({
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className={cn('ui-control ui-select-trigger h-9 px-3 text-left', open && 'ui-control-open')}
|
||||
className={cn('ui-control ui-select-trigger h-[var(--ui-height-md)] px-[var(--ui-px-md)] text-left text-[length:var(--ui-font-md)]', open && 'ui-control-open')}
|
||||
onClick={() => {
|
||||
if (disabled) return
|
||||
setOpen((v) => !v)
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const sizes = {
|
||||
sm: { track: 'h-4 w-7', thumb: 'h-2.5 w-2.5', on: 'translate-x-[14px]', off: 'translate-x-[2px]' },
|
||||
md: { track: 'h-5 w-9', thumb: 'h-3.5 w-3.5', on: 'translate-x-[18px]', off: 'translate-x-[3px]' },
|
||||
lg: { track: 'h-6 w-11', thumb: 'h-4 w-4', on: 'translate-x-[22px]', off: 'translate-x-[3px]' },
|
||||
}
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
className,
|
||||
size = 'md',
|
||||
}: {
|
||||
checked: boolean
|
||||
onCheckedChange: (v: boolean) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}) {
|
||||
const s = sizes[size]
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -18,7 +27,8 @@ export function Switch({
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition',
|
||||
'relative inline-flex shrink-0 items-center rounded-full border transition',
|
||||
s.track,
|
||||
checked ? 'border-transparent bg-[var(--bg-accent)]' : 'border-[var(--border)] bg-[var(--bg-tertiary)]',
|
||||
disabled && 'opacity-55 cursor-not-allowed',
|
||||
className,
|
||||
@@ -27,8 +37,9 @@ export function Switch({
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform',
|
||||
checked ? 'translate-x-[18px]' : 'translate-x-[3px]',
|
||||
'inline-block rounded-full bg-white shadow transition-transform',
|
||||
s.thumb,
|
||||
checked ? s.on : s.off,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type TableSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
const thSizes: Record<TableSize, string> = {
|
||||
sm: 'px-2 py-[var(--ui-table-py-sm)] text-[10px]',
|
||||
md: 'px-3 py-[var(--ui-table-py-md)] text-[11px]',
|
||||
lg: 'px-4 py-[var(--ui-table-py-lg)] text-[12px]',
|
||||
}
|
||||
|
||||
const tdSizes: Record<TableSize, string> = {
|
||||
sm: 'px-2 py-[var(--ui-table-py-sm)] text-[length:var(--ui-font-sm)]',
|
||||
md: 'px-3 py-[var(--ui-table-py-md)] text-[length:var(--ui-font-md)]',
|
||||
lg: 'px-4 py-[var(--ui-table-py-lg)] text-[length:var(--ui-font-lg)]',
|
||||
}
|
||||
|
||||
export function Table({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -9,11 +23,12 @@ export function Table({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Th({ children, className }: { children?: ReactNode; className?: string }) {
|
||||
export function Th({ children, className, size = 'md' }: { children?: ReactNode; className?: string; size?: TableSize }) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'border-b border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--text-muted)]',
|
||||
'border-b border-[var(--border)] bg-[var(--bg-secondary)] font-medium uppercase tracking-wide text-[var(--text-muted)]',
|
||||
thSizes[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -22,9 +37,9 @@ export function Th({ children, className }: { children?: ReactNode; className?:
|
||||
)
|
||||
}
|
||||
|
||||
export function Td({ children, className }: { children?: ReactNode; className?: string }) {
|
||||
export function Td({ children, className, size = 'md' }: { children?: ReactNode; className?: string; size?: TableSize }) {
|
||||
return (
|
||||
<td className={cn('border-b border-[var(--border)] px-3 py-2.5 align-middle text-[var(--text-primary)]', className)}>
|
||||
<td className={cn('border-b border-[var(--border)] align-middle text-[var(--text-primary)]', tdSizes[size], className)}>
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
|
||||
@@ -5,12 +5,19 @@ export function Tabs({
|
||||
onValueChange,
|
||||
items,
|
||||
className,
|
||||
size = 'md',
|
||||
}: {
|
||||
value: string
|
||||
onValueChange: (v: string) => void
|
||||
items: { value: string; label: string }[]
|
||||
className?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}) {
|
||||
const tabSizes = {
|
||||
sm: 'px-2.5 py-1 text-[12px]',
|
||||
md: 'px-3.5 py-1.5 text-[13px]',
|
||||
lg: 'px-4 py-2 text-[14px]',
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -29,7 +36,8 @@ export function Tabs({
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={cn(
|
||||
'relative shrink-0 rounded-md px-3.5 py-1.5 text-[13px] font-medium whitespace-nowrap transition-colors',
|
||||
'relative shrink-0 rounded-md font-medium whitespace-nowrap transition-colors',
|
||||
tabSizes[size],
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)]',
|
||||
active
|
||||
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-[var(--shadow-sm)] ring-1 ring-[var(--border)]'
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return <textarea {...props} className={cn('ui-control min-h-24 resize-y px-3 py-2', className)} />
|
||||
const sizes: Record<string, string> = {
|
||||
sm: 'text-[length:var(--ui-font-sm)] px-[var(--ui-px-sm)] py-1.5 min-h-16',
|
||||
md: 'text-[length:var(--ui-font-md)] px-[var(--ui-px-md)] py-2 min-h-24',
|
||||
lg: 'text-[length:var(--ui-font-lg)] px-[var(--ui-px-lg)] py-2.5 min-h-32',
|
||||
}
|
||||
|
||||
export function Textarea({ className, size = 'md', ...props }: TextareaHTMLAttributes<HTMLTextAreaElement> & { size?: 'sm' | 'md' | 'lg' }) {
|
||||
return <textarea {...props} className={cn('ui-control resize-y', sizes[size], className)} />
|
||||
}
|
||||
|
||||
+68
-11
@@ -78,6 +78,44 @@
|
||||
--info: #2563eb;
|
||||
}
|
||||
|
||||
/* ---- 尺寸系统:默认 md ---- */
|
||||
:root {
|
||||
--ui-height-sm: 1.75rem; /* 28px */
|
||||
--ui-height-md: 2.25rem; /* 36px */
|
||||
--ui-height-lg: 2.75rem; /* 44px */
|
||||
--ui-font-sm: 12px;
|
||||
--ui-font-md: 14px;
|
||||
--ui-font-lg: 15px;
|
||||
--ui-px-sm: 0.5rem;
|
||||
--ui-px-md: 0.75rem;
|
||||
--ui-px-lg: 1rem;
|
||||
--ui-gap-sm: 0.25rem;
|
||||
--ui-gap-md: 0.375rem;
|
||||
--ui-gap-lg: 0.5rem;
|
||||
--ui-table-py-sm: 0.375rem;
|
||||
--ui-table-py-md: 0.625rem;
|
||||
--ui-table-py-lg: 0.75rem;
|
||||
}
|
||||
|
||||
/* 紧凑模式 */
|
||||
[data-density='compact'] {
|
||||
--ui-height-sm: 1.5rem; /* 24px */
|
||||
--ui-height-md: 1.875rem; /* 30px */
|
||||
--ui-height-lg: 2.25rem; /* 36px */
|
||||
--ui-font-sm: 11px;
|
||||
--ui-font-md: 13px;
|
||||
--ui-font-lg: 14px;
|
||||
--ui-px-sm: 0.375rem;
|
||||
--ui-px-md: 0.5rem;
|
||||
--ui-px-lg: 0.75rem;
|
||||
--ui-gap-sm: 0.125rem;
|
||||
--ui-gap-md: 0.25rem;
|
||||
--ui-gap-lg: 0.375rem;
|
||||
--ui-table-py-sm: 0.25rem;
|
||||
--ui-table-py-md: 0.375rem;
|
||||
--ui-table-py-lg: 0.5rem;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -234,10 +272,10 @@ pre,
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
gap: var(--ui-gap-md);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-size: var(--ui-font-md);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
transition:
|
||||
@@ -310,19 +348,38 @@ pre,
|
||||
}
|
||||
|
||||
.ui-btn-sm {
|
||||
height: 2rem;
|
||||
padding: 0 0.625rem;
|
||||
font-size: 12px;
|
||||
height: var(--ui-height-sm);
|
||||
padding: 0 var(--ui-px-sm);
|
||||
font-size: var(--ui-font-sm);
|
||||
}
|
||||
|
||||
.ui-btn-md {
|
||||
height: 2.25rem;
|
||||
padding: 0 0.75rem;
|
||||
height: var(--ui-height-md);
|
||||
padding: 0 var(--ui-px-md);
|
||||
font-size: var(--ui-font-md);
|
||||
}
|
||||
|
||||
.ui-btn-lg {
|
||||
height: var(--ui-height-lg);
|
||||
padding: 0 var(--ui-px-lg);
|
||||
font-size: var(--ui-font-lg);
|
||||
}
|
||||
|
||||
.ui-btn-icon {
|
||||
height: 2.25rem;
|
||||
width: 2.25rem;
|
||||
height: var(--ui-height-md);
|
||||
width: var(--ui-height-md);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ui-btn-icon-sm {
|
||||
height: var(--ui-height-sm);
|
||||
width: var(--ui-height-sm);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ui-btn-icon-lg {
|
||||
height: var(--ui-height-lg);
|
||||
width: var(--ui-height-lg);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -357,9 +414,9 @@ pre,
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-size: var(--ui-font-sm);
|
||||
line-height: 1.35;
|
||||
min-height: 2rem;
|
||||
min-height: var(--ui-height-sm);
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
+100
-15
@@ -267,11 +267,23 @@ export default function Settings() {
|
||||
</Card>
|
||||
<Card className="space-y-3 p-4">
|
||||
<h2 className="font-medium">站点设置</h2>
|
||||
<Input placeholder="标题" value={siteForm.title} onChange={(e) => setSiteForm((f) => ({ ...f, title: e.target.value }))} />
|
||||
<Input placeholder="副标题" value={siteForm.subtitle} onChange={(e) => setSiteForm((f) => ({ ...f, subtitle: e.target.value }))} />
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-muted)]">站点标题</label>
|
||||
<Input placeholder="任务池" value={siteForm.title} onChange={(e) => setSiteForm((f) => ({ ...f, title: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-muted)]">站点副标题</label>
|
||||
<Input placeholder="轻量级任务调度平台" value={siteForm.subtitle} onChange={(e) => setSiteForm((f) => ({ ...f, subtitle: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input placeholder="分页大小" value={siteForm.page_size} onChange={(e) => setSiteForm((f) => ({ ...f, page_size: e.target.value }))} />
|
||||
<Input placeholder="Cookie 天数" value={siteForm.cookie_days} onChange={(e) => setSiteForm((f) => ({ ...f, cookie_days: e.target.value }))} />
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-muted)]">分页大小</label>
|
||||
<Input placeholder="20" value={siteForm.page_size} onChange={(e) => setSiteForm((f) => ({ ...f, page_size: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-muted)]">登录 Cookie 有效天数</label>
|
||||
<Input placeholder="7" value={siteForm.cookie_days} onChange={(e) => setSiteForm((f) => ({ ...f, cookie_days: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center justify-between text-sm">
|
||||
<span>启用 OpenAPI</span>
|
||||
@@ -283,16 +295,76 @@ export default function Settings() {
|
||||
生成 Token
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-md border border-[var(--border)] bg-[var(--bg-secondary)] p-3 text-xs leading-5 text-[var(--text-secondary)]">
|
||||
<div className="mb-2 font-medium text-[var(--text-primary)]">MCP Server(AI Agent 接入)</div>
|
||||
<p className="mb-2">
|
||||
MCP Server 已内置在后端服务中,启用 OpenAPI 后即可使用。AI Agent 可通过 MCP 协议完整接管任务/脚本/日志。
|
||||
</p>
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className="rounded border border-[var(--border)] bg-[var(--bg-tertiary)]/50 p-2">
|
||||
<div className="mb-1 text-xs font-medium text-[var(--text-primary)]">HTTP 模式(推荐)</div>
|
||||
<p className="mb-2 text-[var(--text-muted)]">远程 Agent 使用,直接访问内置端点(Hermes / OpenClaw)</p>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap rounded border border-[var(--border)] bg-[var(--bg-primary)] p-2 font-mono text-[11px] text-[var(--text-primary)]">{`{
|
||||
"mcpServers": {
|
||||
"taskpool": {
|
||||
"url": "${window.location.origin}/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${siteForm.openapi_token || '你的Token'}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`}</pre>
|
||||
</div>
|
||||
<div className="rounded border border-[var(--border)] bg-[var(--bg-tertiary)]/50 p-2">
|
||||
<div className="mb-1 text-xs font-medium text-[var(--text-primary)]">stdio 模式</div>
|
||||
<p className="mb-2 text-[var(--text-muted)]">本地 Agent 使用,需要安装 taskpool 二进制(Claude Desktop / Cursor)</p>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap rounded border border-[var(--border)] bg-[var(--bg-primary)] p-2 font-mono text-[11px] text-[var(--text-primary)]">{`{
|
||||
"mcpServers": {
|
||||
"taskpool": {
|
||||
"command": "taskpool",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[var(--text-muted)]">详细说明见仓库 docs/guide/mcp.md 与 skills/taskpool/SKILL.md</p>
|
||||
</div>
|
||||
<h3 className="pt-2 text-sm font-medium text-[var(--text-secondary)]">日志保留</h3>
|
||||
<p className="text-xs text-[var(--text-muted)]">设置各类日志的保留天数和最大条数限制,超出后自动清理最旧记录</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input placeholder="系统通知保留天" value={siteForm.system_notice_days} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_days: e.target.value }))} />
|
||||
<Input placeholder="系统通知最大条数" value={siteForm.system_notice_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_max_count: e.target.value }))} />
|
||||
<Input placeholder="推送日志保留天" value={siteForm.push_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_days: e.target.value }))} />
|
||||
<Input placeholder="推送日志最大条数" value={siteForm.push_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_max_count: e.target.value }))} />
|
||||
<Input placeholder="登录日志保留天" value={siteForm.login_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_days: e.target.value }))} />
|
||||
<Input placeholder="登录日志最大条数" value={siteForm.login_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_max_count: e.target.value }))} />
|
||||
<Input placeholder="调度日志保留天" value={siteForm.scheduler_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_days: e.target.value }))} />
|
||||
<Input placeholder="调度日志最大条数" value={siteForm.scheduler_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_max_count: e.target.value }))} />
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-muted)]">系统通知保留天数</label>
|
||||
<Input placeholder="7" value={siteForm.system_notice_days} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_days: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-muted)]">系统通知最大条数</label>
|
||||
<Input placeholder="1000" value={siteForm.system_notice_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_max_count: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-muted)]">推送日志保留天数</label>
|
||||
<Input placeholder="7" value={siteForm.push_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_days: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-muted)]">推送日志最大条数</label>
|
||||
<Input placeholder="1000" value={siteForm.push_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_max_count: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-muted)]">登录日志保留天数</label>
|
||||
<Input placeholder="30" value={siteForm.login_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_days: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-muted)]">登录日志最大条数</label>
|
||||
<Input placeholder="1000" value={siteForm.login_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_max_count: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-muted)]">调度日志保留天数</label>
|
||||
<Input placeholder="7" value={siteForm.scheduler_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_days: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-muted)]">调度日志最大条数</label>
|
||||
<Input placeholder="1000" value={siteForm.scheduler_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_max_count: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => saveSite.mutate()} disabled={saveSite.isPending}>
|
||||
保存站点设置
|
||||
@@ -388,10 +460,23 @@ export default function Settings() {
|
||||
{tab === 'scheduler' ? (
|
||||
<Card className="space-y-3 p-4">
|
||||
<h2 className="font-medium">调度设置</h2>
|
||||
<p className="text-xs text-[var(--text-muted)]">控制任务调度器的并发能力和队列行为</p>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Input placeholder="Worker 数" value={schedulerForm.worker_count} onChange={(e) => setSchedulerForm((f) => ({ ...f, worker_count: e.target.value }))} />
|
||||
<Input placeholder="队列大小" value={schedulerForm.queue_size} onChange={(e) => setSchedulerForm((f) => ({ ...f, queue_size: e.target.value }))} />
|
||||
<Input placeholder="限速间隔" value={schedulerForm.rate_interval} onChange={(e) => setSchedulerForm((f) => ({ ...f, rate_interval: e.target.value }))} />
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-muted)]">Worker 数量</label>
|
||||
<Input placeholder="4" value={schedulerForm.worker_count} onChange={(e) => setSchedulerForm((f) => ({ ...f, worker_count: e.target.value }))} />
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">并发执行任务的最大数量</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-muted)]">队列大小</label>
|
||||
<Input placeholder="100" value={schedulerForm.queue_size} onChange={(e) => setSchedulerForm((f) => ({ ...f, queue_size: e.target.value }))} />
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">等待执行的任务队列容量</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-muted)]">限速间隔(毫秒)</label>
|
||||
<Input placeholder="0" value={schedulerForm.rate_interval} onChange={(e) => setSchedulerForm((f) => ({ ...f, rate_interval: e.target.value }))} />
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">任务启动间隔,0 表示不限速</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => saveScheduler.mutate()} disabled={saveScheduler.isPending}>
|
||||
保存调度设置
|
||||
|
||||
Reference in New Issue
Block a user