01218b2907
- 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)
938 lines
34 KiB
JavaScript
938 lines
34 KiB
JavaScript
/**
|
||
* 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')
|
||
}
|