Files
gyxx-flow/workbench/plugin/gyxx-workbench.mjs
wangyunlong 01218b2907 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)
2026-09-04 11:10:16 +08:00

938 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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: '手动运行的操作 IDactive_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')
}