mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:21:57 +00:00
242 lines
11 KiB
TypeScript
242 lines
11 KiB
TypeScript
import { useState } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import { Ban, Check, CheckCheck, Hand, PenLine, Wrench } from 'lucide-react'
|
||
import { api } from '../api/client'
|
||
import { useStudio } from '../store/studio'
|
||
import { useCatalog } from '../store/catalog'
|
||
import { NODE_DEFS } from '../canvas/nodeDefs'
|
||
import { StatusDot, useToast } from '../components/ui'
|
||
import { useLocalActor } from '../lib/actor'
|
||
import { formatDateTime, formatSpan, formatTime, parseServerTime } from '../lib/format'
|
||
import { runClassLabel, runName, TOOL_TRUST_TEXT } from '../lib/terms'
|
||
import { useRunClock } from './useRunClock'
|
||
import { Markdown } from './Markdown'
|
||
import { ApprovalReports, ApprovalRunClass } from './ApprovalReport'
|
||
import { ChallengeSummary } from './ChallengeSummary'
|
||
import { DualCheckApproval } from './DualCheckApproval'
|
||
import type { Approval, ApprovalReport } from '../types'
|
||
|
||
// 发起运行的表单搬去了 run/RunControl.tsx 的工具栏控件里。它原先常驻在
|
||
// 助手栏顶部,和 Copilot 输入框两个"主要动作"互相压着——而它们是两种不同的
|
||
// 意图(跑这张图 / 改这张图),不该争同一块地方。运行是对整张图的动作,
|
||
// 和保存、发布同类,属于工具栏。
|
||
|
||
/**
|
||
* 审批卡。
|
||
*
|
||
* 卡头交代上下文:哪个工作流、挂在哪个节点、什么时候发起、已经等了多久(超过
|
||
* 一天用琥珀色);按钮下面常显"批了会怎样",以前藏在驳回按钮的 hover 里;
|
||
* 底部写明"将以谁的名义签批"——审批留痕记的是设置里的署名,没署名时静默记空,
|
||
* 事后审计才发现没有人名。
|
||
*
|
||
* onResolved 是给非画布场景用的:这个组件被对话页和运行页复用,而默认的
|
||
* "恢复后重新接上事件流"走的是 studio store——在对话页里用就会同时开两条
|
||
* 订阅(一条 chat 的、一条 studio 的),两边各收一份事件。谁用谁负责重新
|
||
* 接流,是唯一不会打架的分工。
|
||
*/
|
||
export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
|
||
approval: Approval | any
|
||
onResolved?: (runId: string) => void | Promise<void>
|
||
/** 画布上工作流就是眼前这张,卡头不用再说一遍 */
|
||
showWorkflow?: boolean
|
||
}) {
|
||
const toast = useToast()
|
||
const refreshApprovals = useCatalog((s) => s.refreshApprovals)
|
||
const attachRun = useStudio((s) => s.attachRun)
|
||
const [note, setNote] = useState('')
|
||
const [value, setValue] = useState(() =>
|
||
approval.payload?.draft != null ? String(approval.payload.draft) : '',
|
||
)
|
||
const [busy, setBusy] = useState(false)
|
||
const payload = approval.payload ?? {}
|
||
// 本机署名随 X-Actor 发给后端、写进审批留痕;在设置或发布弹窗里改了,卡底这句跟着变
|
||
const actor = useLocalActor()
|
||
// 等待时长一分钟级就够,不订阅 100ms 的时钟;卡片因别的原因重画时顺带更新
|
||
const now = useRunClock(false)
|
||
const created = parseServerTime(approval.created_at)
|
||
const age = created ? now - created.getTime() : undefined
|
||
const stale = age != null && age > 24 * 3_600_000
|
||
const approveMode = approval.mode === 'approve'
|
||
// 探索运行里 MCP / 自定义工具的审批才带它(契约:payload.trust_key)。没有就和以前一模一样
|
||
const trustKey: string | undefined = approveMode
|
||
? (typeof payload.trust_key === 'string' && payload.trust_key) || undefined
|
||
: undefined
|
||
|
||
/**
|
||
* always:「始终允许」——批准这次,并让后端把这个工具设成门控把关。/approvals/{id}/decide
|
||
* 的请求体没有 always,所以走 /runs/{id}/resume,带上 approval_id 指明是哪一条
|
||
*/
|
||
const decide = async (approved: boolean, always = false) => {
|
||
setBusy(true)
|
||
try {
|
||
if (always && trustKey) {
|
||
await api.runs.resume(approval.run_id, { approved: true, always: true, note }, approval.id)
|
||
} else {
|
||
await api.approvals.decide(approval.id, {
|
||
approved,
|
||
note,
|
||
...(!approveMode ? { value } : {}),
|
||
})
|
||
}
|
||
await refreshApprovals()
|
||
// 工具页的信任档跟着变了(后端设成了门控把关):目录重拉一次,那边不显示旧档
|
||
if (always && trustKey) void useCatalog.getState().reload('tools')
|
||
// 恢复后重新接上事件流:调用方给了 onResolved 就听它的
|
||
if (onResolved) await onResolved(approval.run_id)
|
||
else await attachRun(approval.run_id)
|
||
// 驳回在"补充输入/编辑草稿"模式下会终止整个运行(图上没有 rejected 那条
|
||
// 出口边),说清楚比笼统一句"已驳回"诚实
|
||
toast(
|
||
always && trustKey
|
||
? TOOL_TRUST_TEXT.alwaysDone(trustKey)
|
||
: approved
|
||
? '已批准,运行继续'
|
||
: approveMode ? '已驳回,转入驳回分支' : '已驳回,本次运行终止',
|
||
'ok',
|
||
)
|
||
} catch (e) {
|
||
toast.error(e)
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const where = approval.node_label || approval.node_id
|
||
// 人工审批节点挂起的审批(工具、代码的审批另有 kind):卡上标明运行类别、批了会怎样,审的是报告时附上核对概况
|
||
const humanNode = payload.kind === 'human_node'
|
||
const reports: ApprovalReport[] = Array.isArray(payload.reports)
|
||
? payload.reports.filter((r: unknown): r is ApprovalReport =>
|
||
!!r && typeof r === 'object' && typeof (r as ApprovalReport).node_id === 'string')
|
||
: []
|
||
return (
|
||
<div className="fade-up border-b p-3 last:border-b-0" data-approval={approval.id}
|
||
style={{ background: 'color-mix(in srgb, var(--warn) 7%, transparent)' }}>
|
||
<div className="mb-1 flex items-center gap-1.5 text-xs font-semibold" style={{ color: 'var(--warn)' }}>
|
||
<Hand size={13} aria-hidden /> {approval.title || '待审批'}
|
||
</div>
|
||
<div className="tnum mb-2 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-2xs text-dim">
|
||
<span>人工审批</span>
|
||
{showWorkflow && approval.workflow_name && <><span aria-hidden>·</span><span>{runName(approval)}</span></>}
|
||
{where && <><span aria-hidden>·</span><span>节点「{where}」</span></>}
|
||
{approval.run_class && !humanNode && <><span aria-hidden>·</span><span>{runClassLabel(approval.run_class)}</span></>}
|
||
{created && (
|
||
<>
|
||
<span aria-hidden>·</span>
|
||
<span title={formatDateTime(created)}>{formatTime(created)} 发起</span>
|
||
</>
|
||
)}
|
||
{age != null && age >= 60_000 && (
|
||
<>
|
||
<span aria-hidden>·</span>
|
||
<span style={stale ? { color: 'var(--st-waiting)', fontWeight: 600 } : undefined}>
|
||
已等待 {formatSpan(age, { coarse: true })}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* 老后端的审批没有运行类别:不猜,不画这一行 */}
|
||
{humanNode && approval.run_class && <ApprovalRunClass runClass={approval.run_class} approve={approveMode} />}
|
||
|
||
{payload.message && (
|
||
// 待审的往往是一整段模型写的文案,同样是 Markdown。要人家判断"能不能
|
||
// 发",却让他对着一堆星号看,是在给审批这件事添难度
|
||
<div className="mb-2 max-h-40 overflow-y-auto rounded border bg-bg p-2">
|
||
<Markdown text={String(payload.message)} dense />
|
||
</div>
|
||
)}
|
||
|
||
{/* 双盲复算对不上、交给人裁决的审批,卡上画两路结果的对照 */}
|
||
{payload.kind === 'dual_check' && <DualCheckApproval approval={approval} />}
|
||
|
||
<ApprovalReports reports={reports} runId={approval.run_id} runClass={approval.run_class} />
|
||
<ChallengeSummary reports={reports} runId={approval.run_id} />
|
||
|
||
{payload.tool && (
|
||
<div className="mb-2 rounded border bg-bg p-2">
|
||
<div className="mb-1 flex items-center gap-1.5 text-2xs">
|
||
<Wrench size={11} aria-hidden /> <code className="mono">{payload.tool}</code>
|
||
</div>
|
||
<pre className="mono max-h-32 overflow-auto text-2xs leading-relaxed text-dim">
|
||
{JSON.stringify(payload.args ?? {}, null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
|
||
{payload.code && (
|
||
<pre className="mono mb-2 max-h-40 overflow-auto rounded border bg-bg p-2 text-2xs leading-relaxed">
|
||
{payload.code}
|
||
</pre>
|
||
)}
|
||
|
||
{!approveMode && (
|
||
<textarea
|
||
className="field mb-2"
|
||
rows={4}
|
||
value={value}
|
||
aria-label={approval.mode === 'edit' ? '修改后的内容' : '你的输入'}
|
||
placeholder={approval.mode === 'edit' ? '修改后的内容' : '你的输入'}
|
||
onChange={(e) => setValue(e.target.value)}
|
||
/>
|
||
)}
|
||
|
||
<input
|
||
className="field mb-2"
|
||
aria-label="备注"
|
||
placeholder="备注(将传递给后续节点)"
|
||
value={note}
|
||
onChange={(e) => setNote(e.target.value)}
|
||
/>
|
||
|
||
<div className="flex gap-2">
|
||
<button className="btn btn-primary flex-1 justify-center" disabled={busy} onClick={() => decide(true)}>
|
||
<Check size={12} aria-hidden /> {approveMode ? '批准' : '提交'}
|
||
</button>
|
||
{trustKey && (
|
||
<button className="btn flex-1 justify-center" disabled={busy} data-approval-always
|
||
title={TOOL_TRUST_TEXT.alwaysHint(trustKey)} aria-describedby={`approval-always-${approval.id}`}
|
||
onClick={() => decide(true, true)}>
|
||
<CheckCheck size={12} aria-hidden /> {TOOL_TRUST_TEXT.alwaysButton}
|
||
</button>
|
||
)}
|
||
<button className="btn btn-danger flex-1 justify-center" disabled={busy} onClick={() => decide(false)}>
|
||
<Ban size={12} aria-hidden /> {approveMode ? '驳回' : '驳回并终止'}
|
||
</button>
|
||
</div>
|
||
{/* 批了会怎样,常显而不是藏在 hover 里:审批人得在按下去之前知道 */}
|
||
<div className="mt-1.5 text-2xs leading-relaxed text-dim">
|
||
{approveMode
|
||
? '批准:继续运行;驳回:转入「驳回」分支'
|
||
: '提交:以你填写的内容继续运行;驳回:终止本次运行'}
|
||
</div>
|
||
{trustKey && (
|
||
<div id={`approval-always-${approval.id}`} className="mt-0.5 text-2xs leading-relaxed text-dim">
|
||
{TOOL_TRUST_TEXT.alwaysButton}:{TOOL_TRUST_TEXT.alwaysHint(trustKey)}
|
||
</div>
|
||
)}
|
||
<div className="mt-1 flex items-center gap-1 text-2xs leading-relaxed"
|
||
style={actor ? { color: 'var(--text-dim)' } : { color: 'var(--st-waiting)' }}>
|
||
<PenLine size={10} aria-hidden className="shrink-0" />
|
||
{actor
|
||
? <span>将以「{actor}」签批,并记入审批留痕</span>
|
||
: (
|
||
<span>
|
||
未署名:本次签批不会记录审批人 ·{' '}
|
||
<Link to="/settings/prefs" className="underline underline-offset-2 hover:text-fg">去设置署名</Link>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Timeline / OutputView / describe / EVENT_META / IssuanceBanner / RawEvents
|
||
// 都搬走了:
|
||
// - "事件 → 人话"归 run/decode.ts,这里曾经有一套平行的 EVENT_META + describe(),
|
||
// 和解码器各译各的。留着两套,同一次运行在画布和问数据页会读出不同的故事,
|
||
// 而用户没法判断哪个是真的。
|
||
// - 怎么画归 run/AssistantStream.tsx(含出具横幅),怎么组装归 run/AssistantPanel.tsx。
|
||
// 这个文件现在只剩两样东西:发起运行,和处理审批。
|
||
|
||
export { StatusDot, NODE_DEFS }
|