Files
agent-workbench/assets/dashboard/src/pages/decisions.tsx
T

107 lines
15 KiB
TypeScript
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.
import { useEffect, useRef, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { Textarea } from '@/components/ui/textarea'
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
import { PageHeading, Panel, Empty } from '@/components/shared'
import DecisionChat from '@/components/decision-chat'
import VersionNotice from '@/components/version-notice'
import { useLocalDraft } from '@/lib/use-local-draft'
import { useWorkbench } from '@/lib/context'
import { ApiError, errorText, messageKey, request } from '@/lib/api'
import { date, type Decision } from '@/lib/types'
import { decisionStatus, decisionLabels, writable, submissionBody, confirmDraft, type DecisionDraft } from './decision-ui'
type HistoryEntry = { id?: string | number; kind?: string; created?: number; revision?: number; data?: Record<string, unknown> }
function Status({ decision }: { decision: Decision }) { const status = decisionStatus(decision); return <Badge variant="outline" className={status === 'completed' ? 'status-success' : status === 'needs_reconsideration' ? 'status-danger' : 'status-neutral'}>{decisionLabels[status]}</Badge> }
function Text({ label, text }: { label: string; text?: string }) { return text ? <section><h3 className="font-medium">{label}</h3><p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6">{text}</p></section> : null }
const historyLabels: Record<string, string> = { version: '决策版本', submission: '提交选择', review: '主控检查', completion: '完成交接', binding: '绑定咨询会话', archive: '归档确认', linked: '关联决策', created: '建立决策', updated: '内容更新', revised: '版本更新', submitted: '提交选择', responded: '提交选择', reviewed: '检查结论', completed: '完成交接', bound: '绑定咨询会话', session_bound: '绑定咨询会话', archived: '归档确认', fact_requested: '请求核实', fact_answered: '核实回复' }
function HistoryRecord({ entry }: { entry: HistoryEntry }) {
const data = entry.data ?? {}
const snapshot = data.snapshot && typeof data.snapshot === 'object' ? data.snapshot as Record<string, unknown> : data.content && typeof data.content === 'object' ? data.content as Record<string, unknown> : data
const options = Array.isArray(snapshot.options) ? snapshot.options.filter((item): item is { id: string; label: string; description?: string } => Boolean(item && typeof item === 'object' && typeof item.id === 'string' && typeof item.label === 'string')) : []
return <li className="space-y-2 rounded-lg border p-3 text-sm"><p className="font-medium">{historyLabels[entry.kind ?? ''] || '处理记录'}{entry.revision ? ` · 版本 ${entry.revision}` : ''} · {date(entry.created)}</p>
{typeof data.option_id === 'string' && <p>当时选择:{options.find(option => option.id === data.option_id)?.label || data.option_id}</p>}
{Object.entries({ reason:'理由', comment:'提交意见', conclusion:'完成结论', handoff:'交接', impact:'影响', execution_plan:'执行计划', question:'问题', answer:'核实回复' }).map(([key,label]) => typeof data[key] === 'string' ? <Text key={key} label={label} text={data[key] as string} /> : null)}
{snapshot !== data && typeof snapshot.question === 'string' && <Text label="当时的问题" text={snapshot.question} />}
{options.length > 0 && <details><summary className="cursor-pointer">查看当时全部方案</summary><ul className="mt-2 space-y-2">{options.map(option => <li key={option.id}><p className="font-medium">{option.label}</p><p className="whitespace-pre-wrap">{option.description}</p></li>)}</ul></details>}
</li>
}
function Detail({ id, initial }: { id: string; initial?: Decision }) {
const { token, refresh, state } = useWorkbench()
const [decision, setDecision] = useState(initial), [history, setHistory] = useState<HistoryEntry[]>([]), [error, setError] = useState(''), [notice, setNotice] = useState(''), [, redraw] = useState(0)
const reload = useRef<() => Promise<void>>(async () => {}), generation = useRef(0)
useEffect(() => {
const controller = new AbortController(); let pending = false
const poll = async () => {
if (pending || controller.signal.aborted) return
pending = true; const seq = ++generation.current
try { const current = await request<Decision>(token, `/api/decisions/${encodeURIComponent(id)}`, undefined, controller.signal); if (!controller.signal.aborted && seq === generation.current) { setDecision(current); setError('') }
const events = await request<{ history: HistoryEntry[] }>(token, `/api/decisions/${encodeURIComponent(id)}/history`, undefined, controller.signal); if (!controller.signal.aborted && seq === generation.current) setHistory(events.history)
} catch (cause) { if (!controller.signal.aborted && seq === generation.current) setError(errorText(cause)) } finally { pending = false }
}
reload.current = poll; void poll(); const timer = setInterval(() => void poll(), 3000)
return () => { controller.abort(); clearInterval(timer) }
}, [id, token])
const revision = decision?.revision ?? 1
const local = useLocalDraft<DecisionDraft>(state.instance_id && decision && Number.isInteger(decision.revision) && (decision.revision ?? 0) > 0 ? [state.instance_id, 'decision', id, String(revision)] : null, {option:null,comment:'',revision,conflict:false}, (v): v is DecisionDraft => Boolean(v && typeof v === 'object' && typeof (v as DecisionDraft).comment === 'string' && Number.isInteger((v as DecisionDraft).revision)), true)
const draft = local.value
const sending = useRef(false)
function persist() { local.save(draft); redraw(n => n + 1) }
const changed = Boolean(draft && (draft.revision !== revision || draft.conflict))
const editable = Boolean(decision && writable(decision) && !draft?.sending && local.ready)
async function submit() {
if (!decision || !draft || sending.current || !editable || changed || (!draft.option && !draft.comment.trim())) return
const body = submissionBody(decision, draft)
if (!body) return
const signature = JSON.stringify(body)
if (!draft.attempt || draft.attempt.signature !== signature) draft.attempt = { signature, key: messageKey() }
if (!local.save(draft)) return
sending.current = true
draft.sending = true; redraw(n => n + 1); setNotice('')
try {
const result = await request<Decision>(token, `/api/decisions/${encodeURIComponent(id)}/respond`, { ...body, idempotency_key: draft.attempt.key })
++generation.current; setDecision(result); setNotice(''); local.clear()
await refresh().catch(() => {}); await reload.current()
} catch (cause) {
if (cause instanceof ApiError && cause.status === 409) { draft.conflict = true; persist(); setNotice('内容或状态已变化,草稿已保留。请阅读最新内容并重新确认,未自动重交。'); await reload.current() }
else setNotice(`${errorText(cause)} 草稿已保留,相同内容重试复用原提交记录。`)
} finally { sending.current = false; draft.sending = false; redraw(n => n + 1) }
}
return <><SheetHeader className="pr-12"><SheetTitle>{decision?.title ?? '读取决策…'}</SheetTitle><SheetDescription>{decision?.summary || decision?.question || '查看方案、检查结论与本决策咨询。'}</SheetDescription></SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-5"><VersionNotice />{error && <p role="status" className="mb-4 text-sm text-destructive">{error} 保留已读取内容。<Button variant="link" onClick={() => void reload.current()}>重试</Button></p>}
{decision && draft ? <div className="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]"><div className="min-w-0 space-y-5">
{local.error && <p role="status" className="text-xs text-destructive">{local.error}</p>}{local.error && <Button variant="outline" size="sm" disabled={draft.sending} onClick={() => persist()}>重试保存草稿</Button>}{!writable(decision) && (draft.comment || draft.option) && <section className="rounded-lg border p-3 text-sm"><h3>保留的本地草稿(只读,未自动提交)</h3><p>版本 {draft.revision} · 选项 {draft.option || '无'}</p><p className="whitespace-pre-wrap">{draft.comment}</p></section>}<div className="flex flex-wrap gap-2"><Status decision={decision} /><Badge variant="outline">版本 {revision}</Badge>{decision.task_id && <Link to={`/tasks?task=${encodeURIComponent(decision.task_id)}`} className="text-sm text-primary underline">{state.tasks.find(task => task.id === decision.task_id)?.name || decision.task_id}</Link>}</div>
<Text label="需要决定的问题" text={decision.question} /><Text label="背景与依据" text={decision.details} /><Text label="影响" text={decision.impact} /><Text label="推荐与理由(不会替你选择)" text={decision.recommendation} />
{decision.deadline && Number.isFinite(Date.parse(decision.deadline)) && <p className="text-sm">截止时间:{date(decision.deadline)}</p>}
<p className="text-xs text-muted-foreground">选择提交后需检查;接受方案不代表权限授权,也不代表已经完成。</p>
{decision.options.length > 0 && <fieldset disabled={!editable || changed} className="space-y-2"><legend className="mb-2 font-medium">全部方案</legend>{decision.options.map(option => <label key={option.id} className="flex gap-3 rounded-lg border p-3"><input type="radio" name={`decision-${id}`} checked={draft.option === option.id} onChange={() => { draft.option = option.id; persist() }} className="mt-1 size-4 shrink-0 accent-primary" /><span><span className="font-medium">{option.label}</span><span className="mt-1 block whitespace-pre-wrap text-sm text-muted-foreground">{option.description}</span></span></label>)}</fieldset>}
{writable(decision) && <div className="space-y-3"><Button variant="ghost" size="sm" disabled={!editable || changed || !draft.option} onClick={() => { draft.option = null; persist() }}>清除选项,仅填写意见</Button><label className="block text-sm font-medium" htmlFor={`opinion-${id}`}>你的意见(可不选方案,直接填写)</label><Textarea id={`opinion-${id}`} disabled={!editable} value={draft.comment} onChange={event => { draft.comment = event.target.value; persist() }} />
{changed && <div className="space-y-2 rounded-lg border p-3"><p className="text-sm">请重新阅读当前版本;之前的选择不会自动作为本次确认。</p><Button variant="outline" disabled={draft.sending || Boolean(error)} onClick={() => { confirmDraft(decision, draft); persist(); setNotice(''); redraw(n => n + 1) }}>我已阅读版本 {revision},重新确认</Button></div>}
<Button disabled={!editable || changed || (!draft.option && !draft.comment.trim())} onClick={() => void submit()}>{draft.sending ? '正在提交…' : '提交本次选择或意见'}</Button>
</div>}
{notice && <p role="status" className="text-sm">{notice}</p>}
{decision.response && <section className="space-y-2 rounded-lg border p-3"><h3 className="font-medium">最近提交</h3><p className="text-sm">提交版本:{decision.response.revision ?? '旧记录'} · {date(decision.response.created)}</p><p className="text-sm">选项:{decision.response.snapshot?.options?.find(option => option.id === decision.response?.option_id)?.label ?? decision.response.option_id ?? '仅填写意见'}</p><p className="whitespace-pre-wrap text-sm">{decision.response.comment}</p></section>}
{decision.review && <section className="space-y-3 rounded-lg border p-3"><h3 className="font-medium">检查结论:{decision.review.outcome === 'accept' ? '接受,待落实' : '需要重新考虑'}</h3><Text label="理由" text={decision.review.reason} /><Text label="影响" text={decision.review.impact} /><Text label="执行计划" text={decision.review.execution_plan} /></section>}
{decision.completion && <section className="space-y-3 rounded-lg border p-3"><Text label="完成结论" text={decision.completion.conclusion} /><Text label="交接说明" text={decision.completion.handoff} />{decision.completion.evidence?.map((text,index) => <p key={index} className="break-words text-sm">{text}</p>)}</section>}
{decision.status === 'completed' && <p className="text-sm">{decision.archive_status === 'archived' ? '咨询会话已确认归档,历史只读。' : '决策已完成,咨询会话待归档确认。'}</p>}
{Boolean(decision.supersedes_id || decision.related_decision_ids?.length) && <section><h3 className="mb-2 font-medium">关联决策</h3>{[...new Set([...(decision.supersedes_id ? [decision.supersedes_id] : []), ...(decision.related_decision_ids ?? [])])].map(other => <Link key={other} className="mr-3 text-sm text-primary underline" to={`/decisions?decision=${encodeURIComponent(other)}`}>{other}{other === decision.supersedes_id ? '(被此决策替代)' : ''}</Link>)}</section>}
<section><h3 className="mb-2 font-medium">版本与处理历史</h3>{history.length ? <ol className="space-y-3">{history.map((entry,index) => <HistoryRecord key={entry.id ?? index} entry={entry} />)}</ol> : <p className="text-sm text-muted-foreground">暂无已读取的历史记录。</p>}</section>
</div><div className="min-w-0"><DecisionChat key={id} decision={decision} token={token} /></div></div> : !error && <Empty>正在读取当前决策。</Empty>}
</div></>
}
export default function Decisions() {
const { state } = useWorkbench(), [params, setParams] = useSearchParams()
const id = params.get('decision')
const decisions = state.decisions ?? []
return <div className="space-y-6"><PageHeading title="方案与决策" description="查看方案、咨询依据并明确提交,跟踪检查与落实结果。" />
{decisions.length ? <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{decisions.map(decision => <Card key={decision.id} className="p-0"><button type="button" onClick={() => setParams(previous => { const next = new URLSearchParams(previous); next.set('decision',decision.id); return next })} className="flex h-full w-full flex-col gap-3 rounded-xl p-6 text-left hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring"><div className="flex flex-wrap gap-2"><Status decision={decision} /><Badge variant="outline">{decision.task_id ? state.tasks.find(task => task.id === decision.task_id)?.name || decision.task_id : '未关联任务'}</Badge></div><h2 className="font-semibold">{decision.title}</h2><p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">{decision.summary || decision.question}</p><span className="mt-auto text-sm text-primary">查看方案与咨询</span></button></Card>)}</div> : <Panel title="决策展板"><Empty>当前没有登记决策事项。</Empty></Panel>}
{state.tasks.some(task => task.decision) && <Panel title="旧版文字条目(只读)">{state.tasks.filter(task => task.decision).map(task => <p key={task.id} className="mb-3 whitespace-pre-wrap text-sm">{task.name}:{task.decision}</p>)}</Panel>}
<Sheet open={id !== null} onOpenChange={value => { if (!value) setParams(previous => { const next = new URLSearchParams(previous); next.delete('decision'); return next }) }}><SheetContent className="w-full sm:max-w-6xl">{id && <Detail key={id} id={id} initial={decisions.find(decision => decision.id === id)} />}</SheetContent></Sheet>
</div>
}