Files
Agent-System-Frontend/src/components/ConversationWork.tsx
T

131 lines
7.7 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { ActivityCardView, Collapsible, activitySummary, groupActivityCards, pendingCount, type ActivityCard } from '@ui'
import { store, useStore } from '../store'
import { docBridge } from './Workbench'
import type { WorkCardAction } from '../contracts'
import { WorkExecutionRecord } from './WorkExecutionRecord'
interface CandidateChange { key: string; before?: string; after: string; impact?: string }
/** 候选差异与影响:只取服务端已给出的发现字段(corrects 为更正前,text 为候选,impact 为影响),不读取正文。 */
export function candidateChanges(card: ActivityCard): CandidateChange[] {
if (card.status !== 'action_required') return []
return (card.task?.findings ?? [])
.filter(f => f.status === 'active' && (f.corrects || f.impact))
.map(f => ({ key: `${f.id}:${f.revision}`, before: f.corrects, after: f.text, impact: f.impact }))
}
const CONFIRM_IDS = new Set(['confirm', 'accept', 'approve'])
const REJECT_IDS = new Set(['reject', 'decline', 'deny'])
/** 确认/拒绝动作:沿用卡片自带 actions;非工作卡的动作行已由 ActivityCardView 渲染,这里只补工作卡。 */
export function candidateActions(card: ActivityCard) {
if (!card.work || card.status !== 'action_required') return []
return (card.actions ?? []).filter(a => CONFIRM_IDS.has(a.id) || REJECT_IDS.has(a.id))
.sort((a, b) => Number(REJECT_IDS.has(a.id)) - Number(REJECT_IDS.has(b.id)))
}
function CandidateReview({ card, locked, onAction }: { card: ActivityCard; locked: boolean; onAction: (card: ActivityCard, actionId: string, payload?: WorkCardAction) => void }) {
const changes = candidateChanges(card)
const actions = candidateActions(card)
if (changes.length === 0 && actions.length === 0) return null
return (
<div className="conversation-candidate" data-candidate-card={card.id}>
<div className="conversation-candidate-head">候选变更 · {card.title}</div>
{changes.map(c => (
<dl key={c.key} className="conversation-candidate-change">
{c.before && <div><dt>原内容</dt><dd>{c.before}</dd></div>}
<div><dt>{c.before ? '候选' : '候选内容'}</dt><dd>{c.after}</dd></div>
{c.impact && <div><dt>影响</dt><dd>{c.impact}</dd></div>}
</dl>
))}
{actions.length > 0 && (
<div className="acard-actions">
{actions.map(a => (
<button key={a.id} type="button" className={`btn${REJECT_IDS.has(a.id) ? ' ghost' : ''}`} disabled={locked}
onClick={() => { if (!locked) onAction(card, a.id, { resultVersion: card.work!.resultVersion }) }}>{a.label}</button>
))}
</div>
)}
</div>
)
}
/** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */
export function ConversationWork() {
const cards = useStore(s => s.cards)
const conversationId = useStore(s => s.currentConvId)
const projectId = useStore(s => s.currentProjectId)
const scratch = useStore(s => !!s.tasks.find(t => t.id === s.currentConvId)?.scratch)
const messages = useStore(s => s.messages)
const actionStates = useStore(s => s.cardActionStates)
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
const rightTab = useStore(s => s.ui.rightTab)
const record = useStore(s => s.workRecord)
const detailHere = rightCollapsed || rightTab !== 'work'
const [summaryOpen, setSummaryOpen] = useState(detailHere)
useEffect(() => setSummaryOpen(detailHere), [detailHere])
const { current, required, focused, history } = useMemo(() => {
const current = cards.filter(card => card.status !== 'dismissed'
&& card.conversationId === conversationId && card.projectId === projectId)
const required: ActivityCard[] = []
const remaining: ActivityCard[] = []
for (const card of current) {
// A confirmation already visible at its user message has one main action
// path in the conversation; assistant-anchored cards still need this path.
if (card.kind === 'confirm' && card.anchorMessageId
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
const summary = activitySummary(card)
const taskNotice = card.task?.limitations?.length || card.task?.phase.id === 'correction'
|| card.task?.findings?.some(f => f.status === 'active' && (f.kind === 'needs_user' || f.corrects))
if (taskNotice || card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
else remaining.push(card)
}
// Preserve the host's newest-first order for completed results and history.
const results = groupActivityCards(remaining).find(group => group.key === 'results')?.cards ?? []
const focused = remaining.find(card => card.pinned && card.status === 'active')
?? remaining.find(card => card.status === 'active')
?? results[0]
return { current, required, focused, history: remaining.filter(card => card !== focused).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) }
}, [cards, messages, conversationId, projectId])
if (!detailHere) return null
if (record) return <div className="conversation-work" data-conversation-work><WorkExecutionRecord /></div>
if (current.length === 0) return null
const pending = pendingCount(current)
const active = current.filter(card => card.status === 'active').length
const results = current.filter(card => card.status === 'done' || card.status === 'failed').length
const summary = [pending ? `${pending} 项待处理` : '', active ? `${active} 项工作` : '', results ? `${results} 项结果` : ''].filter(Boolean).join(' · ')
const callbacks = {
onAction: (card: ActivityCard, actionId: string, payload?: WorkCardAction) => { void store.cardAction(card, actionId, payload) },
onOpenRecord: (card: ActivityCard) => store.openWorkRecord(card),
onReference: (card: ActivityCard) => store.referenceWork(card),
actionState: (card: ActivityCard) => actionStates?.[card.id],
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
onLocate: (card: ActivityCard) => { store.locateMessage(card.anchorMessageId) },
...(scratch ? {} : { onArchive: (card: ActivityCard) => { void store.cardToProject(card) } }),
}
const view = (card: ActivityCard) => <ActivityCardView key={card.id} card={card} callbacks={callbacks} docBridge={docBridge} />
return (
<section className="conversation-work" data-conversation-work aria-label="工作进展">
<div className="conversation-work-heading"><span>工作进展</span><span>{summary || '已处理的工作'}</span></div>
<div className="conversation-work-current">
{required.map(card => <div key={card.id} className="conversation-work-required">
<CandidateReview card={card} locked={actionStates?.[card.id] !== undefined} onAction={callbacks.onAction} />
{view(card)}
</div>)}
{focused && <Collapsible id={`conversation-current:${conversationId}:${focused.id}`} open={summaryOpen} onToggle={setSummaryOpen}
triggerClassName="conversation-work-more" trigger={<><span className="process-collapse-chev">▸</span>{focused.title} · 查看摘要</>}>
{view(focused)}
</Collapsible>}
</div>
{history.length > 0 && <Collapsible id={`conversation-work:${projectId}:${conversationId}`}
triggerClassName="conversation-work-more"
trigger={<><span className="process-collapse-chev">▸</span>更多工作与成果 · {history.length} 项</>}>
<div className="conversation-work-history">{history.map(view)}</div>
</Collapsible>}
</section>
)
}