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:
2026-09-04 11:10:16 +08:00
parent b124d757b0
commit 01218b2907
26 changed files with 4347 additions and 8 deletions
+258
View File
@@ -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) => ({'&':'&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>