Files
gyxx-flow/workbench/plugin/client/standalone.html
T
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

259 lines
12 KiB
HTML
Raw 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.
<!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) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>