Compare commits
38 Commits
7f54256ac0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e4b9602d0e | |||
| 2902919ae5 | |||
| 8ef0ff8507 | |||
| 36d39e4a47 | |||
| 901de3e340 | |||
| 455f46caa0 | |||
| 8be7e8ad2f | |||
| e2063dbeb8 | |||
| ce9d1b4f1c | |||
| 7430457f35 | |||
| 2cda95ddbd | |||
| 5db4364615 | |||
| ea5f712f8c | |||
| bf7e87b582 | |||
| 4d38d5df3c | |||
| 870ee1ce85 | |||
| 4c10a0032f | |||
| 42cb37b5e3 | |||
| 2bf410c825 | |||
| f3c89d35a7 | |||
| 9589572b4c | |||
| ec38778951 | |||
| 727c1dd8cb | |||
| f68c092520 | |||
| 2cc584713f | |||
| 060a8f132d | |||
| 19b3fb60f7 | |||
| 2c9b44aeca | |||
| bcf7cbbeea | |||
| f3a44a901e | |||
| 066383de83 | |||
| f004cfc937 | |||
| 334152417b | |||
| 6709c4ed77 | |||
| d104442e6b | |||
| a9024ff6f8 | |||
| 29319ee2d3 | |||
| a0a90f558c |
+61
-23
@@ -15,36 +15,74 @@ env:
|
|||||||
IMAGE_NAME: admin/taskpool
|
IMAGE_NAME: admin/taskpool
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
name: Build and Push Docker Image
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
runs-on: ubuntu
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
env:
|
||||||
|
RUNNER_TOOL_CACHE: /toolcache
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Install Docker CLI
|
||||||
uses: actions/checkout@v4
|
run: |
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
if command -v apk &> /dev/null; then
|
||||||
|
apk add --no-cache docker-cli
|
||||||
|
elif command -v apt-get &> /dev/null; then
|
||||||
|
apt-get update && apt-get install -y docker.io
|
||||||
|
else
|
||||||
|
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-24.0.7.tgz | tar xz -C /tmp
|
||||||
|
mv /tmp/docker/docker /usr/local/bin/
|
||||||
|
chmod +x /usr/local/bin/docker
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
docker --version
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
run: |
|
||||||
|
export PATH="/toolcache/bin:$PATH"
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
if command -v apk &> /dev/null; then
|
||||||
|
apk add --no-cache nodejs npm
|
||||||
|
else
|
||||||
|
curl -fsSL https://nodejs.org/dist/v22.0.0/node-v22.0.0-linux-x64.tar.xz | tar -xJ -C /tmp
|
||||||
|
cp -r /tmp/node-v22.0.0-linux-x64/{bin,lib,share} /toolcache/
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
node --version
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
run: |
|
||||||
|
cd /tmp
|
||||||
|
rm -rf repo
|
||||||
|
git clone --depth=1 https://admin:${{ secrets.TOKEN }}@git.viaeon.com/admin/TaskPool.git repo
|
||||||
|
cd repo
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
run: |
|
||||||
with:
|
export PATH="/toolcache/bin:$PATH"
|
||||||
version: 9
|
npm install -g pnpm@9
|
||||||
|
pnpm --version
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Install dependencies and build
|
||||||
uses: actions/setup-node@v4
|
run: |
|
||||||
with:
|
export PATH="/toolcache/bin:$PATH"
|
||||||
node-version: '22'
|
cd /tmp/repo/web
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
- name: Install dependencies
|
pnpm build
|
||||||
working-directory: web
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
working-directory: web
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Login to Registry
|
- name: Login to Registry
|
||||||
run: echo "${{ secrets.TOKEN }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
|
env:
|
||||||
|
TOKEN: ${{ secrets.TOKEN }}
|
||||||
- name: Build and push Docker image
|
|
||||||
run: |
|
run: |
|
||||||
|
echo "$TOKEN" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
cd /tmp/repo
|
||||||
docker build -f Dockerfile.standalone -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} .
|
docker build -f Dockerfile.standalone -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} .
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
run: |
|
||||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
- **远程执行:** 支持远程agent执行任务,展示执行结果
|
- **远程执行:** 支持远程agent执行任务,展示执行结果
|
||||||
- **多语言支持:** 深度集成 Mise,支持几乎所有主流编程语言的动态安装、多版本切换及依赖管理
|
- **多语言支持:** 深度集成 Mise,支持几乎所有主流编程语言的动态安装、多版本切换及依赖管理
|
||||||
- **内建助手库:** **(New)** 为 Python/Node.js 提供零配置助手库,简单 import 即可实现一键推信,无需手动管理 API Token 和 URL
|
- **内建助手库:** **(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 (
|
import (
|
||||||
"github.com/engigu/taskpool/cmd/builtininstall"
|
"github.com/engigu/taskpool/cmd/builtininstall"
|
||||||
"github.com/engigu/taskpool/cmd/depinstall"
|
"github.com/engigu/taskpool/cmd/depinstall"
|
||||||
|
mcpcmd "github.com/engigu/taskpool/cmd/mcp"
|
||||||
"github.com/engigu/taskpool/cmd/reposync"
|
"github.com/engigu/taskpool/cmd/reposync"
|
||||||
"github.com/engigu/taskpool/cmd/resetpwd"
|
"github.com/engigu/taskpool/cmd/resetpwd"
|
||||||
"github.com/engigu/taskpool/cmd/restore"
|
"github.com/engigu/taskpool/cmd/restore"
|
||||||
@@ -22,6 +23,7 @@ var Handlers = map[string]CommandHandler{
|
|||||||
"restore": restore.Run,
|
"restore": restore.Run,
|
||||||
"builtininstall": builtininstall.Run,
|
"builtininstall": builtininstall.Run,
|
||||||
"task": task.Run,
|
"task": task.Run,
|
||||||
|
"mcp": mcpcmd.Run,
|
||||||
"webui": webui.Run,
|
"webui": webui.Run,
|
||||||
"version": version.Run,
|
"version": version.Run,
|
||||||
"-v": 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: [
|
nav: [
|
||||||
{ text: '快速开始', link: '/guide/introduction' },
|
{ text: '快速开始', link: '/guide/introduction' },
|
||||||
{ text: '部署指南', link: '/guide/deployment' },
|
{ text: '部署指南', link: '/guide/deployment' },
|
||||||
{ text: 'API 文档', link: '/guide/api' }
|
{ text: 'API 文档', link: '/guide/api' },
|
||||||
|
{ text: 'MCP', link: '/guide/mcp' }
|
||||||
],
|
],
|
||||||
|
|
||||||
sidebar: [
|
sidebar: [
|
||||||
@@ -25,7 +26,8 @@ export default defineConfig({
|
|||||||
{ text: '项目介绍', link: '/guide/introduction' },
|
{ text: '项目介绍', link: '/guide/introduction' },
|
||||||
{ text: '部署说明', link: '/guide/deployment' },
|
{ text: '部署说明', link: '/guide/deployment' },
|
||||||
{ text: '开始使用', link: '/guide/getting-started' },
|
{ 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/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/hashicorp/yamux v0.1.2
|
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/robfig/cron/v3 v3.0.1
|
||||||
github.com/rs/xid v1.6.0
|
github.com/rs/xid v1.6.0
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
@@ -81,6 +83,7 @@ require (
|
|||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/gohugoio/hashstructure v0.6.0 // indirect
|
github.com/gohugoio/hashstructure v0.6.0 // indirect
|
||||||
github.com/gohugoio/hugo v0.163.3 // 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/google/uuid v1.6.0 // indirect
|
||||||
github.com/hairyhenderson/go-codeowners v0.7.1 // indirect
|
github.com/hairyhenderson/go-codeowners v0.7.1 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
@@ -91,7 +94,6 @@ require (
|
|||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/joho/godotenv v1.5.1 // indirect
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // 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/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // 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/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/shoenig/go-m1cpu v0.2.1 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.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/tklauser/numcpus v0.12.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.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
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
||||||
go.uber.org/multierr v1.11.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/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
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 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
|
||||||
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/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-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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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/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 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
|
||||||
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
|
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 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
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/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 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
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.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.30/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=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ var Commands = []CommandInfo{
|
|||||||
Name: "task",
|
Name: "task",
|
||||||
Description: "系统级任务的列表查询、触发运行、启停控制及状态查看",
|
Description: "系统级任务的列表查询、触发运行、启停控制及状态查看",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "mcp",
|
||||||
|
Description: "启动 MCP Server,供 Hermes / OpenClaw 等 AI Agent 接管任务池",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "depinstall",
|
Name: "depinstall",
|
||||||
Description: "一键补全指定任务执行日志中的缺失依赖包",
|
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),
|
Interconnect: controllers.NewInterconnectController(interconnectService),
|
||||||
Data: controllers.NewDataController(taskController, envController),
|
Data: controllers.NewDataController(taskController, envController),
|
||||||
Install: controllers.NewInstallController(),
|
Install: controllers.NewInstallController(),
|
||||||
|
MCP: controllers.NewMCPController(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type Controllers struct {
|
|||||||
Interconnect *controllers.InterconnectController
|
Interconnect *controllers.InterconnectController
|
||||||
Data *controllers.DataController
|
Data *controllers.DataController
|
||||||
Install *controllers.InstallController
|
Install *controllers.InstallController
|
||||||
|
MCP *controllers.MCPController
|
||||||
}
|
}
|
||||||
|
|
||||||
func Setup(c *Controllers) *gin.Engine {
|
func Setup(c *Controllers) *gin.Engine {
|
||||||
@@ -79,6 +80,9 @@ func Setup(c *Controllers) *gin.Engine {
|
|||||||
initAgentAPIRoutes(root, c)
|
initAgentAPIRoutes(root, c)
|
||||||
initOpenAPIV1Routes(root, c)
|
initOpenAPIV1Routes(root, c)
|
||||||
|
|
||||||
|
// 5. [ location /mcp ] MCP Server 路由(供 AI Agent 使用)
|
||||||
|
initMCPRoutes(root, c)
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// [ location / ] 全局 404 兜底与 SPA 渲染
|
// [ location / ] 全局 404 兜底与 SPA 渲染
|
||||||
// 对应 Nginx: try_files $uri $uri/ /index.html;
|
// 对应 Nginx: try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if handler, ok := cmd.Handlers[commandName]; ok {
|
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() // 专为命令行工具定制启动基础环境,屏蔽后台启动刷屏日志
|
bootstrap.InitBasicForCmd() // 专为命令行工具定制启动基础环境,屏蔽后台启动刷屏日志
|
||||||
}
|
}
|
||||||
handler(os.Args[2:])
|
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_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
|
||||||
gzip_min_length 1000;
|
gzip_min_length 1000;
|
||||||
|
|
||||||
# SPA fallback
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# API proxy - 指向同一容器内的后端
|
# API proxy - 指向同一容器内的后端
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:8052;
|
proxy_pass http://127.0.0.1:8052;
|
||||||
@@ -24,6 +19,32 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
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
|
# Cache static assets
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
expires 1y;
|
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 点执行的清理任务」
|
||||||
+10
-2
@@ -65,8 +65,16 @@ export interface MonitorStats {
|
|||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export let activeInterconnectNodeId = localStorage.getItem('activeInterconnectNodeId') || ''
|
|
||||||
export let activeInterconnectNodeName = localStorage.getItem('activeInterconnectNodeName') || ''
|
// 初始化时使用空字符串,避免 SSR 时访问 localStorage
|
||||||
|
export let activeInterconnectNodeId = ''
|
||||||
|
export let activeInterconnectNodeName = ''
|
||||||
|
|
||||||
|
// 在客户端初始化时从 localStorage 读取
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
activeInterconnectNodeId = localStorage.getItem('activeInterconnectNodeId') || ''
|
||||||
|
activeInterconnectNodeName = localStorage.getItem('activeInterconnectNodeName') || ''
|
||||||
|
}
|
||||||
|
|
||||||
export function setActiveInterconnectNodeId(id: string, name?: string) {
|
export function setActiveInterconnectNodeId(id: string, name?: string) {
|
||||||
activeInterconnectNodeId = id
|
activeInterconnectNodeId = id
|
||||||
|
|||||||
@@ -65,8 +65,16 @@ export interface MonitorStats {
|
|||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export let activeInterconnectNodeId = localStorage.getItem('activeInterconnectNodeId') || ''
|
|
||||||
export let activeInterconnectNodeName = localStorage.getItem('activeInterconnectNodeName') || ''
|
// 初始化时使用空字符串,避免 SSR 时访问 localStorage
|
||||||
|
export let activeInterconnectNodeId = ''
|
||||||
|
export let activeInterconnectNodeName = ''
|
||||||
|
|
||||||
|
// 在客户端初始化时从 localStorage 读取
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
activeInterconnectNodeId = localStorage.getItem('activeInterconnectNodeId') || ''
|
||||||
|
activeInterconnectNodeName = localStorage.getItem('activeInterconnectNodeName') || ''
|
||||||
|
}
|
||||||
|
|
||||||
export function setActiveInterconnectNodeId(id: string, name?: string) {
|
export function setActiveInterconnectNodeId(id: string, name?: string) {
|
||||||
activeInterconnectNodeId = id
|
activeInterconnectNodeId = id
|
||||||
|
|||||||
@@ -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}
|
{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>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||||
import { MonitorDot } from 'lucide-react'
|
import { MonitorDot, ChevronDown } from 'lucide-react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import {
|
import {
|
||||||
activeInterconnectNodeId,
|
activeInterconnectNodeId,
|
||||||
api,
|
api,
|
||||||
@@ -12,8 +13,12 @@ import { cn } from '@/lib/utils'
|
|||||||
|
|
||||||
export function NodeSwitcher() {
|
export function NodeSwitcher() {
|
||||||
const [nodes, setNodes] = useState<InterconnectNode[]>([])
|
const [nodes, setNodes] = useState<InterconnectNode[]>([])
|
||||||
const [selected, setSelected] = useState(activeInterconnectNodeId || 'local')
|
const [selected, setSelected] = useState('local')
|
||||||
const [isMaster, setIsMaster] = useState(false)
|
const [isMaster, setIsMaster] = useState(false)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({})
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
const fetchNodes = useCallback(async () => {
|
const fetchNodes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -34,15 +39,23 @@ export function NodeSwitcher() {
|
|||||||
} else {
|
} else {
|
||||||
setNodes([])
|
setNodes([])
|
||||||
setSelected('local')
|
setSelected('local')
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
const traveling = !!document.cookie.match(/(?:^| )active_interconnect_node_id=([^;]*)/)
|
const traveling = !!document.cookie.match(/(?:^| )active_interconnect_node_id=([^;]*)/)
|
||||||
if (activeInterconnectNodeId && !traveling) {
|
if (activeInterconnectNodeId && !traveling) {
|
||||||
setActiveInterconnectNodeId('')
|
setActiveInterconnectNodeId('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[fetchNodes],
|
[fetchNodes],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
// 在客户端初始化时从 localStorage 读取选中的节点
|
||||||
|
setSelected(activeInterconnectNodeId || 'local')
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -71,24 +84,53 @@ export function NodeSwitcher() {
|
|||||||
}
|
}
|
||||||
}, [isMaster, fetchNodes])
|
}, [isMaster, fetchNodes])
|
||||||
|
|
||||||
if (!isMaster) return null
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const updateMenuPosition = () => {
|
||||||
|
const el = buttonRef.current
|
||||||
|
if (!el) return
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const viewportH = window.innerHeight
|
||||||
|
const spaceBelow = viewportH - rect.bottom
|
||||||
|
const openUp = spaceBelow < 220 && rect.top > spaceBelow
|
||||||
|
setMenuStyle({
|
||||||
|
position: 'fixed',
|
||||||
|
left: rect.left,
|
||||||
|
width: Math.max(rect.width, 140),
|
||||||
|
zIndex: 80,
|
||||||
|
...(openUp
|
||||||
|
? { bottom: viewportH - rect.top + 6, top: 'auto' }
|
||||||
|
: { top: rect.bottom + 6, bottom: 'auto' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
updateMenuPosition()
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
const t = e.target as Node
|
||||||
|
if (buttonRef.current?.contains(t)) return
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
const onReposition = () => updateMenuPosition()
|
||||||
|
document.addEventListener('mousedown', onDoc)
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
window.addEventListener('resize', onReposition)
|
||||||
|
window.addEventListener('scroll', onReposition, true)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDoc)
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
window.removeEventListener('resize', onReposition)
|
||||||
|
window.removeEventListener('scroll', onReposition, true)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
return (
|
// SSR 时或非 master 节点时返回 null
|
||||||
<div
|
if (!mounted || !isMaster) return null
|
||||||
className={cn(
|
|
||||||
'inline-flex h-8 max-w-[150px] shrink-0 items-center gap-1 rounded-md border px-2 text-[11px] leading-none',
|
const handleSelect = (val: string) => {
|
||||||
selected !== 'local'
|
|
||||||
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'
|
|
||||||
: 'border-[var(--border)] bg-[var(--bg-primary)] text-[var(--text-secondary)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<MonitorDot size={12} className="shrink-0" />
|
|
||||||
<select
|
|
||||||
className="max-w-[120px] truncate border-0 bg-transparent text-[11px] outline-none"
|
|
||||||
value={selected}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value
|
|
||||||
setSelected(val)
|
setSelected(val)
|
||||||
|
setOpen(false)
|
||||||
if (val === 'local') {
|
if (val === 'local') {
|
||||||
setActiveInterconnectNodeId('')
|
setActiveInterconnectNodeId('')
|
||||||
} else {
|
} else {
|
||||||
@@ -96,15 +138,50 @@ export function NodeSwitcher() {
|
|||||||
setActiveInterconnectNodeId(val, name)
|
setActiveInterconnectNodeId(val, name)
|
||||||
}
|
}
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}}
|
}
|
||||||
|
|
||||||
|
const allOptions = [
|
||||||
|
{ value: 'local', label: '本机节点' },
|
||||||
|
...nodes.map((n) => ({ value: n.id, label: n.name })),
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentLabel = allOptions.find((o) => o.value === selected)?.label || '本机节点'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-8 max-w-[150px] shrink-0 items-center gap-1.5 rounded-md border px-2 text-[11px] leading-none transition-colors',
|
||||||
|
selected !== 'local'
|
||||||
|
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 hover:bg-amber-500/15 dark:text-amber-400'
|
||||||
|
: 'border-[var(--border)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<option value="local">本机节点</option>
|
<MonitorDot size={12} className="shrink-0" />
|
||||||
{nodes.map((n) => (
|
<span className="max-w-[100px] truncate">{currentLabel}</span>
|
||||||
<option key={n.id} value={n.id}>
|
<ChevronDown size={12} className="shrink-0 opacity-60" />
|
||||||
{n.name}
|
</button>
|
||||||
</option>
|
|
||||||
|
{open &&
|
||||||
|
mounted &&
|
||||||
|
createPortal(
|
||||||
|
<div className="ui-select-menu" style={menuStyle}>
|
||||||
|
{allOptions.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(opt.value)}
|
||||||
|
className={cn('ui-select-item', opt.value === selected && 'is-active')}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{opt.label}</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</select>
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ export type BadgeTone = 'default' | 'success' | 'warn' | 'danger' | 'info'
|
|||||||
export function Badge({
|
export function Badge({
|
||||||
children,
|
children,
|
||||||
tone = 'default',
|
tone = 'default',
|
||||||
|
size = 'md',
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
tone?: BadgeTone
|
tone?: BadgeTone
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
}) {
|
}) {
|
||||||
const tones: Record<BadgeTone, string> = {
|
const tones: Record<BadgeTone, string> = {
|
||||||
default: 'border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]',
|
default: 'border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]',
|
||||||
@@ -21,8 +23,13 @@ export function Badge({
|
|||||||
info:
|
info:
|
||||||
'border-[color-mix(in_srgb,var(--info)_25%,transparent)] bg-[color-mix(in_srgb,var(--info)_12%,transparent)] text-[var(--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 (
|
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}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ButtonHTMLAttributes } from 'react'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'danger' | 'outline'
|
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> {
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: ButtonVariant
|
variant?: ButtonVariant
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export { Badge } from './badge'
|
|||||||
export type { BadgeTone } from './badge'
|
export type { BadgeTone } from './badge'
|
||||||
export { Modal } from './modal'
|
export { Modal } from './modal'
|
||||||
export { Table, Th, Td } from './table'
|
export { Table, Th, Td } from './table'
|
||||||
|
export type { TableSize } from './table'
|
||||||
export { Field } from './field'
|
export { Field } from './field'
|
||||||
export { PageHeader } from './page-header'
|
export { PageHeader } from './page-header'
|
||||||
export { Empty } from './empty'
|
export { Empty } from './empty'
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { InputHTMLAttributes } from 'react'
|
import type { InputHTMLAttributes } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
const heights: Record<string, string> = {
|
||||||
return <input {...props} className={cn('ui-control h-9 px-3', className)} />
|
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}
|
disabled={disabled}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={open}
|
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={() => {
|
onClick={() => {
|
||||||
if (disabled) return
|
if (disabled) return
|
||||||
setOpen((v) => !v)
|
setOpen((v) => !v)
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import { cn } from '@/lib/utils'
|
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({
|
export function Switch({
|
||||||
checked,
|
checked,
|
||||||
onCheckedChange,
|
onCheckedChange,
|
||||||
disabled,
|
disabled,
|
||||||
className,
|
className,
|
||||||
|
size = 'md',
|
||||||
}: {
|
}: {
|
||||||
checked: boolean
|
checked: boolean
|
||||||
onCheckedChange: (v: boolean) => void
|
onCheckedChange: (v: boolean) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
}) {
|
}) {
|
||||||
|
const s = sizes[size]
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -18,7 +27,8 @@ export function Switch({
|
|||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
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)]',
|
checked ? 'border-transparent bg-[var(--bg-accent)]' : 'border-[var(--border)] bg-[var(--bg-tertiary)]',
|
||||||
disabled && 'opacity-55 cursor-not-allowed',
|
disabled && 'opacity-55 cursor-not-allowed',
|
||||||
className,
|
className,
|
||||||
@@ -27,8 +37,9 @@ export function Switch({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform',
|
'inline-block rounded-full bg-white shadow transition-transform',
|
||||||
checked ? 'translate-x-[18px]' : 'translate-x-[3px]',
|
s.thumb,
|
||||||
|
checked ? s.on : s.off,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
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 }) {
|
export function Table({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<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 (
|
return (
|
||||||
<th
|
<th
|
||||||
className={cn(
|
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,
|
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 (
|
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}
|
{children}
|
||||||
</td>
|
</td>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,12 +5,19 @@ export function Tabs({
|
|||||||
onValueChange,
|
onValueChange,
|
||||||
items,
|
items,
|
||||||
className,
|
className,
|
||||||
|
size = 'md',
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string
|
||||||
onValueChange: (v: string) => void
|
onValueChange: (v: string) => void
|
||||||
items: { value: string; label: string }[]
|
items: { value: string; label: string }[]
|
||||||
className?: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -29,7 +36,8 @@ export function Tabs({
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
className={cn(
|
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)]',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)]',
|
||||||
active
|
active
|
||||||
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-[var(--shadow-sm)] ring-1 ring-[var(--border)]'
|
? '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 type { TextareaHTMLAttributes } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
const sizes: Record<string, string> = {
|
||||||
return <textarea {...props} className={cn('ui-control min-h-24 resize-y px-3 py-2', className)} />
|
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)} />
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-13
@@ -78,6 +78,44 @@
|
|||||||
--info: #2563eb;
|
--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,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@@ -234,10 +272,10 @@ pre,
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.375rem;
|
gap: var(--ui-gap-md);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: 14px;
|
font-size: var(--ui-font-md);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
transition:
|
transition:
|
||||||
@@ -310,19 +348,38 @@ pre,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-btn-sm {
|
.ui-btn-sm {
|
||||||
height: 2rem;
|
height: var(--ui-height-sm);
|
||||||
padding: 0 0.625rem;
|
padding: 0 var(--ui-px-sm);
|
||||||
font-size: 12px;
|
font-size: var(--ui-font-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-btn-md {
|
.ui-btn-md {
|
||||||
height: 2.25rem;
|
height: var(--ui-height-md);
|
||||||
padding: 0 0.75rem;
|
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 {
|
.ui-btn-icon {
|
||||||
height: 2.25rem;
|
height: var(--ui-height-md);
|
||||||
width: 2.25rem;
|
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;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,9 +414,9 @@ pre,
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: var(--ui-font-sm);
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
min-height: 2rem;
|
min-height: var(--ui-height-sm);
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -368,13 +425,18 @@ pre,
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-select-item:hover:not(:disabled) {
|
.ui-select-item:hover:not(:disabled):not(.is-active) {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-select-item.is-active {
|
.ui-select-item.is-active {
|
||||||
background: var(--bg-selected);
|
background: var(--bg-selected);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-select-item.is-active:hover {
|
||||||
|
background: var(--bg-selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-select-item:disabled {
|
.ui-select-item:disabled {
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ class EventBusDriver {
|
|||||||
private initialized = false
|
private initialized = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
// 延迟初始化,避免 SSR 时访问 window
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
const baseUrl = (window as any).__BASE_URL__ || ''
|
const baseUrl = (window as any).__BASE_URL__ || ''
|
||||||
const apiVersion = (window as any).__API_VERSION__ || '/api/v1'
|
const apiVersion = (window as any).__API_VERSION__ || '/api/v1'
|
||||||
let host = window.location.host
|
let host = window.location.host
|
||||||
|
|||||||
+100
-15
@@ -267,11 +267,23 @@ export default function Settings() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card className="space-y-3 p-4">
|
<Card className="space-y-3 p-4">
|
||||||
<h2 className="font-medium">站点设置</h2>
|
<h2 className="font-medium">站点设置</h2>
|
||||||
<Input placeholder="标题" value={siteForm.title} onChange={(e) => setSiteForm((f) => ({ ...f, title: e.target.value }))} />
|
<div>
|
||||||
<Input placeholder="副标题" value={siteForm.subtitle} onChange={(e) => setSiteForm((f) => ({ ...f, subtitle: e.target.value }))} />
|
<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">
|
<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 }))} />
|
<div>
|
||||||
<Input placeholder="Cookie 天数" value={siteForm.cookie_days} onChange={(e) => setSiteForm((f) => ({ ...f, cookie_days: e.target.value }))} />
|
<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>
|
</div>
|
||||||
<label className="flex items-center justify-between text-sm">
|
<label className="flex items-center justify-between text-sm">
|
||||||
<span>启用 OpenAPI</span>
|
<span>启用 OpenAPI</span>
|
||||||
@@ -283,16 +295,76 @@ export default function Settings() {
|
|||||||
生成 Token
|
生成 Token
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
<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">
|
<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 }))} />
|
<div>
|
||||||
<Input placeholder="系统通知最大条数" value={siteForm.system_notice_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_max_count: e.target.value }))} />
|
<label className="mb-1 block text-xs text-[var(--text-muted)]">系统通知保留天数</label>
|
||||||
<Input placeholder="推送日志保留天" value={siteForm.push_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_days: e.target.value }))} />
|
<Input placeholder="7" value={siteForm.system_notice_days} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_days: e.target.value }))} />
|
||||||
<Input placeholder="推送日志最大条数" value={siteForm.push_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_max_count: e.target.value }))} />
|
</div>
|
||||||
<Input placeholder="登录日志保留天" value={siteForm.login_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_days: e.target.value }))} />
|
<div>
|
||||||
<Input placeholder="登录日志最大条数" value={siteForm.login_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_max_count: e.target.value }))} />
|
<label className="mb-1 block text-xs text-[var(--text-muted)]">系统通知最大条数</label>
|
||||||
<Input placeholder="调度日志保留天" value={siteForm.scheduler_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_days: e.target.value }))} />
|
<Input placeholder="1000" value={siteForm.system_notice_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_max_count: e.target.value }))} />
|
||||||
<Input placeholder="调度日志最大条数" value={siteForm.scheduler_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_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.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>
|
</div>
|
||||||
<Button onClick={() => saveSite.mutate()} disabled={saveSite.isPending}>
|
<Button onClick={() => saveSite.mutate()} disabled={saveSite.isPending}>
|
||||||
保存站点设置
|
保存站点设置
|
||||||
@@ -388,10 +460,23 @@ export default function Settings() {
|
|||||||
{tab === 'scheduler' ? (
|
{tab === 'scheduler' ? (
|
||||||
<Card className="space-y-3 p-4">
|
<Card className="space-y-3 p-4">
|
||||||
<h2 className="font-medium">调度设置</h2>
|
<h2 className="font-medium">调度设置</h2>
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">控制任务调度器的并发能力和队列行为</p>
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<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 }))} />
|
<div>
|
||||||
<Input placeholder="队列大小" value={schedulerForm.queue_size} onChange={(e) => setSchedulerForm((f) => ({ ...f, queue_size: e.target.value }))} />
|
<label className="mb-1 block text-xs text-[var(--text-muted)]">Worker 数量</label>
|
||||||
<Input placeholder="限速间隔" value={schedulerForm.rate_interval} onChange={(e) => setSchedulerForm((f) => ({ ...f, rate_interval: e.target.value }))} />
|
<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>
|
</div>
|
||||||
<Button onClick={() => saveScheduler.mutate()} disabled={saveScheduler.isPending}>
|
<Button onClick={() => saveScheduler.mutate()} disabled={saveScheduler.isPending}>
|
||||||
保存调度设置
|
保存调度设置
|
||||||
|
|||||||
Reference in New Issue
Block a user