Add revisioned decisions and scoped consultation lifecycle

This commit is contained in:
aiagent
2026-10-19 22:28:53 -07:00
parent 0fcfe1c024
commit 6e4178dc79
27 changed files with 1431 additions and 192 deletions
+86 -99
View File
@@ -1,113 +1,100 @@
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ArrowRight } from 'lucide-react'
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, SheetFooter } from '@/components/ui/sheet'
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
import { PageHeading, Panel, Empty } from '@/components/shared'
import DecisionChat from '@/components/decision-chat'
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, type ChatDraft } from './decision-ui'
const statuses = { open: '等待决定', decided: '已决定', pending_verification: '待核实' }
const priorities = { normal: '普通', high: '重要', urgent: '紧急' }
type Draft = { optionId: string | null; comment: string }
const emptyDraft: Draft = { optionId: null, comment: '' }
function Status({ decision }: { decision: Decision }) {
return <Badge variant="outline" className={decision.status === 'decided' ? 'status-success' : 'status-neutral'}>{statuses[decision.status]}</Badge>
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, drafts, chats }: { id: string; initial?: Decision; drafts: Map<string, DecisionDraft>; chats: Map<string, ChatDraft> }) {
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])
if (decision && !drafts.has(id)) drafts.set(id, { option: null, comment: '', revision: decision.revision ?? 1, conflict: false })
const draft = drafts.get(id)
const revision = decision?.revision ?? 1
const changed = Boolean(draft && (draft.revision !== revision || draft.conflict))
const editable = Boolean(decision && writable(decision) && !draft?.sending)
async function submit() {
if (!decision || !draft || !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() }
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(''); draft.option = null; draft.comment = ''; draft.attempt = undefined
await refresh().catch(() => {}); await reload.current()
} catch (cause) {
if (cause instanceof ApiError && cause.status === 409) { draft.conflict = true; setNotice('内容或状态已变化,草稿已保留。请阅读最新内容并重新确认,未自动重交。'); await reload.current() }
else setNotice(`${errorText(cause)} 草稿已保留,相同内容重试复用原提交记录。`)
} finally { 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">{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">
<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; redraw(n => n + 1) }} 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; redraw(n => n + 1) }}>清除选项,仅填写意见</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; redraw(n => n + 1) }} />
{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); 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} drafts={chats} /></div></div> : !error && <Empty>正在读取当前决策。</Empty>}
</div></>
}
export default function Decisions() {
const { state, token, refresh } = useWorkbench()
const [params, setParams] = useSearchParams()
const [receipts, setReceipts] = useState<Record<string, Decision>>({})
const decisions = (state.decisions ?? []).map(decision => decision.status === 'open' ? receipts[decision.id] ?? decision : decision)
const selectedId = params.get('decision')
const selected = decisions.find(d => d.id === selectedId)
const [drafts, setDrafts] = useState<Record<string, Draft>>({})
const [notices, setNotices] = useState<Record<string, string>>({})
const [busy, setBusy] = useState<Record<string, boolean>>({})
const [submitted, setSubmitted] = useState<Record<string, boolean>>({})
const inFlight = useRef(new Set<string>())
const attempts = useRef(new Map<string, { payload: string; key: string }>())
const acknowledged = useRef(new Set<string>())
const draft = selected ? drafts[selected.id] ?? emptyDraft : emptyDraft
const editable = selected?.status === 'open' && !busy[selected.id] && !submitted[selected.id]
function open(id: string | null) {
setParams(previous => { const next = new URLSearchParams(previous); if (id) next.set('decision', id); else next.delete('decision'); return next })
}
function updateDraft(value: Partial<Draft>) {
if (!selected || !editable) return
setDrafts(previous => ({ ...previous, [selected.id]: { ...(previous[selected.id] ?? emptyDraft), ...value } }))
}
async function sync(id: string) {
try { await refresh(); setNotices(previous => ({ ...previous, [id]: '' })) }
catch (error) { setNotices(previous => ({ ...previous, [id]: `状态同步未完成:${errorText(error)} 请刷新状态。` })) }
}
async function submit(decision: Decision) {
const id = decision.id
const current = drafts[id] ?? emptyDraft
if (decision.status !== 'open' || inFlight.current.has(id) || acknowledged.current.has(id)) return
if (decision.options.length && !decision.options.some(option => option.id === current.optionId)) return
const body = { option_id: current.optionId, comment: current.comment }
const payload = JSON.stringify(body)
let attempt = attempts.current.get(id)
if (!attempt || attempt.payload !== payload) { attempt = { payload, key: messageKey() }; attempts.current.set(id, attempt) }
inFlight.current.add(id)
setBusy(previous => ({ ...previous, [id]: true }))
setNotices(previous => ({ ...previous, [id]: '' }))
try {
const result = await request<Decision>(token, `/api/decisions/${encodeURIComponent(id)}/respond`, { ...body, idempotency_key: attempt.key })
setReceipts(previous => ({ ...previous, [id]: result }))
acknowledged.current.add(id)
setSubmitted(previous => ({ ...previous, [id]: true }))
setNotices(previous => ({ ...previous, [id]: '' }))
await sync(id)
} catch (error) {
setNotices(previous => ({ ...previous, [id]: `${errorText(error)} 草稿已保留;重试相同内容不会重复提交。` }))
if (error instanceof ApiError && error.status === 409) await sync(id)
} finally {
inFlight.current.delete(id)
setBusy(previous => ({ ...previous, [id]: false }))
}
}
const legacy = state.tasks.filter(task => task.decision)
return <div className="space-y-6">
<PageHeading title="方案与决策" description="查看影响与方案,再明确提交你的选择。" action={<Button asChild variant="outline"><Link to="/communication">讨论方案 <ArrowRight /></Link></Button>} />
{decisions.length ? <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{decisions.map(decision => <Card key={decision.id} className="gap-0 p-0">
<button type="button" onClick={() => open(decision.id)} className="flex h-full w-full flex-col gap-4 rounded-xl p-6 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={`查看决策:${decision.title}`}>
<div className="flex flex-wrap gap-2"><Badge variant="outline" className={decision.priority === 'urgent' ? 'status-danger' : 'status-neutral'}>{priorities[decision.priority]}</Badge><Status decision={decision} /></div>
<h2 className="font-semibold break-words">{decision.title}</h2>
<p className="whitespace-pre-wrap break-words text-sm leading-6">{decision.question}</p>
<p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">影响:{decision.impact || '尚未补充'}</p>
<span className="mt-auto flex items-center gap-2 text-sm text-primary">查看方案与详情 <ArrowRight className="size-4" /></span>
</button>
</Card>)}</div> : <Panel title="决策展板"><Empty>当前没有结构化决策事项。可在沟通中请总控补充需要决定的问题与方案。</Empty></Panel>}
{legacy.length > 0 && <Panel title="待完善条目(只读)" description="以下是工作包原有的文字记录,尚不能在此提交选择。"><div className="space-y-4">{legacy.map(task => <article key={task.id} className="rounded-lg border p-4"><h3 className="font-medium">{task.id} · {task.name}</h3><p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6">{task.decision}</p><p className="mt-2 text-xs text-muted-foreground">更新于 {date(task.updated)}</p><Button asChild variant="link" className="mt-2 h-auto p-0"><Link to="/communication">请总控补充结构化选项 <ArrowRight /></Link></Button></article>)}</div></Panel>}
<Sheet open={selectedId !== null} onOpenChange={value => { if (!value) open(null) }}>
<SheetContent className="w-full sm:max-w-xl">
<SheetHeader className="pr-12"><SheetTitle>{selected?.title ?? '决策暂不可用'}</SheetTitle><SheetDescription>{selected?.question ?? '该条目尚未同步或已移除,请刷新状态后重试。'}</SheetDescription></SheetHeader>
{selected ? <>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-4 pb-4 break-words">
<div className="flex flex-wrap gap-2"><Badge variant="outline">{priorities[selected.priority]}</Badge><Status decision={selected} /></div>
<section><h3 className="font-medium">影响</h3><p className="mt-2 whitespace-pre-wrap text-sm leading-6">{selected.impact || '尚未补充'}</p></section>
<section><h3 className="font-medium">背景与依据</h3><p className="mt-2 whitespace-pre-wrap text-sm leading-6">{selected.details || '尚未补充'}</p></section>
{selected.recommendation && <section className="rounded-lg border bg-muted/50 p-4"><h3 className="font-medium">推荐与理由</h3><p className="mt-2 whitespace-pre-wrap text-sm leading-6">{selected.recommendation}</p><p className="mt-2 text-xs text-muted-foreground">推荐不会替你选择。</p></section>}
{selected.deadline && Number.isFinite(Date.parse(selected.deadline)) && <p className="text-sm">截止时间:{date(selected.deadline)}</p>}
{selected.requires_authorization && <p className="rounded-lg border p-4 text-sm">此事项涉及权限。提交选择后仍需核实,不代表已授权。</p>}
{selected.options.length > 0 && <fieldset disabled={!editable} className="space-y-3"><legend className="mb-3 font-medium">方案与取舍{selected.status === 'open' ? '(请选择一项)' : ''}</legend>{selected.options.map(option => <label key={option.id} className={`flex gap-3 rounded-lg border p-4 ${editable ? 'cursor-pointer hover:bg-muted/50' : ''}`}><input type="radio" name={`decision-${selected.id}`} value={option.id} checked={selected.status === 'open' ? draft.optionId === option.id : selected.response?.option_id === option.id} onChange={() => updateDraft({ optionId: option.id })} 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 leading-6 text-muted-foreground">{option.description}</span></span></label>)}</fieldset>}
{selected.status === 'open' && <div className="space-y-2"><label htmlFor="decision-comment" className="text-sm font-medium">补充备注</label><Textarea id="decision-comment" value={draft.comment} onChange={event => updateDraft({ comment: event.target.value })} disabled={!editable} placeholder="补充你的考虑或要求" /></div>}
{selected.response && <section className="space-y-2 rounded-lg border p-4"><h3 className="font-medium">已记录的回复</h3><p className="text-sm">选择:{selected.options.find(option => option.id === selected.response?.option_id)?.label ?? selected.response.option_id ?? '仅备注'}</p><p className="whitespace-pre-wrap text-sm">{selected.response.comment || '无补充备注'}</p><p className="text-xs text-muted-foreground">{date(selected.response.created)}</p></section>}
{selected.status === 'pending_verification' && <p role="status" className="text-sm">选择已记录,等待核实;尚未授权。</p>}
{notices[selected.id] && <p role="status" className="text-sm">{notices[selected.id]}</p>}
</div>
<SheetFooter className="border-t">
{selected.status === 'open' && !submitted[selected.id] && <Button disabled={!editable || (selected.options.length > 0 && !selected.options.some(option => option.id === draft.optionId))} onClick={() => void submit(selected)}>{busy[selected.id] ? '正在提交…' : '提交选择'}</Button>}
<Button variant="outline" disabled={busy[selected.id]} onClick={() => void sync(selected.id)}>刷新状态</Button>
</SheetFooter>
</> : <SheetFooter><Button variant="outline" onClick={() => void sync(selectedId ?? '')}>刷新状态</Button></SheetFooter>}
</SheetContent>
</Sheet>
const { state } = useWorkbench(), [params, setParams] = useSearchParams()
const id = params.get('decision'), drafts = useRef(new Map<string, DecisionDraft>()), chats = useRef(new Map<string, ChatDraft>())
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)} drafts={drafts.current} chats={chats.current} />}</SheetContent></Sheet>
</div>
}