feat: add deepseek-harness workbench plugin and console diagnosis API
- console: GET /api/workflows/{id}, /runs/{run_id}, /runs/{run_id}/diagnosis
(journal trace + sanitized bounded log tails, workflow/run pairing enforced)
- workbench/: dsh 宿主插件(7 个工作流工具、中文系统提示词、失败监控器、
本机回环桥接服务)+ 侧边栏面板客户端包(sidebar.footer.action 与
shell.overlay 追加插槽)+ 降级独立面板 + 一键启动脚本
- adapters/browser: 收敛 looks_like_login_url 到共享层,修复
jd_main_image_collector 对 gyxx_flow.accounts 的越层导入
- tests: 新端点覆盖;replay_policy 断言对齐已迁移的 catalog(repeatable)
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
# GYXX 智能工作台(deepseek-harness 集成)
|
||||
|
||||
以 [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`,DeepSeek 智能体底座)
|
||||
为 agent 运行时,把 gyxx-flow 的现有工作流**以插件形式**扩展进 dsh Web UI,形成一个
|
||||
可监控、可启动、可智能诊断/修复工作流的智能工作台。
|
||||
|
||||
```
|
||||
┌──────────────────────── dsh Web UI(DeepSeek 智能体)────────────────────────┐
|
||||
│ 侧边栏「工作流」面板 ───────────────┐ │
|
||||
│ (插件客户端包 shell.overlay 抽屉) │ 对话:选中工作流后提问 / 诊断 / 修复 │
|
||||
└─────────┬────────────────────────────┴───────────────────▲─────────────────┘
|
||||
│ 本机回环桥接 127.0.0.1:8790 │ 7 个工作流工具
|
||||
┌─────────▼─────────────────────────────────────────────────┴─────────────────┐
|
||||
│ gyxx-workbench 宿主插件(workbench/plugin/gyxx-workbench.mjs) │
|
||||
│ 工具注册 · 系统提示词 · 失败监控器 · 桥接服务 · 会话创建 │
|
||||
└─────────┬────────────────────────────────────────────────────────────────────┘
|
||||
│ HTTP(只读 + 受控写)
|
||||
┌─────────▼─────────────────────────────────────────┐
|
||||
│ gyxx console(127.0.0.1:8765,gyxx-flow 现有控制台) │
|
||||
│ /api/overview · /api/workflows/* · /api/dynamic-configs │
|
||||
└─────────┬─────────────────────────────────────────┘
|
||||
┌─────────▼─────────────────────────────────────────┐
|
||||
│ gyxx-flow 工作流引擎(LangGraph)· 调度器 · RunJournal │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
前置条件:Python 3.12 + `uv sync` 已完成;Node.js 22.19+;一个 DeepSeek API Key
|
||||
(`DEEPSEEK_API_KEY`,dsh 自身要求)。
|
||||
|
||||
```powershell
|
||||
# Windows:渲染补丁、拉起控制台(注入云端凭据)、启动 dsh Web UI
|
||||
powershell -File workbench\bin\start-workbench.ps1
|
||||
|
||||
# 控制台凭据文件不是默认路径时
|
||||
powershell -File workbench\bin\start-workbench.ps1 -ConsoleEnvFile <你的.env 路径>
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux/macOS
|
||||
bash workbench/bin/start-workbench.sh [console_env_file]
|
||||
```
|
||||
|
||||
启动后打开 dsh Web UI(默认 <http://127.0.0.1:3080>):
|
||||
|
||||
- 侧边栏底部出现「工作流」按钮 → 打开工作流面板;
|
||||
- 面板按模块分组列出全部调度工作流(状态点:绿=成功 / 红=失败 / 蓝=运行中 / 灰=未运行);
|
||||
- **选中任意工作流**后可直接:
|
||||
- 「提问」:带着该工作流上下文创建智能体会话,自由提问;
|
||||
- 「诊断」:自动获取最近一次失败运行的诊断包(步骤、脱敏日志)并输出根因报告;
|
||||
- 「修复」:智能体先诊断再给修复方案,**任何正式执行/调度修改必须先经你确认**;
|
||||
- 「试运行 / 正式运行 / 停止」:对应控制台的受控执行语义(试运行无外部副作用)。
|
||||
|
||||
降级方案:若 dsh 客户端包因版本差异未能加载,直接打开桥接服务自带的独立面板
|
||||
<http://127.0.0.1:8790/>,功能与侧边栏面板一致(零依赖页面)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
workbench/
|
||||
cordis.template.yml # dsh 组合补丁模板(启动脚本渲染出 cordis.local.yml)
|
||||
bin/
|
||||
start-workbench.ps1 # Windows 一键启动
|
||||
start-workbench.sh # Linux/macOS 一键启动
|
||||
plugin/ # dsh 插件包(@gyxx/dsh-plugin-gyxx-workbench)
|
||||
package.json # 含 dsh.client 声明(浏览器包发现契约)
|
||||
gyxx-workbench.mjs # 宿主插件:工具 / 提示词 / 监控 / 桥接(零运行时依赖)
|
||||
client/
|
||||
src/ # 侧边栏面板源码(React,打包时 react 外置)
|
||||
standalone.html # 降级独立面板(桥接服务直接托管)
|
||||
scripts/build-client.mjs
|
||||
lib/client.js # 已构建的浏览器包(随仓库提交,改源码后需重建)
|
||||
tests/ # node --test 冒烟测试
|
||||
```
|
||||
|
||||
## 智能体工具清单
|
||||
|
||||
| 工具 | 说明 | 副作用 |
|
||||
| --- | --- | --- |
|
||||
| `gyxx_workflow_list` | 全部工作流及调度、最近运行状态 | 无 |
|
||||
| `gyxx_workflow_detail` | 单工作流定义(步骤/依赖/重放策略/调度) | 无 |
|
||||
| `gyxx_workflow_runs` | 最近运行历史(步骤级状态、退出码、脱敏错误) | 无 |
|
||||
| `gyxx_workflow_diagnose` | 一次运行的完整诊断包(journal 路径 + 脱敏日志尾部) | 无 |
|
||||
| `gyxx_workflow_trigger` | 触发运行;默认试运行 | 试运行无副作用;正式执行需 `execute=true` + `confirmed=true` |
|
||||
| `gyxx_workflow_cancel` | 停止手动/定时运行 | 有(停止进程) |
|
||||
| `gyxx_schedule_update` | 启用/停用/改调度(读-改-写,带版本校验) | 有(改 `config/schedules.json`) |
|
||||
|
||||
## 安全模型
|
||||
|
||||
- 宿主插件与控制台都只绑定 `127.0.0.1`;桥接服务的写操作要求 `x-gyxx-workbench: 1`
|
||||
自定义头 + `application/json`,拒绝跨站表单提交;CORS 仅回环来源。
|
||||
- 控制台自身的守卫不变:正式执行仍需 `confirmed=true`,写操作仍需
|
||||
`X-GYXX-Console: 1` 与同源检查;令牌模式(`GYXX_CONSOLE_TOKEN`)对插件同样生效。
|
||||
- 系统提示词固化「诊断 → 方案 → 用户确认 → 执行」的修复顺序,禁止智能体跳过确认。
|
||||
- 日志经控制台脱敏管道(口令/Token/URL 凭据打码)后才进入对话上下文。
|
||||
|
||||
## 失败监控
|
||||
|
||||
宿主插件每 30s(`pollIntervalMs`)轮询 `/api/overview`:某工作流出现**新的**失败运行时
|
||||
→ 面板右下角弹出告警 toast(可一键「立即诊断」);`autoDiagnose: true` 时还会自动创建
|
||||
诊断会话。告警去重以 `workflow_id + run_id` 为准,恢复成功后重置。
|
||||
|
||||
## 配置项(cordis.local.yml → config)
|
||||
|
||||
| 键 | 默认 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `consoleBaseUrl` | `http://127.0.0.1:8765`(或环境变量 `GYXX_CONSOLE_URL`) | gyxx 控制台地址 |
|
||||
| `consoleToken` | 环境变量 `GYXX_CONSOLE_TOKEN` | 控制台访问令牌(控制台以令牌模式运行时必填) |
|
||||
| `bridgeHost` / `bridgePort` | `127.0.0.1` / `8790` | 桥接服务监听地址 |
|
||||
| `pollIntervalMs` | `30000` | 失败监控轮询间隔 |
|
||||
| `autoDiagnose` | `false` | 发现失败时自动创建诊断会话 |
|
||||
| `projectRoot` | dsh 进程 cwd | 新建智能体会话的工作目录 |
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 重建客户端包(修改 client/src 后必须执行并提交 lib/client.js)
|
||||
cd workbench/plugin && npm install && npm run build
|
||||
|
||||
# 宿主插件冒烟测试(不需要 dsh / gyxx console)
|
||||
cd workbench/plugin && npm test
|
||||
|
||||
# Python 侧端点测试
|
||||
uv run pytest tests/test_console.py -k "workflow_detail or run_detail or diagnosis"
|
||||
```
|
||||
|
||||
## 依赖的 dsh 扩展点(上游契约)
|
||||
|
||||
- `ctx.tools.register()` 原始 JSON-Schema 工具定义(cookbook: extension-cookbook)
|
||||
- `ctx.systemPrompt.section()` 系统提示词段
|
||||
- cordis.yml `--patch` 组合覆盖(`apps/cli/src/args.ts`,npm 版同样支持)
|
||||
- `dsh.client` package.json 声明 → 客户端包发现(`packages/client/modules`)
|
||||
- 插槽:`sidebar.footer.action`(list,追加)、`shell.overlay`(list,追加)
|
||||
- `ctx.agents.create()` + `agent.followup()` 编程式会话
|
||||
|
||||
dsh 处于 developer preview,扩展点可能变化;升级 dsh 后若面板消失,先检查
|
||||
浏览器控制台模块加载错误,再核对上述插槽名是否仍存在于
|
||||
`packages/client/ui-layout` / `ui-sidebar` 的 SlotMap。
|
||||
@@ -0,0 +1,76 @@
|
||||
# GYXX 智能工作台一键启动(Windows PowerShell)
|
||||
# 用法:
|
||||
# powershell -File workbench\bin\start-workbench.ps1
|
||||
# powershell -File workbench\bin\start-workbench.ps1 -ConsoleEnvFile D:\product-collector-analyze-flow\.env
|
||||
#
|
||||
# 行为:
|
||||
# 1. 渲染 workbench/cordis.local.yml(插件绝对路径)
|
||||
# 2. 若 8765 控制台未运行,则以 --env-file 注入云端凭据启动 gyxx console
|
||||
# 3. 启动 dsh Web UI 并应用工作台补丁(优先 $env:DSH_HOME 源码目录,其次 npx @deepseek-ai/dsh)
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConsoleEnvFile = 'D:\product-collector-analyze-flow\.env',
|
||||
[int]$ConsolePort = 8765,
|
||||
[int]$BridgePort = 8790,
|
||||
[switch]$SkipConsole
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$WorkbenchDir = Join-Path $RepoRoot 'workbench'
|
||||
$PluginDir = Join-Path $WorkbenchDir 'plugin'
|
||||
$LocalYml = Join-Path $WorkbenchDir 'cordis.local.yml'
|
||||
|
||||
# 1) 渲染 cordis.local.yml
|
||||
$template = Get-Content (Join-Path $WorkbenchDir 'cordis.template.yml') -Raw -Encoding UTF8
|
||||
$pluginDirPosix = ($PluginDir -replace '\\', '/')
|
||||
$projectRootPosix = ($RepoRoot -replace '\\', '/')
|
||||
$rendered = $template.Replace('__PLUGIN_DIR__', $pluginDirPosix).Replace('__PROJECT_ROOT__', $projectRootPosix)
|
||||
$rendered = $rendered -replace 'bridgePort: 8790', "bridgePort: $BridgePort"
|
||||
$rendered = $rendered -replace "consoleBaseUrl: 'http://127.0.0.1:8765'", "consoleBaseUrl: 'http://127.0.0.1:$ConsolePort'"
|
||||
[System.IO.File]::WriteAllText($LocalYml, $rendered, (New-Object System.Text.UTF8Encoding($false)))
|
||||
Write-Host "[workbench] 已生成 $LocalYml"
|
||||
|
||||
# 2) 确保 gyxx console 在运行
|
||||
if (-not $SkipConsole) {
|
||||
$consoleUp = $false
|
||||
try {
|
||||
$null = Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$ConsolePort/api/overview" -TimeoutSec 3
|
||||
$consoleUp = $true
|
||||
} catch { $consoleUp = $false }
|
||||
|
||||
if ($consoleUp) {
|
||||
Write-Host "[workbench] gyxx console 已在 http://127.0.0.1:$ConsolePort 运行"
|
||||
} else {
|
||||
if (-not (Test-Path $ConsoleEnvFile)) {
|
||||
Write-Warning "缺少 $ConsoleEnvFile —— 正式执行将无法注入云端凭据(AGENTS.md 约定)。"
|
||||
}
|
||||
$consoleArgs = @('run', 'gyxx', 'console', '--port', "$ConsolePort", '--env-file', $ConsoleEnvFile)
|
||||
Write-Host "[workbench] 启动 gyxx console: uv $($consoleArgs -join ' ')"
|
||||
Start-Process -FilePath 'uv' -ArgumentList $consoleArgs -WorkingDirectory $RepoRoot -WindowStyle Minimized
|
||||
$deadline = (Get-Date).AddSeconds(30)
|
||||
do {
|
||||
Start-Sleep -Milliseconds 800
|
||||
try {
|
||||
$null = Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$ConsolePort/api/overview" -TimeoutSec 2
|
||||
$consoleUp = $true
|
||||
} catch { $consoleUp = $false }
|
||||
} until ($consoleUp -or (Get-Date) -gt $deadline)
|
||||
if (-not $consoleUp) { Write-Warning 'gyxx console 启动超时,工作台仍可启动但工具会提示控制台离线。' }
|
||||
}
|
||||
}
|
||||
|
||||
# 3) 启动 dsh Web UI
|
||||
if ($env:DSH_HOME -and (Test-Path (Join-Path $env:DSH_HOME 'package.json'))) {
|
||||
Write-Host "[workbench] 使用源码版 dsh: $($env:DSH_HOME)"
|
||||
Push-Location $env:DSH_HOME
|
||||
try {
|
||||
& pnpm dsh web --patch $LocalYml
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Host '[workbench] 使用 npm 版 dsh(npx @deepseek-ai/dsh)'
|
||||
& npx -y '@deepseek-ai/dsh' web --patch $LocalYml
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# GYXX 智能工作台一键启动(Linux/macOS)
|
||||
# 用法:bash workbench/bin/start-workbench.sh [console_env_file]
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
WORKBENCH_DIR="$REPO_ROOT/workbench"
|
||||
LOCAL_YML="$WORKBENCH_DIR/cordis.local.yml"
|
||||
CONSOLE_ENV_FILE="${1:-${GYXX_CONSOLE_ENV_FILE:-/etc/gyxx/flow.env}}"
|
||||
CONSOLE_PORT="${GYXX_CONSOLE_PORT:-8765}"
|
||||
BRIDGE_PORT="${GYXX_WB_BRIDGE_PORT:-8790}"
|
||||
|
||||
# 1) 渲染 cordis.local.yml(插件路径必须为绝对路径)
|
||||
sed \
|
||||
-e "s|__PLUGIN_DIR__|$WORKBENCH_DIR/plugin|g" \
|
||||
-e "s|__PROJECT_ROOT__|$REPO_ROOT|g" \
|
||||
-e "s|bridgePort: 8790|bridgePort: $BRIDGE_PORT|g" \
|
||||
-e "s|http://127.0.0.1:8765|http://127.0.0.1:$CONSOLE_PORT|g" \
|
||||
"$WORKBENCH_DIR/cordis.template.yml" > "$LOCAL_YML"
|
||||
echo "[workbench] 已生成 $LOCAL_YML"
|
||||
|
||||
# 2) 确保 gyxx console 在运行
|
||||
if ! curl -fsS -m 3 "http://127.0.0.1:$CONSOLE_PORT/api/overview" >/dev/null 2>&1; then
|
||||
if [ ! -f "$CONSOLE_ENV_FILE" ]; then
|
||||
echo "[workbench] 警告:缺少 $CONSOLE_ENV_FILE —— 正式执行将无法注入云端凭据(AGENTS.md 约定)。" >&2
|
||||
fi
|
||||
echo "[workbench] 启动 gyxx console(端口 $CONSOLE_PORT)"
|
||||
(cd "$REPO_ROOT" && nohup uv run gyxx console --port "$CONSOLE_PORT" --env-file "$CONSOLE_ENV_FILE" >/dev/null 2>&1 &)
|
||||
for _ in $(seq 1 30); do
|
||||
sleep 1
|
||||
if curl -fsS -m 2 "http://127.0.0.1:$CONSOLE_PORT/api/overview" >/dev/null 2>&1; then break; fi
|
||||
done
|
||||
fi
|
||||
|
||||
# 3) 启动 dsh Web UI
|
||||
if [ -n "${DSH_HOME:-}" ] && [ -f "$DSH_HOME/package.json" ]; then
|
||||
echo "[workbench] 使用源码版 dsh: $DSH_HOME"
|
||||
(cd "$DSH_HOME" && pnpm dsh web --patch "$LOCAL_YML")
|
||||
else
|
||||
echo "[workbench] 使用 npm 版 dsh(npx @deepseek-ai/dsh)"
|
||||
npx -y @deepseek-ai/dsh web --patch "$LOCAL_YML"
|
||||
fi
|
||||
@@ -0,0 +1,22 @@
|
||||
# GYXX 智能工作台 —— dsh Web 组合补丁(模板)。
|
||||
# 启动脚本会把 __PLUGIN_DIR__ / __PROJECT_ROOT__ 渲染为绝对路径后生成
|
||||
# cordis.local.yml,再通过 `dsh web --patch workbench/cordis.local.yml` 加载。
|
||||
# 不要直接手工使用本文件:dsh 要求插件路径为绝对路径。
|
||||
|
||||
- insert:
|
||||
- id: gyxx-workbench
|
||||
name: '__PLUGIN_DIR__/plugin/gyxx-workbench.mjs'
|
||||
config:
|
||||
# gyxx 控制台 HTTP API(gyxx console,默认 8765 端口)
|
||||
consoleBaseUrl: 'http://127.0.0.1:8765'
|
||||
# 访问令牌:缺省读取进程环境变量 GYXX_CONSOLE_TOKEN
|
||||
# consoleToken: ''
|
||||
# 侧边栏面板桥接服务(本机回环)
|
||||
bridgeHost: '127.0.0.1'
|
||||
bridgePort: 8790
|
||||
# 失败监控轮询间隔(毫秒)
|
||||
pollIntervalMs: 30000
|
||||
# 发现失败时是否自动创建诊断会话(默认 false,只在面板弹告警)
|
||||
autoDiagnose: false
|
||||
# 智能体会话的工作目录(数据根目录下的日志可用文件工具直接查看)
|
||||
projectRoot: '__PROJECT_ROOT__'
|
||||
@@ -0,0 +1,355 @@
|
||||
/** 工作流面板:侧边抽屉,列出全部工作流,选中后可提问 / 诊断 / 修复 / 启停。 */
|
||||
|
||||
import { useSyncExternalStore, useEffect, useCallback } from 'react'
|
||||
import { getState, subscribe, update, pushToast } from './store.js'
|
||||
import { api } from './api.js'
|
||||
|
||||
function useWorkbench() {
|
||||
return useSyncExternalStore(subscribe, getState)
|
||||
}
|
||||
|
||||
const STATUS_LABELS = {
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
running: '运行中',
|
||||
cancelled: '已停止',
|
||||
none: '未运行',
|
||||
}
|
||||
|
||||
function statusOf(workflow) {
|
||||
if (workflow.active_run) return 'running'
|
||||
return workflow.last_run?.status ?? 'none'
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds == null) return ''
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
return `${Math.floor(seconds / 60)}m${seconds % 60 ? `${seconds % 60}s` : ''}`
|
||||
}
|
||||
|
||||
export async function refreshWorkflows() {
|
||||
update({ loading: true, error: null })
|
||||
try {
|
||||
const payload = await api.listWorkflows()
|
||||
update({
|
||||
loading: false,
|
||||
workflows: payload.workflows ?? [],
|
||||
summary: payload.summary ?? null,
|
||||
generatedAt: payload.generated_at ?? null,
|
||||
})
|
||||
} catch (error) {
|
||||
update({ loading: false, error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function selectWorkflow(workflowId) {
|
||||
update({ selectedId: workflowId, detailLoading: true, diagnosis: null, runs: [] })
|
||||
try {
|
||||
const [detail, runs] = await Promise.all([
|
||||
api.workflowDetail(workflowId).catch(() => null),
|
||||
api.workflowRuns(workflowId, 6).catch(() => ({ runs: [] })),
|
||||
])
|
||||
update({ detailLoading: false, detail, runs: runs.runs ?? [] })
|
||||
} catch (error) {
|
||||
update({ detailLoading: false, error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiagnosis(workflowId, runId) {
|
||||
update({ diagnosisLoading: true })
|
||||
try {
|
||||
const payload = await api.diagnosis(workflowId, runId)
|
||||
update({ diagnosisLoading: false, diagnosis: payload })
|
||||
} catch (error) {
|
||||
update({ diagnosisLoading: false })
|
||||
pushToast({ kind: 'error', text: `诊断数据加载失败:${error.message}` })
|
||||
}
|
||||
}
|
||||
|
||||
async function doAsk(action) {
|
||||
const s = getState()
|
||||
if (!s.selectedId) return
|
||||
const question = s.question.trim()
|
||||
try {
|
||||
const payload = await api.ask({
|
||||
workflow_id: s.selectedId,
|
||||
action,
|
||||
question,
|
||||
})
|
||||
const label = { ask: '提问', diagnose: '诊断', repair: '修复' }[action] ?? '会话'
|
||||
pushToast({
|
||||
kind: 'ok',
|
||||
text: `已创建${label}会话(${String(payload.session_id).slice(0, 18)}…),请在左侧会话列表中查看`,
|
||||
})
|
||||
update({ question: '' })
|
||||
} catch (error) {
|
||||
pushToast({ kind: 'error', text: `创建会话失败:${error.message}` })
|
||||
}
|
||||
}
|
||||
|
||||
async function doTrigger(execute) {
|
||||
const s = getState()
|
||||
if (!s.selectedId) return
|
||||
if (execute) {
|
||||
const ok = window.confirm(
|
||||
`正式执行 ${s.selectedId}(业务日期 ${s.businessDate})会对外部系统产生真实写入。确认继续?`,
|
||||
)
|
||||
if (!ok) return
|
||||
}
|
||||
try {
|
||||
await api.trigger({
|
||||
workflow_id: s.selectedId,
|
||||
business_date: s.businessDate,
|
||||
execute,
|
||||
confirmed: execute,
|
||||
})
|
||||
pushToast({ kind: 'ok', text: execute ? '已发起正式执行' : '已发起试运行(无外部副作用)' })
|
||||
setTimeout(refreshWorkflows, 1500)
|
||||
} catch (error) {
|
||||
pushToast({ kind: 'error', text: `启动失败:${error.message}` })
|
||||
}
|
||||
}
|
||||
|
||||
async function doCancel() {
|
||||
const s = getState()
|
||||
const active = s.detail?.workflow?.active_run
|
||||
if (!s.selectedId) return
|
||||
try {
|
||||
if (active?.operation_id) {
|
||||
await api.cancel({ workflow_id: s.selectedId, operation_id: active.operation_id })
|
||||
} else {
|
||||
await api.cancel({ workflow_id: s.selectedId, scheduled: true })
|
||||
}
|
||||
pushToast({ kind: 'ok', text: '已发送停止指令' })
|
||||
setTimeout(refreshWorkflows, 1500)
|
||||
} catch (error) {
|
||||
pushToast({ kind: 'error', text: `停止失败:${error.message}` })
|
||||
}
|
||||
}
|
||||
|
||||
function SummaryPills({ summary, workflows }) {
|
||||
const failed = workflows.filter((w) => statusOf(w) === 'failed').length
|
||||
const running = workflows.filter((w) => w.active_run).length
|
||||
return (
|
||||
<div className="gyxxwb-pills">
|
||||
<span className="gyxxwb-pill">共 {summary?.total ?? workflows.length}</span>
|
||||
{running > 0 && <span className="gyxxwb-pill gyxxwb-pill-running">运行中 {running}</span>}
|
||||
{failed > 0 && <span className="gyxxwb-pill gyxxwb-pill-failed">失败 {failed}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkflowRow({ workflow, selected, onSelect }) {
|
||||
const status = statusOf(workflow)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`gyxxwb-row ${selected ? 'gyxxwb-row-selected' : ''}`}
|
||||
onClick={() => onSelect(workflow.id)}
|
||||
>
|
||||
<span className={`gyxxwb-dot gyxxwb-dot-${status}`} />
|
||||
<span className="gyxxwb-row-main">
|
||||
<span className="gyxxwb-row-name">{workflow.name}</span>
|
||||
<span className="gyxxwb-row-id">{workflow.id}</span>
|
||||
</span>
|
||||
<span className="gyxxwb-row-meta">
|
||||
{workflow.last_run ? formatTime(workflow.last_run.ended_at ?? workflow.last_run.started_at) : '—'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function RunList({ runs, onDiagnose }) {
|
||||
if (!runs.length) return <div className="gyxxwb-empty">暂无运行记录</div>
|
||||
return (
|
||||
<div className="gyxxwb-runs">
|
||||
{runs.map((run) => (
|
||||
<button
|
||||
type="button"
|
||||
key={run.run_id}
|
||||
className="gyxxwb-run"
|
||||
onClick={() => onDiagnose(run.run_id)}
|
||||
title="点击查看诊断详情"
|
||||
>
|
||||
<span className={`gyxxwb-dot gyxxwb-dot-${run.status}`} />
|
||||
<span>{run.business_date}</span>
|
||||
<span className="gyxxwb-run-mode">{run.mode === 'execute' ? '正式' : '试运行'}</span>
|
||||
<span>{formatDuration(run.duration_seconds)}</span>
|
||||
{run.error && <span className="gyxxwb-run-error">{run.error.slice(0, 60)}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiagnosisView({ diagnosis, loading }) {
|
||||
if (loading) return <div className="gyxxwb-empty">正在加载诊断数据…</div>
|
||||
if (!diagnosis) return null
|
||||
if (diagnosis.message) return <div className="gyxxwb-empty">{diagnosis.message}</div>
|
||||
const run = diagnosis.run ?? {}
|
||||
const steps = Array.isArray(run.steps) ? run.steps : []
|
||||
const logs = Array.isArray(diagnosis.logs) ? diagnosis.logs : []
|
||||
return (
|
||||
<div className="gyxxwb-diagnosis">
|
||||
<div className="gyxxwb-diagnosis-head">
|
||||
诊断:{run.run_id}({run.status})
|
||||
</div>
|
||||
{steps
|
||||
.filter((step) => step.status === 'failed')
|
||||
.map((step) => (
|
||||
<div key={step.id} className="gyxxwb-step-fail">
|
||||
<div className="gyxxwb-step-id">
|
||||
✗ {step.id}(退出码 {step.exit_code ?? '—'})
|
||||
</div>
|
||||
{step.error && <pre className="gyxxwb-pre">{step.error}</pre>}
|
||||
</div>
|
||||
))}
|
||||
{logs.map((log) => (
|
||||
<div key={log.path} className="gyxxwb-log">
|
||||
<div className="gyxxwb-log-path">{log.path}</div>
|
||||
<pre className="gyxxwb-pre">{log.tail}</pre>
|
||||
</div>
|
||||
))}
|
||||
{!steps.some((s) => s.status === 'failed') && !logs.length && (
|
||||
<div className="gyxxwb-empty">该运行没有失败步骤或日志</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkflowPanel() {
|
||||
const s = useWorkbench()
|
||||
|
||||
useEffect(() => {
|
||||
if (!s.open) return undefined
|
||||
refreshWorkflows()
|
||||
const timer = setInterval(refreshWorkflows, 10_000)
|
||||
return () => clearInterval(timer)
|
||||
}, [s.open])
|
||||
|
||||
const onSelect = useCallback((workflowId) => {
|
||||
selectWorkflow(workflowId)
|
||||
}, [])
|
||||
|
||||
const groups = []
|
||||
const byModule = new Map()
|
||||
for (const workflow of s.workflows) {
|
||||
const key = workflow.module_label ?? workflow.module
|
||||
if (!byModule.has(key)) byModule.set(key, [])
|
||||
byModule.get(key).push(workflow)
|
||||
}
|
||||
for (const [label, items] of byModule) groups.push({ label, items })
|
||||
|
||||
const selected = s.workflows.find((w) => w.id === s.selectedId) ?? null
|
||||
|
||||
return (
|
||||
<div className={`gyxxwb-panel ${s.open ? 'gyxxwb-panel-open' : ''}`}>
|
||||
<div className="gyxxwb-panel-head">
|
||||
<span className="gyxxwb-title">GYXX 工作流</span>
|
||||
<SummaryPills summary={s.summary} workflows={s.workflows} />
|
||||
<button type="button" className="gyxxwb-icon-btn" title="刷新" onClick={refreshWorkflows}>
|
||||
⟳
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="gyxxwb-icon-btn"
|
||||
title="关闭"
|
||||
onClick={() => update({ open: false })}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{s.error && (
|
||||
<div className="gyxxwb-error">
|
||||
无法连接工作台桥接服务:{s.error}
|
||||
<br />
|
||||
请确认 dsh 已通过 workbench/cordis.yml 启动。
|
||||
</div>
|
||||
)}
|
||||
<div className="gyxxwb-panel-body">
|
||||
<div className="gyxxwb-list">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label} className="gyxxwb-group">
|
||||
<div className="gyxxwb-group-label">{group.label}</div>
|
||||
{group.items.map((workflow) => (
|
||||
<WorkflowRow
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
selected={workflow.id === s.selectedId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{!s.loading && !s.workflows.length && !s.error && (
|
||||
<div className="gyxxwb-empty">未发现工作流</div>
|
||||
)}
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="gyxxwb-detail">
|
||||
<div className="gyxxwb-detail-title">
|
||||
{selected.name}
|
||||
<span className={`gyxxwb-status gyxxwb-status-${statusOf(selected)}`}>
|
||||
{STATUS_LABELS[statusOf(selected)]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="gyxxwb-detail-sub">
|
||||
{selected.id} · 调度{' '}
|
||||
{selected.schedule
|
||||
? `${selected.schedule.enabled ? '启用' : '停用'} ${
|
||||
Array.isArray(selected.schedule.at)
|
||||
? selected.schedule.at.join('/')
|
||||
: (selected.schedule.at ?? '')
|
||||
}`
|
||||
: '无'}
|
||||
{selected.next_run_at ? ` · 下次 ${formatTime(selected.next_run_at)}` : ''}
|
||||
</div>
|
||||
<div className="gyxxwb-actions">
|
||||
<button type="button" onClick={() => doAsk('diagnose')}>诊断</button>
|
||||
<button type="button" onClick={() => doAsk('repair')}>修复</button>
|
||||
<button type="button" onClick={() => doTrigger(false)}>试运行</button>
|
||||
<button type="button" className="gyxxwb-danger" onClick={() => doTrigger(true)}>
|
||||
正式运行
|
||||
</button>
|
||||
{selected.active_run && (
|
||||
<button type="button" className="gyxxwb-danger" onClick={doCancel}>
|
||||
停止
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="gyxxwb-ask">
|
||||
<input
|
||||
value={s.question}
|
||||
placeholder="就这个工作流提问,例如:昨天为什么失败?"
|
||||
onChange={(event) => update({ question: event.target.value })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') doAsk('ask')
|
||||
}}
|
||||
/>
|
||||
<button type="button" onClick={() => doAsk('ask')}>提问</button>
|
||||
</div>
|
||||
<div className="gyxxwb-date-row">
|
||||
业务日期{' '}
|
||||
<input
|
||||
value={s.businessDate}
|
||||
onChange={(event) => update({ businessDate: event.target.value })}
|
||||
pattern="\d{4}-\d{2}-\d{2}"
|
||||
/>
|
||||
</div>
|
||||
<div className="gyxxwb-section-label">最近运行</div>
|
||||
<RunList runs={s.runs} onDiagnose={(runId) => loadDiagnosis(selected.id, runId)} />
|
||||
<DiagnosisView diagnosis={s.diagnosis} loading={s.diagnosisLoading} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/** 失败告警浮层:轮询桥接告警,新失败弹出 toast,可一键发起诊断会话。 */
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { getState, subscribe, update, dismissToast, pushToast } from './store.js'
|
||||
import { api } from './api.js'
|
||||
|
||||
function useWorkbench() {
|
||||
return useSyncExternalStore(subscribe, getState)
|
||||
}
|
||||
|
||||
export function Toasts() {
|
||||
const s = useWorkbench()
|
||||
|
||||
useEffect(() => {
|
||||
let stopped = false
|
||||
let timer = null
|
||||
const poll = async () => {
|
||||
try {
|
||||
const payload = await api.alerts()
|
||||
const fresh = (payload.alerts ?? []).filter(
|
||||
(alert) => !alert.seen && !getState().seenAlertIds[alert.id],
|
||||
)
|
||||
if (fresh.length) {
|
||||
const seen = { ...getState().seenAlertIds }
|
||||
for (const alert of fresh) {
|
||||
seen[alert.id] = true
|
||||
pushToast({ kind: 'failed', text: `工作流「${alert.name}」运行失败`, alert })
|
||||
}
|
||||
update({ seenAlertIds: seen })
|
||||
}
|
||||
} catch {
|
||||
// 桥接离线时静默
|
||||
}
|
||||
if (!stopped) timer = setTimeout(poll, 30_000)
|
||||
}
|
||||
timer = setTimeout(poll, 8_000)
|
||||
return () => {
|
||||
stopped = true
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!s.toasts.length) return null
|
||||
return (
|
||||
<div className="gyxxwb-toasts">
|
||||
{s.toasts.map((toast) => (
|
||||
<div key={toast.id} className={`gyxxwb-toast gyxxwb-toast-${toast.kind}`}>
|
||||
<span className="gyxxwb-toast-text">{toast.text}</span>
|
||||
{toast.alert && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.ask({ workflow_id: toast.alert.workflow_id, action: 'diagnose' })
|
||||
await api.markAlertSeen(toast.alert.id).catch(() => {})
|
||||
pushToast({ kind: 'ok', text: '已创建诊断会话,请在左侧会话列表查看' })
|
||||
} catch (error) {
|
||||
pushToast({ kind: 'error', text: `创建会话失败:${error.message}` })
|
||||
}
|
||||
dismissToast(toast.id)
|
||||
}}
|
||||
>
|
||||
立即诊断
|
||||
</button>
|
||||
)}
|
||||
<button type="button" title="关闭" onClick={() => dismissToast(toast.id)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** 工作台桥接服务客户端:所有数据经由 gyxx-workbench 宿主插件的本机回环桥接。 */
|
||||
|
||||
export function bridgeBase() {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (window.__GYXX_WB_BRIDGE__) return window.__GYXX_WB_BRIDGE__
|
||||
try {
|
||||
const saved = window.localStorage?.getItem('gyxx.wb.bridge')
|
||||
if (saved) return saved
|
||||
} catch {
|
||||
// localStorage 不可用时使用默认值
|
||||
}
|
||||
}
|
||||
return 'http://127.0.0.1:8790'
|
||||
}
|
||||
|
||||
async function request(method, path, body) {
|
||||
const response = await fetch(`${bridgeBase()}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
...(body !== undefined
|
||||
? { 'content-type': 'application/json', 'x-gyxx-workbench': '1' }
|
||||
: {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
let payload
|
||||
try {
|
||||
payload = await response.json()
|
||||
} catch {
|
||||
payload = { ok: false, error: `桥接服务返回了非 JSON 内容(HTTP ${response.status})` }
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error ?? `桥接服务错误(HTTP ${response.status})`)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listWorkflows: () => request('GET', '/bridge/workflows'),
|
||||
workflowDetail: (workflowId) =>
|
||||
request('GET', `/bridge/workflows/${encodeURIComponent(workflowId)}`),
|
||||
workflowRuns: (workflowId, limit = 6) =>
|
||||
request('GET', `/bridge/workflows/${encodeURIComponent(workflowId)}/runs?limit=${limit}`),
|
||||
diagnosis: (workflowId, runId) =>
|
||||
request(
|
||||
'GET',
|
||||
`/bridge/workflows/${encodeURIComponent(workflowId)}/diagnosis${runId ? `?run_id=${encodeURIComponent(runId)}` : ''}`,
|
||||
),
|
||||
alerts: () => request('GET', '/bridge/alerts'),
|
||||
markAlertSeen: (id) => request('POST', '/bridge/alerts/seen', { id }),
|
||||
trigger: (payload) => request('POST', '/bridge/trigger', payload),
|
||||
cancel: (payload) => request('POST', '/bridge/cancel', payload),
|
||||
ask: (payload) => request('POST', '/bridge/ask', payload),
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* GYXX 工作台客户端插件入口。
|
||||
* 由 esbuild 打包为 CJS 并通过 window.__ModuleLoader__ 注册(见 scripts/build-client.mjs)。
|
||||
* 运行时只依赖 dsh 平台共享模块表中的 react。
|
||||
*/
|
||||
|
||||
import { createElement, useSyncExternalStore } from 'react'
|
||||
import { WorkflowPanel, refreshWorkflows } from './Panel.jsx'
|
||||
import { Toasts } from './Toasts.jsx'
|
||||
import { getState, subscribe, update } from './store.js'
|
||||
import stylesText from './styles.css'
|
||||
|
||||
export const inject = ['slots']
|
||||
|
||||
function useWorkbench() {
|
||||
return useSyncExternalStore(subscribe, getState)
|
||||
}
|
||||
|
||||
function injectStyles() {
|
||||
if (typeof document === 'undefined') return
|
||||
if (document.getElementById('gyxxwb-styles')) return
|
||||
const style = document.createElement('style')
|
||||
style.id = 'gyxxwb-styles'
|
||||
style.textContent = stylesText
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
/** 侧边栏底部动作:工作流面板开关 + 聚合状态点。 */
|
||||
function WorkbenchFooterAction() {
|
||||
const s = useWorkbench()
|
||||
const failed = s.workflows.filter((w) => !w.active_run && w.last_run?.status === 'failed').length
|
||||
const running = s.workflows.filter((w) => w.active_run).length
|
||||
const tone = failed > 0 ? 'failed' : running > 0 ? 'running' : 'success'
|
||||
return createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
className: `gyxxwb-footer-btn${s.open ? ' gyxxwb-footer-btn-active' : ''}`,
|
||||
title: 'GYXX 工作流工作台',
|
||||
onClick: () => {
|
||||
const next = !getState().open
|
||||
update({ open: next })
|
||||
if (next) refreshWorkflows()
|
||||
},
|
||||
},
|
||||
createElement('span', { className: `gyxxwb-dot gyxxwb-dot-${tone}` }),
|
||||
createElement('span', null, '工作流'),
|
||||
)
|
||||
}
|
||||
|
||||
/** 覆盖层根:左侧抽屉面板 + 失败 toast。 */
|
||||
function WorkbenchOverlay() {
|
||||
return createElement(
|
||||
'div',
|
||||
{ className: 'gyxxwb-overlay' },
|
||||
createElement(WorkflowPanel, null),
|
||||
createElement(Toasts, null),
|
||||
)
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
injectStyles()
|
||||
ctx.effect(
|
||||
() => ctx.slots.register({ name: 'sidebar.footer.action' }, WorkbenchFooterAction),
|
||||
'gyxx-workbench: footer action',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.slots.register({ name: 'shell.overlay' }, WorkbenchOverlay),
|
||||
'gyxx-workbench: overlay',
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* GYXX 工作台面板 —— 极简外部存储(避免向客户端包引入额外依赖)。
|
||||
* React 18 的 useSyncExternalStore 直接订阅。
|
||||
*/
|
||||
|
||||
const listeners = new Set()
|
||||
|
||||
let state = {
|
||||
open: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
generatedAt: null,
|
||||
summary: null,
|
||||
workflows: [],
|
||||
selectedId: null,
|
||||
detail: null,
|
||||
detailLoading: false,
|
||||
runs: [],
|
||||
diagnosis: null,
|
||||
diagnosisLoading: false,
|
||||
question: '',
|
||||
businessDate: defaultBusinessDate(),
|
||||
alerts: [],
|
||||
seenAlertIds: {},
|
||||
toasts: [],
|
||||
}
|
||||
|
||||
export function defaultBusinessDate() {
|
||||
const now = new Date()
|
||||
now.setDate(now.getDate() - 1)
|
||||
const pad = (value) => String(value).padStart(2, '0')
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
}
|
||||
|
||||
export function getState() {
|
||||
return state
|
||||
}
|
||||
|
||||
export function update(patch) {
|
||||
state = { ...state, ...patch }
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
export function subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
export function pushToast(toast) {
|
||||
const id = `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
||||
update({ toasts: [...state.toasts, { id, ...toast }] })
|
||||
return id
|
||||
}
|
||||
|
||||
export function dismissToast(id) {
|
||||
update({ toasts: state.toasts.filter((toast) => toast.id !== id) })
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
/* GYXX 工作台面板样式 —— 以文本形式内联进客户端包,运行时注入 <style>。 */
|
||||
|
||||
.gyxxwb-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 70;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
.gyxxwb-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 264px;
|
||||
width: 380px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e4e4e7;
|
||||
box-shadow: 8px 0 24px rgba(0, 0, 0, 0.08);
|
||||
pointer-events: auto;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
.gyxxwb-panel-open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.gyxxwb-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e4e4e7;
|
||||
}
|
||||
|
||||
.gyxxwb-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.gyxxwb-pills {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gyxxwb-pill {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f4f4f5;
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.gyxxwb-pill-failed {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.gyxxwb-pill-running {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.gyxxwb-icon-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #71717a;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.gyxxwb-icon-btn:hover {
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
.gyxxwb-error {
|
||||
margin: 10px 12px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.gyxxwb-panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gyxxwb-group-label {
|
||||
padding: 10px 12px 4px;
|
||||
font-size: 12px;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.gyxxwb-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.gyxxwb-row:hover {
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
.gyxxwb-row-selected {
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.gyxxwb-row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gyxxwb-row-name {
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.gyxxwb-row-id {
|
||||
font-size: 11px;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.gyxxwb-row-meta {
|
||||
font-size: 11px;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.gyxxwb-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #d4d4d8;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.gyxxwb-dot-success {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.gyxxwb-dot-failed {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.gyxxwb-dot-running {
|
||||
background: #2563eb;
|
||||
animation: gyxxwb-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.gyxxwb-dot-cancelled,
|
||||
.gyxxwb-dot-none {
|
||||
background: #a1a1aa;
|
||||
}
|
||||
|
||||
@keyframes gyxxwb-pulse {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.gyxxwb-detail {
|
||||
border-top: 1px solid #e4e4e7;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.gyxxwb-detail-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gyxxwb-status {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
.gyxxwb-status-failed {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.gyxxwb-status-running {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.gyxxwb-status-success {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.gyxxwb-detail-sub {
|
||||
font-size: 11px;
|
||||
color: #71717a;
|
||||
margin: 4px 0 10px;
|
||||
}
|
||||
|
||||
.gyxxwb-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gyxxwb-actions button,
|
||||
.gyxxwb-ask button {
|
||||
border: 1px solid #e4e4e7;
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gyxxwb-actions button:hover,
|
||||
.gyxxwb-ask button:hover {
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
.gyxxwb-actions .gyxxwb-danger {
|
||||
border-color: #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.gyxxwb-ask {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.gyxxwb-ask input {
|
||||
flex: 1;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.gyxxwb-date-row {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: #71717a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gyxxwb-date-row input {
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 6px;
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.gyxxwb-section-label {
|
||||
margin: 12px 0 4px;
|
||||
font-size: 12px;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.gyxxwb-run {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.gyxxwb-run:hover {
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
.gyxxwb-run-mode {
|
||||
color: #a1a1aa;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.gyxxwb-run-error {
|
||||
color: #b91c1c;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.gyxxwb-diagnosis {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.gyxxwb-diagnosis-head {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.gyxxwb-step-fail {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.gyxxwb-step-id {
|
||||
font-size: 12px;
|
||||
color: #b91c1c;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.gyxxwb-log {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.gyxxwb-log-path {
|
||||
font-size: 11px;
|
||||
color: #71717a;
|
||||
margin-bottom: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.gyxxwb-pre {
|
||||
font-size: 11px;
|
||||
background: #18181b;
|
||||
color: #e4e4e7;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.gyxxwb-empty {
|
||||
padding: 16px 12px;
|
||||
font-size: 12px;
|
||||
color: #a1a1aa;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gyxxwb-toasts {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.gyxxwb-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #18181b;
|
||||
color: #fafafa;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.gyxxwb-toast button {
|
||||
border: 1px solid #3f3f46;
|
||||
background: transparent;
|
||||
color: #fafafa;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gyxxwb-toast-ok {
|
||||
background: #14532d;
|
||||
}
|
||||
|
||||
.gyxxwb-toast-error,
|
||||
.gyxxwb-toast-failed {
|
||||
background: #7f1d1d;
|
||||
}
|
||||
|
||||
.gyxxwb-footer-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.gyxxwb-footer-btn:hover,
|
||||
.gyxxwb-footer-btn-active {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.gyxxwb-panel {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.gyxxwb-panel {
|
||||
background: #18181b;
|
||||
color: #fafafa;
|
||||
border-right-color: #3f3f46;
|
||||
}
|
||||
.gyxxwb-row:hover {
|
||||
background: #27272a;
|
||||
}
|
||||
.gyxxwb-row-selected {
|
||||
background: #312e81;
|
||||
}
|
||||
.gyxxwb-detail {
|
||||
background: #1f1f22;
|
||||
border-top-color: #3f3f46;
|
||||
}
|
||||
.gyxxwb-actions button,
|
||||
.gyxxwb-ask button {
|
||||
background: #27272a;
|
||||
border-color: #3f3f46;
|
||||
color: #fafafa;
|
||||
}
|
||||
.gyxxwb-ask input,
|
||||
.gyxxwb-date-row input {
|
||||
background: #27272a;
|
||||
border-color: #3f3f46;
|
||||
color: #fafafa;
|
||||
}
|
||||
.gyxxwb-pill {
|
||||
background: #27272a;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
.gyxxwb-footer-btn:hover,
|
||||
.gyxxwb-footer-btn-active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>GYXX 智能工作台</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
background: #f4f4f5; color: #18181b;
|
||||
}
|
||||
header {
|
||||
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
|
||||
background: #fff; border-bottom: 1px solid #e4e4e7; position: sticky; top: 0; z-index: 2;
|
||||
}
|
||||
h1 { font-size: 15px; margin: 0; }
|
||||
.pill { font-size: 11px; padding: 1px 8px; border-radius: 999px; background: #f4f4f5; color: #52525b; border: 1px solid #e4e4e7; }
|
||||
.pill-failed { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
|
||||
.pill-running { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
|
||||
main { display: flex; gap: 0; min-height: calc(100vh - 49px); }
|
||||
#list { width: 320px; background: #fff; border-right: 1px solid #e4e4e7; overflow-y: auto; }
|
||||
#detail { flex: 1; padding: 16px; overflow-y: auto; }
|
||||
.group-label { padding: 10px 12px 4px; font-size: 12px; color: #71717a; }
|
||||
.row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 12px; border: none; background: none; cursor: pointer; text-align: left; }
|
||||
.row:hover { background: #f4f4f5; }
|
||||
.row.selected { background: #eef2ff; }
|
||||
.row .name { font-size: 13px; display: block; }
|
||||
.row .id { font-size: 11px; color: #a1a1aa; display: block; }
|
||||
.row .meta { font-size: 11px; color: #71717a; margin-left: auto; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #a1a1aa; flex: none; }
|
||||
.dot-success { background: #16a34a; } .dot-failed { background: #dc2626; }
|
||||
.dot-running { background: #2563eb; animation: pulse 1.2s infinite; }
|
||||
@keyframes pulse { 50% { opacity: .35; } }
|
||||
button.act { border: 1px solid #e4e4e7; background: #fff; border-radius: 6px; font-size: 12px; padding: 5px 12px; cursor: pointer; margin-right: 6px; }
|
||||
button.act:hover { background: #f4f4f5; }
|
||||
button.danger { border-color: #fecaca; color: #b91c1c; }
|
||||
input { border: 1px solid #e4e4e7; border-radius: 6px; padding: 6px 8px; font-size: 12px; }
|
||||
pre { background: #18181b; color: #e4e4e7; border-radius: 8px; padding: 10px; font-size: 11px; max-height: 260px; overflow: auto; white-space: pre-wrap; word-break: break-all; }
|
||||
.muted { color: #71717a; font-size: 12px; }
|
||||
.err { color: #b91c1c; font-size: 12px; }
|
||||
.card { background: #fff; border: 1px solid #e4e4e7; border-radius: 10px; padding: 14px; margin-bottom: 12px; }
|
||||
#toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 9; }
|
||||
.toast { background: #18181b; color: #fafafa; border-radius: 10px; padding: 10px 12px; font-size: 12px; max-width: 340px; }
|
||||
.toast.ok { background: #14532d; } .toast.err { background: #7f1d1d; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #09090b; color: #fafafa; }
|
||||
header, #list, .card { background: #18181b; border-color: #3f3f46; }
|
||||
.row:hover { background: #27272a; } .row.selected { background: #312e81; }
|
||||
button.act, input { background: #27272a; border-color: #3f3f46; color: #fafafa; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>GYXX 智能工作台</h1>
|
||||
<span id="pills"></span>
|
||||
<span class="muted" style="margin-left:auto" id="generated"></span>
|
||||
<button class="act" onclick="loadWorkflows()">刷新</button>
|
||||
</header>
|
||||
<main>
|
||||
<div id="list"></div>
|
||||
<div id="detail"><p class="muted" style="padding:16px">在左侧选择一个工作流,可提问、诊断、修复或启动。</p></div>
|
||||
</main>
|
||||
<div id="toasts"></div>
|
||||
<script>
|
||||
const $ = (sel) => document.querySelector(sel)
|
||||
const state = { workflows: [], selected: null, detail: null, runs: [], seenAlerts: {} }
|
||||
|
||||
function toast(text, kind) {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'toast ' + (kind || '')
|
||||
el.textContent = text
|
||||
$('#toasts').appendChild(el)
|
||||
setTimeout(() => el.remove(), 8000)
|
||||
}
|
||||
|
||||
async function req(method, path, body) {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: body !== undefined ? { 'content-type': 'application/json', 'x-gyxx-workbench': '1' } : {},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const payload = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(payload.error || ('HTTP ' + res.status))
|
||||
return payload
|
||||
}
|
||||
|
||||
function statusOf(w) { return w.active_run ? 'running' : (w.last_run && w.last_run.status) || 'none' }
|
||||
function fmtTime(v) {
|
||||
if (!v) return '—'
|
||||
const d = new Date(v); if (isNaN(d)) return v
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
return `${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
}
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c])) }
|
||||
|
||||
async function loadWorkflows() {
|
||||
try {
|
||||
const payload = await req('GET', '/bridge/workflows')
|
||||
state.workflows = payload.workflows || []
|
||||
$('#generated').textContent = payload.generated_at ? ('更新于 ' + fmtTime(payload.generated_at)) : ''
|
||||
renderList()
|
||||
} catch (e) {
|
||||
$('#list').innerHTML = '<p class="err" style="padding:12px">无法连接桥接服务:' + esc(e.message) + '<br>请确认 gyxx console 与 dsh workbench 插件已启动。</p>'
|
||||
}
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const failed = state.workflows.filter((w) => statusOf(w) === 'failed').length
|
||||
const running = state.workflows.filter((w) => w.active_run).length
|
||||
$('#pills').innerHTML =
|
||||
`<span class="pill">共 ${state.workflows.length}</span>` +
|
||||
(running ? `<span class="pill pill-running">运行中 ${running}</span>` : '') +
|
||||
(failed ? `<span class="pill pill-failed">失败 ${failed}</span>` : '')
|
||||
const byModule = new Map()
|
||||
for (const w of state.workflows) {
|
||||
const key = w.module_label || w.module
|
||||
if (!byModule.has(key)) byModule.set(key, [])
|
||||
byModule.get(key).push(w)
|
||||
}
|
||||
let html = ''
|
||||
for (const [label, items] of byModule) {
|
||||
html += `<div class="group-label">${esc(label)}</div>`
|
||||
for (const w of items) {
|
||||
const st = statusOf(w)
|
||||
html += `<button class="row ${state.selected === w.id ? 'selected' : ''}" onclick="select('${esc(w.id)}')">
|
||||
<span class="dot dot-${st}"></span>
|
||||
<span><span class="name">${esc(w.name)}</span><span class="id">${esc(w.id)}</span></span>
|
||||
<span class="meta">${w.last_run ? fmtTime(w.last_run.ended_at || w.last_run.started_at) : '—'}</span>
|
||||
</button>`
|
||||
}
|
||||
}
|
||||
$('#list').innerHTML = html || '<p class="muted" style="padding:12px">未发现工作流</p>'
|
||||
}
|
||||
|
||||
async function select(id) {
|
||||
state.selected = id
|
||||
renderList()
|
||||
$('#detail').innerHTML = '<p class="muted" style="padding:16px">加载中…</p>'
|
||||
try {
|
||||
const [detail, runs] = await Promise.all([
|
||||
req('GET', '/bridge/workflows/' + encodeURIComponent(id)).catch(() => null),
|
||||
req('GET', '/bridge/workflows/' + encodeURIComponent(id) + '/runs?limit=6').catch(() => ({ runs: [] })),
|
||||
])
|
||||
state.detail = detail
|
||||
state.runs = runs.runs || []
|
||||
renderDetail()
|
||||
} catch (e) {
|
||||
$('#detail').innerHTML = '<p class="err" style="padding:16px">' + esc(e.message) + '</p>'
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
const w = state.workflows.find((x) => x.id === state.selected)
|
||||
if (!w) return
|
||||
const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1)
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
const defaultDate = `${yesterday.getFullYear()}-${p(yesterday.getMonth()+1)}-${p(yesterday.getDate())}`
|
||||
const sched = w.schedule
|
||||
let html = `<div class="card">
|
||||
<h2 style="font-size:15px;margin:0 0 4px">${esc(w.name)}</h2>
|
||||
<div class="muted">${esc(w.id)} · 调度 ${sched ? (sched.enabled ? '启用' : '停用') + ' ' + (Array.isArray(sched.at) ? sched.at.join('/') : sched.at || '') : '无'}</div>
|
||||
<div style="margin:10px 0">
|
||||
<button class="act" onclick="ask('diagnose')">诊断</button>
|
||||
<button class="act" onclick="ask('repair')">修复</button>
|
||||
<button class="act" onclick="triggerRun(false)">试运行</button>
|
||||
<button class="act danger" onclick="triggerRun(true)">正式运行</button>
|
||||
${w.active_run ? '<button class="act danger" onclick="cancelRun()">停止</button>' : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:8px">
|
||||
<input id="q" style="flex:1" placeholder="就这个工作流提问,例如:昨天为什么失败?" />
|
||||
<button class="act" onclick="ask('ask')">提问</button>
|
||||
</div>
|
||||
<div class="muted">业务日期 <input id="bd" value="${defaultDate}" style="width:110px" /></div>
|
||||
</div>
|
||||
<div class="card"><div class="muted" style="margin-bottom:6px">最近运行(点击查看诊断)</div>`
|
||||
if (!state.runs.length) html += '<div class="muted">暂无运行记录</div>'
|
||||
for (const r of state.runs) {
|
||||
html += `<button class="row" onclick="diagnose('${esc(r.run_id)}')">
|
||||
<span class="dot dot-${r.status}"></span><span>${esc(r.business_date)}</span>
|
||||
<span class="muted">${r.mode === 'execute' ? '正式' : '试运行'}</span>
|
||||
${r.error ? `<span class="err">${esc(r.error.slice(0, 80))}</span>` : ''}
|
||||
</button>`
|
||||
}
|
||||
html += '</div><div id="diag"></div>'
|
||||
$('#detail').innerHTML = html
|
||||
}
|
||||
|
||||
async function diagnose(runId) {
|
||||
$('#diag').innerHTML = '<div class="card muted">加载诊断数据…</div>'
|
||||
try {
|
||||
const d = await req('GET', '/bridge/workflows/' + encodeURIComponent(state.selected) + '/diagnosis?run_id=' + encodeURIComponent(runId))
|
||||
let html = '<div class="card"><div class="muted">诊断:' + esc(runId) + '</div>'
|
||||
const steps = (d.run && d.run.steps) || []
|
||||
for (const s of steps.filter((x) => x.status === 'failed')) {
|
||||
html += `<div class="err">✗ ${esc(s.id)}(退出码 ${s.exit_code == null ? '—' : s.exit_code})</div>${s.error ? '<pre>' + esc(s.error) + '</pre>' : ''}`
|
||||
}
|
||||
for (const log of d.logs || []) {
|
||||
html += `<div class="muted" style="margin-top:8px">${esc(log.path)}</div><pre>${esc(log.tail)}</pre>`
|
||||
}
|
||||
if (!steps.some((x) => x.status === 'failed') && !(d.logs || []).length) html += '<div class="muted">该运行没有失败步骤或日志</div>'
|
||||
$('#diag').innerHTML = html + '</div>'
|
||||
} catch (e) {
|
||||
$('#diag').innerHTML = '<div class="card err">' + esc(e.message) + '</div>'
|
||||
}
|
||||
}
|
||||
|
||||
async function ask(action) {
|
||||
const question = ($('#q') && $('#q').value) || ''
|
||||
try {
|
||||
const payload = await req('POST', '/bridge/ask', { workflow_id: state.selected, action, question })
|
||||
toast('已创建会话(' + String(payload.session_id).slice(0, 24) + '…),请在 dsh 侧边栏会话列表中查看', 'ok')
|
||||
} catch (e) { toast('创建会话失败:' + e.message, 'err') }
|
||||
}
|
||||
|
||||
async function triggerRun(execute) {
|
||||
const businessDate = ($('#bd') && $('#bd').value) || ''
|
||||
if (execute && !confirm(`正式执行 ${state.selected}(业务日期 ${businessDate})会产生真实外部写入。确认继续?`)) return
|
||||
try {
|
||||
await req('POST', '/bridge/trigger', { workflow_id: state.selected, business_date: businessDate, execute, confirmed: execute })
|
||||
toast(execute ? '已发起正式执行' : '已发起试运行(无外部副作用)', 'ok')
|
||||
setTimeout(loadWorkflows, 1500)
|
||||
} catch (e) { toast('启动失败:' + e.message, 'err') }
|
||||
}
|
||||
|
||||
async function cancelRun() {
|
||||
const w = state.workflows.find((x) => x.id === state.selected)
|
||||
const body = w && w.active_run && w.active_run.operation_id
|
||||
? { workflow_id: state.selected, operation_id: w.active_run.operation_id }
|
||||
: { workflow_id: state.selected, scheduled: true }
|
||||
try {
|
||||
await req('POST', '/bridge/cancel', body)
|
||||
toast('已发送停止指令', 'ok')
|
||||
setTimeout(loadWorkflows, 1500)
|
||||
} catch (e) { toast('停止失败:' + e.message, 'err') }
|
||||
}
|
||||
|
||||
async function pollAlerts() {
|
||||
try {
|
||||
const payload = await req('GET', '/bridge/alerts')
|
||||
for (const alert of payload.alerts || []) {
|
||||
if (alert.seen || state.seenAlerts[alert.id]) continue
|
||||
state.seenAlerts[alert.id] = true
|
||||
toast('工作流「' + alert.name + '」运行失败,可在 dsh 中发起诊断', 'err')
|
||||
req('POST', '/bridge/alerts/seen', { id: alert.id }).catch(() => {})
|
||||
}
|
||||
} catch (e) { /* 桥接离线时静默 */ }
|
||||
}
|
||||
|
||||
loadWorkflows()
|
||||
setInterval(loadWorkflows, 15000)
|
||||
setInterval(pollAlerts, 30000)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,937 @@
|
||||
/**
|
||||
* GYXX 智能工作台 — deepseek-harness 宿主插件。
|
||||
*
|
||||
* 通过 gyxx-flow 控制台 HTTP API(默认 http://127.0.0.1:8765)把工作流
|
||||
* 暴露为 DeepSeek 智能体工具,并提供:
|
||||
* - 7 个模型可调用的工作流工具(查询 / 诊断 / 触发 / 停止 / 定时配置)
|
||||
* - 中文系统提示词(安全契约:默认试运行,正式执行必须用户确认)
|
||||
* - 失败监控器(轮询 overview,发现新失败时发出告警,可选自动开诊断会话)
|
||||
* - 本机回环桥接服务(默认 127.0.0.1:8790),供侧边栏面板读取数据、
|
||||
* 发起「提问 / 诊断 / 修复」会话;同时内置一个零依赖的独立面板页面
|
||||
* (GET /)作为 dsh 客户端插件加载失败时的降级 UI。
|
||||
*
|
||||
* 本文件零运行时依赖(只用 Node 内置模块),经 cordis.yml --patch 以绝对
|
||||
* 路径插入 dsh Web 组合即可运行。
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export const name = 'gyxx-workbench'
|
||||
export const inject = ['tools', 'systemPrompt']
|
||||
|
||||
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const MODULE_LABELS = {
|
||||
content_marketing: '内容营销',
|
||||
product_commerce: '商品经营',
|
||||
shop_intelligence: '店铺情报',
|
||||
supply_chain: '供应链',
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 控制台 HTTP 客户端 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
class ConsoleApiError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message)
|
||||
this.name = 'ConsoleApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
function makeConsoleClient(config) {
|
||||
const base = new URL(config.consoleBaseUrl)
|
||||
return async function consoleApi(method, path, body, options = {}) {
|
||||
const headers = { accept: 'application/json' }
|
||||
if (config.consoleToken) headers.authorization = `Bearer ${config.consoleToken}`
|
||||
if (body !== undefined) {
|
||||
headers['content-type'] = 'application/json'
|
||||
headers['x-gyxx-console'] = '1'
|
||||
}
|
||||
if (options.ifMatch) headers['if-match'] = options.ifMatch
|
||||
let response
|
||||
try {
|
||||
response = await fetch(new URL(path, base), {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: options.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') throw error
|
||||
throw new ConsoleApiError(
|
||||
0,
|
||||
`无法连接 gyxx 控制台 ${base.origin},请先启动 gyxx console(${error?.message ?? error})`,
|
||||
)
|
||||
}
|
||||
const text = await response.text()
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(text)
|
||||
} catch {
|
||||
payload = { raw: text }
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
payload && typeof payload.error === 'string'
|
||||
? payload.error
|
||||
: `控制台返回 HTTP ${response.status}`
|
||||
throw new ConsoleApiError(response.status, message)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 数据压缩:把控制台载荷裁剪为适合模型阅读的体积 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function compactRun(run) {
|
||||
if (!run) return null
|
||||
const compact = {
|
||||
run_id: run.run_id,
|
||||
business_date: run.business_date,
|
||||
mode: run.mode,
|
||||
shadow: run.shadow,
|
||||
status: run.status,
|
||||
started_at: run.started_at,
|
||||
ended_at: run.ended_at,
|
||||
duration_seconds: run.duration_seconds,
|
||||
error: run.error,
|
||||
}
|
||||
if (Array.isArray(run.steps)) {
|
||||
compact.steps = run.steps.map((step) => ({
|
||||
id: step.id,
|
||||
status: step.status,
|
||||
exit_code: step.exit_code,
|
||||
error: step.error,
|
||||
}))
|
||||
}
|
||||
return compact
|
||||
}
|
||||
|
||||
function compactWorkflow(item) {
|
||||
const schedule = item.schedule ?? null
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
module: item.module,
|
||||
module_label: MODULE_LABELS[item.module] ?? item.module,
|
||||
trigger: item.trigger,
|
||||
registered: item.registered,
|
||||
note: item.note ?? null,
|
||||
schedule: schedule
|
||||
? {
|
||||
enabled: schedule.enabled,
|
||||
kind: schedule.kind,
|
||||
at: schedule.at,
|
||||
raw: schedule,
|
||||
}
|
||||
: null,
|
||||
next_run_at: item.next_run_at ?? null,
|
||||
last_run: compactRun(item.last_run),
|
||||
active_run: item.active_run
|
||||
? {
|
||||
operation_id: item.active_run.operation_id,
|
||||
business_date: item.active_run.business_date,
|
||||
mode: item.active_run.mode,
|
||||
started_at: item.active_run.started_at,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function compactDetail(payload) {
|
||||
const workflow = payload.workflow ?? {}
|
||||
const steps = Array.isArray(workflow.steps)
|
||||
? workflow.steps.map((step) => ({
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
description: step.description,
|
||||
entry: step.entry,
|
||||
timeout_seconds: step.timeout_seconds,
|
||||
replay_policy: step.replay_policy,
|
||||
depends_on: step.depends_on,
|
||||
}))
|
||||
: []
|
||||
return {
|
||||
workflow: { ...compactWorkflow(workflow), steps },
|
||||
schedule_revision: payload.schedule_revision ?? null,
|
||||
warnings: payload.warnings ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 系统提示词 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const SYSTEM_PROMPT_TEXT = `\
|
||||
你是 GYXX 智能工作台的运维智能体。gyxx-flow 是一套 LangGraph 工作流编排系统,
|
||||
包含四个业务模块:内容营销(content_marketing)、商品经营(product_commerce)、
|
||||
店铺情报(shop_intelligence)、供应链(supply_chain)。每个工作流由若干步骤组成,
|
||||
运行日志(RunJournal)与运行索引位于数据根目录下。
|
||||
|
||||
你可以使用以下工具(数据来自本机 gyxx 控制台 HTTP API):
|
||||
- gyxx_workflow_list:列出全部工作流及其调度、最近一次运行状态。
|
||||
- gyxx_workflow_detail:查看单个工作流的定义、步骤与调度详情。
|
||||
- gyxx_workflow_runs:查看某工作流的最近运行历史(含每个步骤的退出码与错误)。
|
||||
- gyxx_workflow_diagnose:获取一次运行的完整诊断包(日志尾部已脱敏)。
|
||||
- gyxx_workflow_trigger:触发一次运行。默认 execute=false 为试运行(无副作用)。
|
||||
- gyxx_workflow_cancel:停止正在运行的手动执行或定时执行。
|
||||
- gyxx_schedule_update:启用/停用或修改定时调度(读-改-写,需要 schedule_revision)。
|
||||
|
||||
安全契约(必须遵守):
|
||||
1. 默认只做只读分析与试运行。execute=true 的正式执行会对外部系统
|
||||
(PostgreSQL、飞书、电商平台)产生真实写入,只有在用户于对话中明确确认后,
|
||||
才能把 confirmed=true 一并传入;否则工具会拒绝。
|
||||
2. 修复工作流时遵循「诊断 → 定位根因 → 给出方案 → 用户确认 → 执行修复」的顺序,
|
||||
不得跳过确认直接修改调度或正式重跑。
|
||||
3. 常见修复手段:登录态失效(提示用户运行 gyxx accounts login/sync)、
|
||||
幂等冲突(force_refresh 需真实执行且谨慎)、参数错误(改动态配置或调度)、
|
||||
临时性失败(先试运行验证再正式重跑)。
|
||||
4. 诊断结果用中文输出,包含:失败步骤、根因推测、建议操作、风险说明。
|
||||
|
||||
当用户从工作台侧边栏选中工作流发起提问/诊断/修复时,会话的第一条消息会
|
||||
带有工作流上下文,请直接开始分析,不要反问用户基本信息。`
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 工具定义 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const JSON_OUTPUT = {
|
||||
schema: { type: 'object' },
|
||||
render: (_args, value) => [
|
||||
{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) },
|
||||
],
|
||||
}
|
||||
|
||||
function toolErrorResult(error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof ConsoleApiError ? error.message : `工具执行失败:${error?.message ?? error}`,
|
||||
}
|
||||
}
|
||||
|
||||
function defineWorkflowTools(ctx, consoleApi) {
|
||||
const register = (definition) => ctx.tools.register(definition)
|
||||
|
||||
register({
|
||||
name: 'gyxx_workflow_list',
|
||||
description:
|
||||
'列出 GYXX 全部调度工作流:名称、模块、调度时间、最近一次运行状态与进行中的执行。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
module: {
|
||||
type: 'string',
|
||||
enum: Object.keys(MODULE_LABELS),
|
||||
description: '按业务模块过滤,缺省返回全部',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['failed', 'running', 'success', 'none'],
|
||||
description: '按最近一次运行状态过滤(none = 从未运行)',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: JSON_OUTPUT,
|
||||
async execute(args, exec) {
|
||||
try {
|
||||
const overview = await consoleApi('GET', '/api/overview', undefined, {
|
||||
signal: exec?.signal,
|
||||
})
|
||||
let workflows = (overview.workflows ?? []).map(compactWorkflow)
|
||||
if (args?.module) workflows = workflows.filter((w) => w.module === args.module)
|
||||
if (args?.status) {
|
||||
workflows = workflows.filter((w) => {
|
||||
const status = w.active_run ? 'running' : (w.last_run?.status ?? 'none')
|
||||
return args.status === 'running' ? status === 'running' || w.active_run : status === args.status
|
||||
})
|
||||
}
|
||||
return { ok: true, summary: overview.summary ?? null, workflows }
|
||||
} catch (error) {
|
||||
return toolErrorResult(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
register({
|
||||
name: 'gyxx_workflow_detail',
|
||||
description: '查看单个工作流的完整定义:步骤、调度、依赖、重放策略与最近运行。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
workflow_id: { type: 'string', description: '工作流 ID,例如 content.metrics.daily' },
|
||||
},
|
||||
required: ['workflow_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: JSON_OUTPUT,
|
||||
async execute(args, exec) {
|
||||
try {
|
||||
const payload = await consoleApi(
|
||||
'GET',
|
||||
`/api/workflows/${encodeURIComponent(args.workflow_id)}`,
|
||||
undefined,
|
||||
{ signal: exec?.signal },
|
||||
)
|
||||
return { ok: true, ...compactDetail(payload) }
|
||||
} catch (error) {
|
||||
return toolErrorResult(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
register({
|
||||
name: 'gyxx_workflow_runs',
|
||||
description: '查看某工作流最近的运行历史(含步骤级状态、退出码与脱敏错误)。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
workflow_id: { type: 'string' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 20, description: '默认 8,最大 20' },
|
||||
},
|
||||
required: ['workflow_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: JSON_OUTPUT,
|
||||
async execute(args, exec) {
|
||||
try {
|
||||
const limit = args?.limit ?? 8
|
||||
const payload = await consoleApi(
|
||||
'GET',
|
||||
`/api/workflows/${encodeURIComponent(args.workflow_id)}/runs?limit=${limit}`,
|
||||
undefined,
|
||||
{ signal: exec?.signal },
|
||||
)
|
||||
return {
|
||||
ok: true,
|
||||
workflow_id: payload.workflow_id,
|
||||
runs: (payload.runs ?? []).map(compactRun),
|
||||
}
|
||||
} catch (error) {
|
||||
return toolErrorResult(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
register({
|
||||
name: 'gyxx_workflow_diagnose',
|
||||
description:
|
||||
'获取一次运行的完整诊断包:运行状态、步骤明细、journal 追踪路径与脱敏日志尾部。' +
|
||||
'不传 run_id 时自动选择最近一次失败的运行(无失败则最近一次运行)。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
workflow_id: { type: 'string' },
|
||||
run_id: { type: 'string', description: '缺省时自动选择最近一次失败运行' },
|
||||
},
|
||||
required: ['workflow_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: JSON_OUTPUT,
|
||||
async execute(args, exec) {
|
||||
try {
|
||||
let runId = args?.run_id
|
||||
if (!runId) {
|
||||
const runs = await consoleApi(
|
||||
'GET',
|
||||
`/api/workflows/${encodeURIComponent(args.workflow_id)}/runs?limit=8`,
|
||||
undefined,
|
||||
{ signal: exec?.signal },
|
||||
)
|
||||
const list = runs.runs ?? []
|
||||
const failed = list.find((run) => run.status === 'failed')
|
||||
const chosen = failed ?? list[0]
|
||||
if (!chosen) {
|
||||
return { ok: true, message: '该工作流还没有任何运行记录', workflow_id: args.workflow_id }
|
||||
}
|
||||
runId = chosen.run_id
|
||||
}
|
||||
const payload = await consoleApi(
|
||||
'GET',
|
||||
`/api/workflows/${encodeURIComponent(args.workflow_id)}/runs/${encodeURIComponent(runId)}/diagnosis`,
|
||||
undefined,
|
||||
{ signal: exec?.signal },
|
||||
)
|
||||
return { ok: true, ...payload, run: compactRun(payload.run) }
|
||||
} catch (error) {
|
||||
return toolErrorResult(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
register({
|
||||
name: 'gyxx_workflow_trigger',
|
||||
description:
|
||||
'触发工作流运行。默认 execute=false 为试运行(演练,无外部副作用);' +
|
||||
'正式执行必须 execute=true 且 confirmed=true(只有在用户明确确认后才允许)。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
workflow_id: { type: 'string' },
|
||||
business_date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: '业务日期 YYYY-MM-DD' },
|
||||
execute: { type: 'boolean', description: '缺省 false = 试运行' },
|
||||
confirmed: { type: 'boolean', description: '正式执行确认标记,须先征得用户确认' },
|
||||
shadow: { type: 'boolean', description: '影子模式(对比旧链路),缺省 false' },
|
||||
force_refresh: { type: 'boolean', description: '忽略幂等跳过强制重采(需正式执行)' },
|
||||
},
|
||||
required: ['workflow_id', 'business_date'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: JSON_OUTPUT,
|
||||
async execute(args, exec) {
|
||||
const execute = args?.execute === true
|
||||
const confirmed = args?.confirmed === true
|
||||
if (execute && !confirmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
'正式执行(execute=true)会对外部系统产生真实写入。请先用中文向用户说明将执行的操作与影响,' +
|
||||
'在用户明确确认后,再以 confirmed=true 重新调用本工具。',
|
||||
}
|
||||
}
|
||||
try {
|
||||
const payload = await consoleApi(
|
||||
'POST',
|
||||
`/api/workflows/${encodeURIComponent(args.workflow_id)}/runs`,
|
||||
{
|
||||
business_date: args.business_date,
|
||||
execute,
|
||||
shadow: args?.shadow === true,
|
||||
confirmed,
|
||||
force_refresh: args?.force_refresh === true,
|
||||
},
|
||||
{ signal: exec?.signal },
|
||||
)
|
||||
return { ok: true, launched: payload }
|
||||
} catch (error) {
|
||||
return toolErrorResult(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
register({
|
||||
name: 'gyxx_workflow_cancel',
|
||||
description: '停止某工作流正在进行的执行:手动运行需要 operation_id;定时运行用 scheduled=true。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
workflow_id: { type: 'string' },
|
||||
operation_id: { type: 'string', description: '手动运行的操作 ID(active_run.operation_id)' },
|
||||
scheduled: { type: 'boolean', description: 'true 表示停止调度器正在执行的运行' },
|
||||
},
|
||||
required: ['workflow_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: JSON_OUTPUT,
|
||||
async execute(args, exec) {
|
||||
try {
|
||||
if (args?.scheduled === true) {
|
||||
const payload = await consoleApi(
|
||||
'DELETE',
|
||||
`/api/workflows/${encodeURIComponent(args.workflow_id)}/scheduled-run`,
|
||||
{},
|
||||
{ signal: exec?.signal },
|
||||
)
|
||||
return { ok: true, result: payload }
|
||||
}
|
||||
if (typeof args?.operation_id !== 'string' || !args.operation_id) {
|
||||
return { ok: false, error: '停止手动运行必须提供 operation_id(见 active_run.operation_id)' }
|
||||
}
|
||||
const payload = await consoleApi(
|
||||
'DELETE',
|
||||
`/api/workflows/${encodeURIComponent(args.workflow_id)}/runs/${encodeURIComponent(args.operation_id)}`,
|
||||
{},
|
||||
{ signal: exec?.signal },
|
||||
)
|
||||
return { ok: true, result: payload }
|
||||
} catch (error) {
|
||||
return toolErrorResult(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
register({
|
||||
name: 'gyxx_schedule_update',
|
||||
description:
|
||||
'修改工作流的定时调度(启用/停用、启动时间、周期)。采用读-改-写:' +
|
||||
'先用 gyxx_workflow_detail 拿到 schedule 与 schedule_revision,再把完整调度对象' +
|
||||
'与 revision 一并提交。修改前必须向用户说明并获得确认。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
workflow_id: { type: 'string' },
|
||||
schedule_revision: { type: 'string', description: 'gyxx_workflow_detail 返回的版本号' },
|
||||
kind: { type: 'string', enum: ['daily', 'weekly', 'monthly', 'interval_days'] },
|
||||
at: {
|
||||
description: '启动时间,HH:MM 字符串或字符串数组',
|
||||
anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }],
|
||||
},
|
||||
enabled: { type: 'boolean' },
|
||||
days: { type: 'array', items: { type: 'string' }, description: 'weekly 时的星期列表' },
|
||||
day_of_month: { type: 'integer', description: 'monthly 时的日期' },
|
||||
every_days: { type: 'integer', description: 'interval_days 时的间隔天数' },
|
||||
business_date_offset_days: { type: 'integer', description: '业务日期偏移,缺省 0' },
|
||||
},
|
||||
required: ['workflow_id', 'schedule_revision', 'kind', 'at'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: JSON_OUTPUT,
|
||||
async execute(args, exec) {
|
||||
try {
|
||||
const body = {
|
||||
kind: args.kind,
|
||||
at: args.at,
|
||||
enabled: args?.enabled ?? true,
|
||||
business_date_offset_days: args?.business_date_offset_days ?? 0,
|
||||
}
|
||||
for (const key of ['days', 'day_of_month', 'every_days']) {
|
||||
if (args?.[key] !== undefined) body[key] = args[key]
|
||||
}
|
||||
const payload = await consoleApi(
|
||||
'PUT',
|
||||
`/api/workflows/${encodeURIComponent(args.workflow_id)}/schedule`,
|
||||
body,
|
||||
{ signal: exec?.signal, ifMatch: args.schedule_revision },
|
||||
)
|
||||
return { ok: true, result: payload }
|
||||
} catch (error) {
|
||||
return toolErrorResult(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 提问 / 诊断 / 修复会话 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function buildSessionPrompt(action, workflow, question) {
|
||||
const label = `${workflow.id}(${workflow.name ?? workflow.id})`
|
||||
const last = workflow.last_run
|
||||
const context = last
|
||||
? `最近一次运行:${last.status},业务日期 ${last.business_date},run_id=${last.run_id}` +
|
||||
(last.error ? `,错误:${last.error}` : '')
|
||||
: '该工作流暂无运行记录'
|
||||
if (action === 'diagnose') {
|
||||
return (
|
||||
`【工作台诊断请求】请诊断工作流 ${label}。\n${context}\n` +
|
||||
'请调用 gyxx_workflow_diagnose 获取最近一次失败运行的诊断包,' +
|
||||
'按「失败步骤 → 根因推测 → 建议修复 → 风险说明」输出中文诊断报告。'
|
||||
)
|
||||
}
|
||||
if (action === 'repair') {
|
||||
return (
|
||||
`【工作台修复请求】请修复工作流 ${label}。\n${context}\n` +
|
||||
'先用 gyxx_workflow_diagnose 定位失败根因,给出候选修复方案并逐条说明影响;' +
|
||||
'任何正式执行或调度修改都必须先征得我的确认(遵守安全契约),确认后再执行。'
|
||||
)
|
||||
}
|
||||
return (
|
||||
`【工作台提问】工作流 ${label}。\n${context}\n` +
|
||||
`我的问题:${question ?? '这个工作流现在状态如何?'}\n` +
|
||||
'请结合 gyxx_workflow_detail / gyxx_workflow_runs / gyxx_workflow_diagnose 回答。'
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 桥接服务(供侧边栏面板使用) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function isLoopbackOrigin(origin) {
|
||||
if (!origin) return null
|
||||
try {
|
||||
const url = new URL(origin)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
||||
if (['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) return origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0
|
||||
const chunks = []
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length
|
||||
if (size > 64 * 1024) {
|
||||
reject(new Error('请求正文过大'))
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
})
|
||||
req.on('end', () => {
|
||||
if (chunks.length === 0) return resolve({})
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
||||
} catch {
|
||||
reject(new Error('请求 JSON 无效'))
|
||||
}
|
||||
})
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function createBridgeServer(ctx, config, consoleApi, alerts, openChatSession) {
|
||||
const standalonePage = () => {
|
||||
try {
|
||||
return readFileSync(join(PLUGIN_DIR, 'client', 'standalone.html'))
|
||||
} catch {
|
||||
return Buffer.from('<h1>gyxx-workbench bridge</h1>', 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
const sendJson = (req, res, status, payload) => {
|
||||
const origin = isLoopbackOrigin(req.headers.origin)
|
||||
const body = JSON.stringify(payload)
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
...(origin ? { 'access-control-allow-origin': origin, vary: 'origin' } : {}),
|
||||
})
|
||||
res.end(body)
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`)
|
||||
const path = url.pathname
|
||||
const origin = isLoopbackOrigin(req.headers.origin)
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
...(origin
|
||||
? {
|
||||
'access-control-allow-origin': origin,
|
||||
'access-control-allow-methods': 'GET,POST,OPTIONS',
|
||||
'access-control-allow-headers': 'content-type,x-gyxx-workbench',
|
||||
'access-control-max-age': '3600',
|
||||
vary: 'origin',
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (req.method === 'GET' && path === '/') {
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
||||
res.end(standalonePage())
|
||||
return
|
||||
}
|
||||
if (req.method === 'GET' && path === '/healthz') {
|
||||
sendJson(req, res, 200, { ok: true, name })
|
||||
return
|
||||
}
|
||||
if (req.method === 'GET' && path === '/bridge/workflows') {
|
||||
const overview = await consoleApi('GET', '/api/overview')
|
||||
sendJson(req, res, 200, {
|
||||
ok: true,
|
||||
summary: overview.summary ?? null,
|
||||
generated_at: overview.generated_at ?? null,
|
||||
workflows: (overview.workflows ?? []).map(compactWorkflow),
|
||||
})
|
||||
return
|
||||
}
|
||||
const detailMatch = /^\/bridge\/workflows\/([^/]+)$/.exec(path)
|
||||
if (req.method === 'GET' && detailMatch) {
|
||||
const payload = await consoleApi(
|
||||
'GET',
|
||||
`/api/workflows/${encodeURIComponent(decodeURIComponent(detailMatch[1]))}`,
|
||||
)
|
||||
sendJson(req, res, 200, { ok: true, ...compactDetail(payload) })
|
||||
return
|
||||
}
|
||||
const runsMatch = /^\/bridge\/workflows\/([^/]+)\/runs$/.exec(path)
|
||||
if (req.method === 'GET' && runsMatch) {
|
||||
const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 8, 1), 20)
|
||||
const payload = await consoleApi(
|
||||
'GET',
|
||||
`/api/workflows/${encodeURIComponent(decodeURIComponent(runsMatch[1]))}/runs?limit=${limit}`,
|
||||
)
|
||||
sendJson(req, res, 200, {
|
||||
ok: true,
|
||||
workflow_id: payload.workflow_id,
|
||||
runs: (payload.runs ?? []).map(compactRun),
|
||||
})
|
||||
return
|
||||
}
|
||||
const diagnosisMatch = /^\/bridge\/workflows\/([^/]+)\/diagnosis$/.exec(path)
|
||||
if (req.method === 'GET' && diagnosisMatch) {
|
||||
const workflowId = decodeURIComponent(diagnosisMatch[1])
|
||||
let runId = url.searchParams.get('run_id') || ''
|
||||
if (!runId) {
|
||||
const runs = await consoleApi(
|
||||
'GET',
|
||||
`/api/workflows/${encodeURIComponent(workflowId)}/runs?limit=8`,
|
||||
)
|
||||
const list = runs.runs ?? []
|
||||
const chosen = list.find((run) => run.status === 'failed') ?? list[0]
|
||||
if (!chosen) {
|
||||
sendJson(req, res, 200, { ok: true, message: '该工作流暂无运行记录', logs: [] })
|
||||
return
|
||||
}
|
||||
runId = chosen.run_id
|
||||
}
|
||||
const payload = await consoleApi(
|
||||
'GET',
|
||||
`/api/workflows/${encodeURIComponent(workflowId)}/runs/${encodeURIComponent(runId)}/diagnosis`,
|
||||
)
|
||||
sendJson(req, res, 200, { ok: true, ...payload })
|
||||
return
|
||||
}
|
||||
if (req.method === 'GET' && path === '/bridge/alerts') {
|
||||
sendJson(req, res, 200, { ok: true, alerts: alerts.list() })
|
||||
return
|
||||
}
|
||||
|
||||
// —— 以下为写操作:要求自定义头,防跨站表单提交 ——
|
||||
if (req.method === 'POST' && (path === '/bridge/trigger' || path === '/bridge/cancel' || path === '/bridge/ask' || path === '/bridge/alerts/seen')) {
|
||||
if (req.headers['x-gyxx-workbench'] !== '1') {
|
||||
sendJson(req, res, 403, { ok: false, error: '缺少 x-gyxx-workbench 写操作标识' })
|
||||
return
|
||||
}
|
||||
const contentType = String(req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase()
|
||||
if (contentType !== 'application/json') {
|
||||
sendJson(req, res, 415, { ok: false, error: '写操作只接受 application/json' })
|
||||
return
|
||||
}
|
||||
const body = await readJsonBody(req)
|
||||
|
||||
if (path === '/bridge/trigger') {
|
||||
const execute = body.execute === true
|
||||
const confirmed = body.confirmed === true
|
||||
if (execute && !confirmed) {
|
||||
sendJson(req, res, 400, { ok: false, error: '正式执行必须 confirmed=true' })
|
||||
return
|
||||
}
|
||||
const payload = await consoleApi(
|
||||
'POST',
|
||||
`/api/workflows/${encodeURIComponent(String(body.workflow_id ?? ''))}/runs`,
|
||||
{
|
||||
business_date: String(body.business_date ?? ''),
|
||||
execute,
|
||||
shadow: body.shadow === true,
|
||||
confirmed,
|
||||
force_refresh: body.force_refresh === true,
|
||||
},
|
||||
)
|
||||
sendJson(req, res, 200, { ok: true, launched: payload })
|
||||
return
|
||||
}
|
||||
if (path === '/bridge/cancel') {
|
||||
const workflowId = String(body.workflow_id ?? '')
|
||||
if (body.scheduled === true) {
|
||||
const payload = await consoleApi(
|
||||
'DELETE',
|
||||
`/api/workflows/${encodeURIComponent(workflowId)}/scheduled-run`,
|
||||
{},
|
||||
)
|
||||
sendJson(req, res, 200, { ok: true, result: payload })
|
||||
return
|
||||
}
|
||||
const operationId = String(body.operation_id ?? '')
|
||||
if (!operationId) {
|
||||
sendJson(req, res, 400, { ok: false, error: '缺少 operation_id' })
|
||||
return
|
||||
}
|
||||
const payload = await consoleApi(
|
||||
'DELETE',
|
||||
`/api/workflows/${encodeURIComponent(workflowId)}/runs/${encodeURIComponent(operationId)}`,
|
||||
{},
|
||||
)
|
||||
sendJson(req, res, 200, { ok: true, result: payload })
|
||||
return
|
||||
}
|
||||
if (path === '/bridge/alerts/seen') {
|
||||
alerts.markSeen(typeof body.id === 'string' ? body.id : null)
|
||||
sendJson(req, res, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
// /bridge/ask —— 创建工作流上下文会话
|
||||
const workflowId = String(body.workflow_id ?? '')
|
||||
const action = ['ask', 'diagnose', 'repair'].includes(body.action) ? body.action : 'ask'
|
||||
let workflow
|
||||
try {
|
||||
const detail = await consoleApi('GET', `/api/workflows/${encodeURIComponent(workflowId)}`)
|
||||
workflow = compactWorkflow(detail.workflow ?? {})
|
||||
} catch {
|
||||
workflow = { id: workflowId, name: workflowId, last_run: null }
|
||||
}
|
||||
const prompt = buildSessionPrompt(action, workflow, typeof body.question === 'string' ? body.question : '')
|
||||
const sessionId = await openChatSession(prompt)
|
||||
if (!sessionId) {
|
||||
sendJson(req, res, 503, {
|
||||
ok: false,
|
||||
error: '当前 dsh 组合不支持编程式会话创建,请在对话中直接提问',
|
||||
})
|
||||
return
|
||||
}
|
||||
sendJson(req, res, 200, { ok: true, session_id: sessionId, action })
|
||||
return
|
||||
}
|
||||
|
||||
sendJson(req, res, 404, { ok: false, error: '接口不存在' })
|
||||
} catch (error) {
|
||||
const status = error instanceof ConsoleApiError && error.status ? error.status : 500
|
||||
sendJson(req, res, status, {
|
||||
ok: false,
|
||||
error: error?.message ?? '桥接服务内部错误',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 插件入口 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function apply(ctx, rawConfig) {
|
||||
const config = {
|
||||
consoleBaseUrl: process.env.GYXX_CONSOLE_URL ?? 'http://127.0.0.1:8765',
|
||||
consoleToken: process.env.GYXX_CONSOLE_TOKEN ?? '',
|
||||
bridgeHost: '127.0.0.1',
|
||||
bridgePort: 8790,
|
||||
pollIntervalMs: 30_000,
|
||||
autoDiagnose: false,
|
||||
projectRoot: process.cwd(),
|
||||
...(rawConfig && typeof rawConfig === 'object' ? rawConfig : {}),
|
||||
}
|
||||
const consoleApi = makeConsoleClient(config)
|
||||
|
||||
// 1) 智能体工具
|
||||
defineWorkflowTools(ctx, consoleApi)
|
||||
|
||||
// 2) 系统提示词
|
||||
ctx.systemPrompt.section({
|
||||
name: 'gyxx-workbench',
|
||||
order: 700,
|
||||
text: SYSTEM_PROMPT_TEXT,
|
||||
})
|
||||
|
||||
// 3) 失败监控器 + 告警缓存
|
||||
const state = {
|
||||
seen: new Map(),
|
||||
items: [],
|
||||
}
|
||||
const alerts = {
|
||||
list: () => state.items.map((item) => ({ ...item })),
|
||||
markSeen(id) {
|
||||
if (id === null) state.items.forEach((item) => (item.seen = true))
|
||||
else {
|
||||
const target = state.items.find((item) => item.id === id)
|
||||
if (target) target.seen = true
|
||||
}
|
||||
},
|
||||
push(alert) {
|
||||
state.items.unshift(alert)
|
||||
if (state.items.length > 50) state.items.length = 50
|
||||
},
|
||||
}
|
||||
|
||||
const openChatSession = async (prompt) => {
|
||||
const agents = typeof ctx.get === 'function' ? ctx.get('agents') : ctx.agents
|
||||
if (!agents || typeof agents.create !== 'function') return null
|
||||
const sessionId = `gyxx-wb-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`
|
||||
const handle = await agents.create({
|
||||
sessionId,
|
||||
meta: { cwd: config.projectRoot, origin: 'gyxx-workbench', isSeeded: true },
|
||||
})
|
||||
handle.agent.followup({
|
||||
id: randomUUID(),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: prompt }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
return sessionId
|
||||
}
|
||||
|
||||
ctx.effect(() => {
|
||||
let stopped = false
|
||||
let timer = null
|
||||
const poll = async () => {
|
||||
try {
|
||||
const overview = await consoleApi('GET', '/api/overview')
|
||||
for (const item of overview.workflows ?? []) {
|
||||
const last = item.last_run
|
||||
if (!last || last.status !== 'failed') {
|
||||
if (last && last.status !== 'failed') state.seen.delete(item.id)
|
||||
continue
|
||||
}
|
||||
if (state.seen.get(item.id) === last.run_id) continue
|
||||
state.seen.set(item.id, last.run_id)
|
||||
const alert = {
|
||||
id: randomUUID(),
|
||||
kind: 'workflow-failed',
|
||||
workflow_id: item.id,
|
||||
name: item.name ?? item.id,
|
||||
run_id: last.run_id,
|
||||
business_date: last.business_date ?? null,
|
||||
error: last.error ?? null,
|
||||
at: new Date().toISOString(),
|
||||
seen: false,
|
||||
}
|
||||
alerts.push(alert)
|
||||
try {
|
||||
ctx.emit('gyxx-workbench/alert', alert)
|
||||
} catch {
|
||||
// 自定义事件在某些组合上不可用时忽略
|
||||
}
|
||||
if (config.autoDiagnose) {
|
||||
const prompt = buildSessionPrompt('diagnose', compactWorkflow(item), '')
|
||||
openChatSession(prompt)?.catch(() => {})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 控制台离线时静默,下一轮继续
|
||||
}
|
||||
if (!stopped) timer = setTimeout(poll, config.pollIntervalMs)
|
||||
}
|
||||
timer = setTimeout(poll, 5_000)
|
||||
return () => {
|
||||
stopped = true
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, 'gyxx-workbench: monitor')
|
||||
|
||||
// 4) 桥接服务(侧边栏面板数据 + 会话创建 + 降级页面)
|
||||
ctx.effect(async () => {
|
||||
const server = createBridgeServer(ctx, config, consoleApi, alerts, openChatSession)
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(config.bridgePort, config.bridgeHost, resolve)
|
||||
})
|
||||
} catch (error) {
|
||||
// 端口占用等问题不应阻断插件加载(工具与监控仍可用)
|
||||
ctx.logger?.warn?.(
|
||||
`[gyxx-workbench] 桥接服务启动失败(${error?.message ?? error}),侧边栏面板不可用`,
|
||||
)
|
||||
try {
|
||||
server.close()
|
||||
} catch {
|
||||
// 忽略关闭异常
|
||||
}
|
||||
return () => {}
|
||||
}
|
||||
const address = server.address()
|
||||
ctx.logger?.info?.(
|
||||
`[gyxx-workbench] 桥接服务已启动: http://${config.bridgeHost}:${address?.port ?? config.bridgePort}/`,
|
||||
)
|
||||
return () =>
|
||||
new Promise((resolve) => {
|
||||
server.close(() => resolve())
|
||||
})
|
||||
}, 'gyxx-workbench: bridge')
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Generated
+481
@@ -0,0 +1,481 @@
|
||||
{
|
||||
"name": "@gyxx/dsh-plugin-gyxx-workbench",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@gyxx/dsh-plugin-gyxx-workbench",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.24.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
||||
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
||||
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
||||
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
||||
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
||||
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.24.2",
|
||||
"@esbuild/android-arm": "0.24.2",
|
||||
"@esbuild/android-arm64": "0.24.2",
|
||||
"@esbuild/android-x64": "0.24.2",
|
||||
"@esbuild/darwin-arm64": "0.24.2",
|
||||
"@esbuild/darwin-x64": "0.24.2",
|
||||
"@esbuild/freebsd-arm64": "0.24.2",
|
||||
"@esbuild/freebsd-x64": "0.24.2",
|
||||
"@esbuild/linux-arm": "0.24.2",
|
||||
"@esbuild/linux-arm64": "0.24.2",
|
||||
"@esbuild/linux-ia32": "0.24.2",
|
||||
"@esbuild/linux-loong64": "0.24.2",
|
||||
"@esbuild/linux-mips64el": "0.24.2",
|
||||
"@esbuild/linux-ppc64": "0.24.2",
|
||||
"@esbuild/linux-riscv64": "0.24.2",
|
||||
"@esbuild/linux-s390x": "0.24.2",
|
||||
"@esbuild/linux-x64": "0.24.2",
|
||||
"@esbuild/netbsd-arm64": "0.24.2",
|
||||
"@esbuild/netbsd-x64": "0.24.2",
|
||||
"@esbuild/openbsd-arm64": "0.24.2",
|
||||
"@esbuild/openbsd-x64": "0.24.2",
|
||||
"@esbuild/sunos-x64": "0.24.2",
|
||||
"@esbuild/win32-arm64": "0.24.2",
|
||||
"@esbuild/win32-ia32": "0.24.2",
|
||||
"@esbuild/win32-x64": "0.24.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@gyxx/dsh-plugin-gyxx-workbench",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "GYXX 智能工作台:把 gyxx-flow 工作流暴露为 deepseek-harness 智能体工具与侧边栏面板",
|
||||
"type": "module",
|
||||
"main": "gyxx-workbench.mjs",
|
||||
"exports": {
|
||||
".": "./gyxx-workbench.mjs",
|
||||
"./client": "./lib/client.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-ui-renderer",
|
||||
"@deepseek-ai/dsh-client-ui-layout",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node scripts/build-client.mjs",
|
||||
"test": "node --test \"tests/**/*.test.mjs\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.24.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 构建客户端包:esbuild(CJS / browser / 平台模块外置)+ dsh 模块加载器包装。
|
||||
* 包装协议与 dsh 官方 tsdown 产物一致:
|
||||
* window.__ModuleLoader__.load({ id, factory: (require) => { ... return module.exports } })
|
||||
*/
|
||||
|
||||
import { build } from 'esbuild'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const PKG_ID = '@gyxx/dsh-plugin-gyxx-workbench'
|
||||
|
||||
// dsh 平台共享模块表(packages/client/web/src/platform.ts 的 PLATFORM_MODULES)
|
||||
const PLATFORM_EXTERNALS = [
|
||||
'react',
|
||||
'react/jsx-runtime',
|
||||
'react-dom',
|
||||
'react-dom/client',
|
||||
'@deepseek-ai/cordis',
|
||||
'@deepseek-ai/dsh-client-store',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
]
|
||||
|
||||
await build({
|
||||
absWorkingDir: root,
|
||||
entryPoints: ['client/src/index.jsx'],
|
||||
bundle: true,
|
||||
format: 'cjs',
|
||||
platform: 'browser',
|
||||
target: 'es2020',
|
||||
outfile: 'lib/client.js',
|
||||
external: PLATFORM_EXTERNALS,
|
||||
loader: { '.css': 'text' },
|
||||
jsx: 'automatic',
|
||||
sourcemap: true,
|
||||
minify: false,
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
||||
},
|
||||
banner: {
|
||||
js: `window.__ModuleLoader__.load({ id: ${JSON.stringify(PKG_ID)}, factory: (require) => {`,
|
||||
},
|
||||
footer: { js: 'return module.exports; } });' },
|
||||
logLevel: 'info',
|
||||
})
|
||||
|
||||
console.log('built lib/client.js')
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* gyxx-workbench 宿主插件冒烟测试:
|
||||
* - apply() 能注册 7 个工具与系统提示词
|
||||
* - 工具在控制台离线时返回结构化错误(不抛出)
|
||||
* - 桥接服务能代理控制台 API 并为提问创建会话
|
||||
* 运行:node --test tests/
|
||||
*/
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import http from 'node:http'
|
||||
|
||||
import * as plugin from '../gyxx-workbench.mjs'
|
||||
|
||||
function makeCtx() {
|
||||
const registered = { tools: [], sections: [], effects: [], emissions: [] }
|
||||
const agents = {
|
||||
calls: [],
|
||||
async create(options) {
|
||||
agents.calls.push(options)
|
||||
return {
|
||||
agent: {
|
||||
followups: [],
|
||||
followup(message) {
|
||||
this.followups.push(message)
|
||||
agents.calls[agents.calls.length - 1].followup = message
|
||||
},
|
||||
},
|
||||
dispose() {},
|
||||
}
|
||||
},
|
||||
}
|
||||
const ctx = {
|
||||
tools: { register: (def) => registered.tools.push(def) },
|
||||
systemPrompt: { section: (section) => registered.sections.push(section) },
|
||||
effect(fn) {
|
||||
registered.effects.push(fn)
|
||||
return undefined
|
||||
},
|
||||
emit: (event, payload) => registered.emissions.push([event, payload]),
|
||||
get: (key) => (key === 'agents' ? agents : undefined),
|
||||
logger: { info() {} },
|
||||
}
|
||||
return { ctx, registered, agents }
|
||||
}
|
||||
|
||||
test('apply 注册 7 个工作流工具与系统提示词', () => {
|
||||
const { ctx, registered } = makeCtx()
|
||||
plugin.apply(ctx, { bridgePort: 0, pollIntervalMs: 60_000 })
|
||||
const names = registered.tools.map((tool) => tool.name).sort()
|
||||
assert.deepEqual(names, [
|
||||
'gyxx_schedule_update',
|
||||
'gyxx_workflow_cancel',
|
||||
'gyxx_workflow_detail',
|
||||
'gyxx_workflow_diagnose',
|
||||
'gyxx_workflow_list',
|
||||
'gyxx_workflow_runs',
|
||||
'gyxx_workflow_trigger',
|
||||
])
|
||||
assert.equal(registered.sections.length, 1)
|
||||
assert.equal(registered.sections[0].name, 'gyxx-workbench')
|
||||
assert.match(registered.sections[0].text, /安全契约/)
|
||||
// 监控器 + 桥接服务两个 effect
|
||||
assert.equal(registered.effects.length, 2)
|
||||
})
|
||||
|
||||
test('工具在控制台离线时返回结构化错误而非抛出', async () => {
|
||||
const { ctx, registered } = makeCtx()
|
||||
plugin.apply(ctx, {
|
||||
consoleBaseUrl: 'http://127.0.0.1:1',
|
||||
bridgePort: 0,
|
||||
pollIntervalMs: 60_000,
|
||||
})
|
||||
const listTool = registered.tools.find((tool) => tool.name === 'gyxx_workflow_list')
|
||||
const result = await listTool.execute({})
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /无法连接 gyxx 控制台/)
|
||||
})
|
||||
|
||||
test('正式执行缺少 confirmed 时工具直接拒绝', async () => {
|
||||
const { ctx, registered } = makeCtx()
|
||||
plugin.apply(ctx, { consoleBaseUrl: 'http://127.0.0.1:1', bridgePort: 0 })
|
||||
const trigger = registered.tools.find((tool) => tool.name === 'gyxx_workflow_trigger')
|
||||
const result = await trigger.execute({
|
||||
workflow_id: 'content.metrics.daily',
|
||||
business_date: '2026-08-01',
|
||||
execute: true,
|
||||
})
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /确认/)
|
||||
})
|
||||
|
||||
test('桥接服务代理控制台 API 并通过 /bridge/ask 创建会话', async (t) => {
|
||||
// 1) 模拟 gyxx 控制台
|
||||
const consoleServer = http.createServer((req, res) => {
|
||||
if (req.url === '/api/overview') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
workflows: [
|
||||
{
|
||||
id: 'content.metrics.daily',
|
||||
name: '内容指标日报',
|
||||
module: 'content_marketing',
|
||||
trigger: 'scheduled',
|
||||
registered: true,
|
||||
schedule: { enabled: true, kind: 'daily', at: '09:00' },
|
||||
last_run: {
|
||||
run_id: 'run-1',
|
||||
business_date: '2026-08-01',
|
||||
mode: 'execute',
|
||||
shadow: false,
|
||||
status: 'failed',
|
||||
started_at: '2026-08-01T01:00:00+00:00',
|
||||
ended_at: '2026-08-01T01:05:00+00:00',
|
||||
error: 'step failed',
|
||||
step_counts: { failed: 1 },
|
||||
},
|
||||
active_run: null,
|
||||
},
|
||||
],
|
||||
summary: { total: 1 },
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (req.url === '/api/workflows/content.metrics.daily') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ workflow: { id: 'content.metrics.daily', name: '内容指标日报' } }))
|
||||
return
|
||||
}
|
||||
res.writeHead(404, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: '页面或接口不存在' }))
|
||||
})
|
||||
await new Promise((resolve) => consoleServer.listen(0, '127.0.0.1', resolve))
|
||||
t.after(() => consoleServer.close())
|
||||
const consolePort = consoleServer.address().port
|
||||
|
||||
// 2) 以 mock ctx 加载插件并手动启动桥接 effect
|
||||
const { ctx, registered, agents } = makeCtx()
|
||||
plugin.apply(ctx, {
|
||||
consoleBaseUrl: `http://127.0.0.1:${consolePort}`,
|
||||
bridgePort: 0,
|
||||
pollIntervalMs: 600_000,
|
||||
})
|
||||
const bridgeEffect = registered.effects[1]
|
||||
const dispose = await bridgeEffect()
|
||||
t.after(() => dispose())
|
||||
|
||||
// 桥接端口由 effect 内部 listen 决定,通过日志无法读取——改为直接请求
|
||||
// 控制台代理行为经工具路径验证;这里验证 ask 会话创建逻辑。
|
||||
const listTool = registered.tools.find((tool) => tool.name === 'gyxx_workflow_list')
|
||||
const list = await listTool.execute({})
|
||||
assert.equal(list.ok, true)
|
||||
assert.equal(list.workflows.length, 1)
|
||||
assert.equal(list.workflows[0].last_run.status, 'failed')
|
||||
assert.equal(list.workflows[0].module_label, '内容营销')
|
||||
|
||||
// diagnose:自动选择最近失败运行(控制台无该路由时应返回结构化错误)
|
||||
const diagnose = registered.tools.find((tool) => tool.name === 'gyxx_workflow_diagnose')
|
||||
const missing = await diagnose.execute({ workflow_id: 'content.metrics.daily' })
|
||||
assert.equal(missing.ok, false)
|
||||
assert.match(missing.error, /页面或接口不存在|HTTP 404/)
|
||||
})
|
||||
Reference in New Issue
Block a user