Files
deerflow-code/web-code/components/qa/components/Timeline.tsx
T
2026-05-27 19:43:15 +08:00

554 lines
20 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 { useCallback, useEffect, useRef, useState } from 'react';
import { Select } from 'tdesign-react';
import AgentCard from './AgentCard';
import FinalReport from './FinalReport';
import './Timeline.css';
const PHASE_LABELS: Record<string, string> = {
starting: '启动',
diverging: '辩论',
closing: '收敛',
done: '完成',
};
function getPhaseBadgeClass(phase?: string): string {
switch (phase) {
case 'starting': return 'badge badge-starting';
case 'diverging': return 'badge badge-diverging';
case 'closing': return 'badge badge-closing';
case 'done': return 'badge badge-done';
default: return 'badge badge-default';
}
}
function BackgroundRetrievalCard({ data }: { data: any }): JSX.Element {
const [open, setOpen] = useState(false);
const { results, dimensions, total_records } = data;
return (
<div className="bg-retrieval-card">
<div className="bg-retrieval-header" onClick={() => setOpen(o => !o)}>
<span className="bg-retrieval-title">背景检索完成</span>
<span className="bg-retrieval-meta">
{dimensions && dimensions.length > 0 ? `${dimensions.length} 维度` : ''}
{total_records != null ? ` · ${total_records} 条` : ''}
</span>
<span className={`bg-retrieval-chevron ${open ? 'open' : ''}`}>▾</span>
</div>
{open && results && (
<div className="bg-retrieval-body">
{Object.entries(results).map(([dim, entries]) => (
<div key={dim} className="bg-retrieval-dim">
<div className="bg-retrieval-dim-label">{dim}</div>
{Array.isArray(entries) && entries.map((entry: any, i: number) => (
<div key={i} className="bg-retrieval-result">
{entry.query && (
<div className="bg-retrieval-query">查询: "{entry.query}"</div>
)}
<div className="bg-retrieval-items">
{Array.isArray(entry.content) && entry.content.map((item: any, j: number) => (
<div key={j} className="bg-retrieval-item">
{typeof item === 'string'
? (item.length > 250 ? item.slice(0, 250) + '…' : item)
: JSON.stringify(item).slice(0, 250)}
</div>
))}
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
);
}
function CoordinatorStreamingCard({ item, reportSchema }: { item: any; reportSchema: Record<string, any> }): JSX.Element {
const { phase, divergeRound, totalDivergeRounds, thinking, content, statusMessage, currentField, reportPartial } = item;
const [thinkingOpen, setThinkingOpen] = useState(false);
const isFinalReport = phase === 'final_report';
const reportFields = reportSchema ? Object.keys(reportSchema) : [];
return (
<div className="coordinator-card coordinator-card-streaming">
<div className="coordinator-card-title">
<span>协调员</span>
{!isFinalReport && divergeRound != null && (
<span className="coordinator-round-badge">
辩论轮 {divergeRound}/{totalDivergeRounds}
</span>
)}
{isFinalReport && (
<span className="coordinator-round-badge">生成最终方案</span>
)}
<span className="coordinator-streaming-dot">●</span>
</div>
{thinking && (
<div className="coordinator-thinking-section">
<div className="coordinator-thinking-header" onClick={() => setThinkingOpen(o => !o)}>
<span className="coordinator-thinking-label">思考中</span>
<span className={`coordinator-draft-chevron ${thinkingOpen ? 'open' : ''}`}>▾</span>
</div>
{thinkingOpen && (
<div className="coordinator-thinking-body">{thinking}</div>
)}
</div>
)}
{isFinalReport && reportFields.length > 0 && (
<div className="coordinator-draft-body" style={{ padding: '8px 0 0' }}>
{reportFields.map(field => {
const done = !!reportPartial[field];
const isActive = currentField === field && !done;
return (
<div key={field} className={`coordinator-draft-field ${done ? 'filled' : isActive ? 'active' : 'empty'}`}>
<span className="coordinator-draft-field-name">
{field}
{isActive && <span className="coordinator-streaming-dot" style={{ marginLeft: 6 }}>●</span>}
</span>
{done
? <span className="coordinator-draft-field-content">{reportPartial[field].slice(0, 200)}{reportPartial[field].length > 200 ? '…' : ''}</span>
: isActive && content
? <span className="coordinator-draft-field-content">{content.slice(-400)}</span>
: <span className="coordinator-draft-field-empty">{isActive ? '生成中…' : '等待中'}</span>
}
</div>
);
})}
</div>
)}
{!isFinalReport && content && (
<div className="coordinator-thinking-body" style={{ marginTop: 6 }}>{content.slice(-600)}</div>
)}
{statusMessage && !isFinalReport && (
<div className="coordinator-status-banner coordinator-status-banner-tail">
<span className="coordinator-status-spinner">⟳</span>
<span>{statusMessage}</span>
</div>
)}
</div>
);
}
function CoordinatorCard({ data, agentStates, thinking }: { data: any; agentStates: Record<string, any>; thinking?: string }): JSX.Element {
const { divergeRound, totalDivergeRounds, nextSpeakers, focus, draft } = data;
const [draftOpen, setDraftOpen] = useState(false);
const [thinkingOpen, setThinkingOpen] = useState(false);
const draftEntries = draft ? Object.entries(draft) : [];
const filledCount = draftEntries.filter(([, v]: [string, any]) => v && String(v).trim()).length;
return (
<div className="coordinator-card">
<div className="coordinator-card-title">
<span>协调员</span>
{divergeRound != null && totalDivergeRounds != null && (
<span className="coordinator-round-badge">
辩论轮 {divergeRound}/{totalDivergeRounds}
</span>
)}
</div>
{thinking && thinking.trim() && (
<div className="coordinator-thinking-section">
<div className="coordinator-thinking-header" onClick={() => setThinkingOpen(o => !o)}>
<span className="coordinator-thinking-label">思考过程</span>
<span className={`coordinator-draft-chevron ${thinkingOpen ? 'open' : ''}`}>▾</span>
</div>
{thinkingOpen && (
<div className="coordinator-thinking-body">{thinking}</div>
)}
</div>
)}
{draftEntries.length > 0 && (
<div className="coordinator-draft-section">
<div
className="coordinator-draft-header"
onClick={() => setDraftOpen(o => !o)}
>
<span className="coordinator-draft-title">方案草稿</span>
<span className="coordinator-draft-progress">
{filledCount}/{draftEntries.length} 字段已填写
</span>
<span className={`coordinator-draft-chevron ${draftOpen ? 'open' : ''}`}>▾</span>
</div>
{draftOpen && (
<div className="coordinator-draft-body">
{draftEntries.map(([field, content]: [string, any]) => (
<div key={field} className={`coordinator-draft-field ${content ? 'filled' : 'empty'}`}>
<span className="coordinator-draft-field-name">{field}</span>
{content
? <span className="coordinator-draft-field-content">{String(content).slice(0, 300)}{String(content).length > 300 ? '…' : ''}</span>
: <span className="coordinator-draft-field-empty">空缺</span>
}
</div>
))}
</div>
)}
</div>
)}
{nextSpeakers && nextSpeakers.length > 0 && (
<div className="coordinator-speakers">
<span className="coordinator-speakers-label">下一轮:</span>
{nextSpeakers.map((id: string) => {
const name = agentStates?.[id]?.name || id;
return <span key={id} className="coordinator-speaker-tag">{name}</span>;
})}
</div>
)}
{focus && Object.keys(focus).length > 0 && (
<div className="coordinator-focus">
{Object.entries(focus).map(([agentId, directive]) => {
const name = agentStates?.[agentId]?.name || agentId;
return (
<div key={agentId} className="coordinator-focus-item">
<span className="coordinator-focus-agent">{name}</span>
<span className="coordinator-focus-directive">{String(directive)}</span>
</div>
);
})}
</div>
)}
</div>
);
}
function HumanReviewCard({ pendingReview, agentStates, onApprove, onRevise, submitting, revising }: any) {
const [nextSpeakers, setNextSpeakers] = useState<string[]>(pendingReview.nextSpeakers || []);
const [focusText, setFocusText] = useState(JSON.stringify(pendingReview.focus || {}, null, 2));
const [draftText, setDraftText] = useState(JSON.stringify(pendingReview.draft || {}, null, 2));
const [gapsText, setGapsText] = useState(JSON.stringify(pendingReview.gaps || [], null, 2));
const [instruction, setInstruction] = useState('');
const [error, setError] = useState('');
useEffect(() => {
setNextSpeakers(pendingReview.nextSpeakers || []);
setFocusText(JSON.stringify(pendingReview.focus || {}, null, 2));
setDraftText(JSON.stringify(pendingReview.draft || {}, null, 2));
setGapsText(JSON.stringify(pendingReview.gaps || [], null, 2));
}, [pendingReview]);
const agentOptions = Object.entries(agentStates || {}).map(([id, agent]: [string, any]) => ({
label: agent.name || id,
value: id,
}));
const handleSpeakerChange = (value: any) => {
const values = Array.isArray(value) ? value : [];
setNextSpeakers(values.map(item => String(item)));
};
const handleSubmit = async () => {
try {
const parsedFocus = JSON.parse(focusText || '{}');
const parsedDraft = JSON.parse(draftText || '{}');
const parsedGaps = JSON.parse(gapsText || '[]');
const nextSpeakerIds = nextSpeakers.map(item => item.trim()).filter(Boolean);
setError('');
await onApprove({
nextSpeakers: nextSpeakerIds,
focus: parsedFocus,
draft: parsedDraft,
gaps: parsedGaps,
});
} catch (err: any) {
setError(err?.message || '提交失败');
}
};
const handleRevise = async () => {
if (!instruction.trim()) {
setError('请先输入你希望协调员如何修改');
return;
}
try {
setError('');
await onRevise(instruction.trim());
setInstruction('');
} catch (err: any) {
setError(err?.message || '修改失败');
}
};
return (
<div className="human-review-card">
<div className="human-review-title">
<span>人工确认</span>
<span className="coordinator-round-badge">
辩论轮 {pendingReview.divergeRound}/{pendingReview.totalDivergeRounds}
</span>
</div>
<div className="human-review-hint">
优先用自然语言告诉协调员怎么改;如果需要,也可以直接微调下方结构化内容。
</div>
<div className="human-review-field">
<label>对协调员说</label>
<textarea
className="human-review-textarea human-review-chatbox"
value={instruction}
onChange={e => setInstruction(e.target.value)}
placeholder="例如:不要让 risk 发言,改成 audience 和 narrative。把 focus 改得更具体,要求 narrative 给出 2 条可以直接发微博的文案。draft 里的 audience_map 先不要定死,保留为待验证。"
disabled={submitting || revising}
/>
<div className="human-review-actions human-review-actions-left">
<button className="btn-secondary" onClick={handleRevise} disabled={submitting || revising}>
{revising ? '协调员修改中…' : '让协调员修改'}
</button>
</div>
</div>
<div className="human-review-field">
<label>下一轮发言者</label>
<Select
className="human-review-speaker-select"
value={nextSpeakers}
options={agentOptions}
multiple
clearable
filterable
minCollapsedNum={3}
placeholder="请选择下一轮发言者"
disabled={submitting || revising}
onChange={handleSpeakerChange}
/>
<div className="human-review-meta">
可选 agent: {agentOptions.map(option => `${option.label}(${option.value})`).join(' / ')}
</div>
</div>
<div className="human-review-grid">
<div className="human-review-field">
<label>任务指令 focus(JSON)</label>
<textarea
className="human-review-textarea"
value={focusText}
onChange={e => setFocusText(e.target.value)}
disabled={submitting || revising}
spellCheck={false}
/>
</div>
<div className="human-review-field">
<label>草稿 draft(JSON)</label>
<textarea
className="human-review-textarea"
value={draftText}
onChange={e => setDraftText(e.target.value)}
disabled={submitting || revising}
spellCheck={false}
/>
</div>
</div>
<div className="human-review-field">
<label>当前缺口 gaps(JSON 数组)</label>
<textarea
className="human-review-textarea human-review-textarea-sm"
value={gapsText}
onChange={e => setGapsText(e.target.value)}
disabled={submitting || revising}
spellCheck={false}
/>
</div>
{error && <div className="human-review-error">{error}</div>}
<div className="human-review-actions">
<button className="btn-primary" onClick={handleSubmit} disabled={submitting || revising}>
{submitting ? '提交中…' : '确认并继续'}
</button>
</div>
</div>
);
}
type Props = {
timeline: any[];
agentStates: Record<string, any>;
reportSchema: Record<string, any>;
pendingReview: any;
onApproveReview: (payload: any) => Promise<void>;
onReviseReview: (instruction: string) => Promise<void>;
reviewSubmitting: boolean;
reviewRevising: boolean;
};
export default function Timeline({ timeline, agentStates, reportSchema, pendingReview, onApproveReview, onReviseReview, reviewSubmitting, reviewRevising }: Props): JSX.Element {
const endRef = useRef<HTMLDivElement | null>(null);
const programmaticScrollUntilRef = useRef(0);
const [showScrollButton, setShowScrollButton] = useState(false);
const [isAutoScrolling, setIsAutoScrolling] = useState(true);
const doScrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
const appMainElement = document.querySelector('.app-main') as HTMLElement | null;
if (!appMainElement) return;
programmaticScrollUntilRef.current = Date.now() + (behavior === 'smooth' ? 700 : 120);
appMainElement.scrollTo({ top: appMainElement.scrollHeight, behavior });
}, []);
useEffect(() => {
const handleScroll = () => {
const appMainElement = document.querySelector('.app-main') as HTMLElement | null;
if (!appMainElement) return;
const { scrollTop, scrollHeight, clientHeight } = appMainElement;
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
const isNearBottom = distanceToBottom < 24;
const isProgrammaticScroll = Date.now() < programmaticScrollUntilRef.current;
setShowScrollButton(!isNearBottom);
if (isNearBottom) {
setIsAutoScrolling(true);
return;
}
if (!isProgrammaticScroll) {
setIsAutoScrolling(false);
}
};
const appMainElement = document.querySelector('.app-main') as HTMLElement | null;
if (appMainElement) {
appMainElement.addEventListener('scroll', handleScroll, { passive: true });
}
return () => {
if (appMainElement) {
appMainElement.removeEventListener('scroll', handleScroll);
}
};
}, []);
useEffect(() => {
if (isAutoScrolling) {
doScrollToBottom('auto');
}
}, [timeline, pendingReview, isAutoScrolling, doScrollToBottom]);
const scrollToBottom = () => {
setIsAutoScrolling(true);
setShowScrollButton(false);
doScrollToBottom('smooth');
};
if (!timeline || timeline.length === 0) {
return (
<div className="timeline">
<div className="timeline-empty">
<div className="timeline-empty-spinner">⟳</div>
<span>等待智能体启动…</span>
</div>
</div>
);
}
return (
<div className="timeline">
{timeline.map((item, idx) => {
if (item.type === 'background_retrieval') {
return <BackgroundRetrievalCard key={idx} data={item.data} />;
}
if (item.type === 'round_start') {
return (
<div key={idx} className="round-header" data-phase={item.phase} data-round={item.round}>
<div className="round-header-line" />
<span className="round-header-label">第 {item.round} 轮</span>
<span className={getPhaseBadgeClass(item.phase)}>
{PHASE_LABELS[item.phase] || item.phase}
</span>
<div className="round-header-line" />
</div>
);
}
if (item.type === 'agent_contribution') {
const { agentId, round, phase, data, streaming } = item;
const agentState = agentStates[agentId] || {};
return (
<div key={`${agentId}-${round}`} data-agent-id={agentId}>
<AgentCard
agentId={agentId}
agentName={agentState.name || agentId}
dimension={agentState.dimension || agentId}
round={round}
phase={phase}
content={data.content}
thinking={data.thinking}
dimensionOutput={data.dimensionOutput}
retrievalLog={data.retrievalLog}
retrievalCount={data.retrievalCount}
referenceMap={data.referenceMap}
streaming={!!streaming}
/>
</div>
);
}
if (item.type === 'coordinator_streaming') {
return (
<div key={idx} data-phase={item.phase || 'closing'}>
<CoordinatorStreamingCard item={item} reportSchema={reportSchema} />
</div>
);
}
if (item.type === 'coordinator') {
return (
<div key={idx} data-phase={item.data?.phase || 'closing'}>
<CoordinatorCard data={item.data} agentStates={agentStates} thinking={item.thinking} />
</div>
);
}
if (item.type === 'final_report') {
return (
<div key={idx} data-phase="done">
<FinalReport output={item.data} reportSchema={reportSchema} />
</div>
);
}
return null;
})}
{pendingReview && (
<HumanReviewCard
pendingReview={pendingReview}
agentStates={agentStates}
onApprove={onApproveReview}
onRevise={onReviseReview}
submitting={reviewSubmitting}
revising={reviewRevising}
/>
)}
<div ref={endRef} />
{showScrollButton && (
<button
className="scroll-to-bottom-button"
onClick={scrollToBottom}
aria-label="滚动到底部并开启自动跟随"
title="回到底部并继续自动下滑"
>
<span className="scroll-arrow">↓</span>
</button>
)}
</div>
);
}