554 lines
20 KiB
TypeScript
554 lines
20 KiB
TypeScript
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>
|
||
);
|
||
}
|