代码修改

This commit is contained in:
fengxiangdebaozi committed 2026-05-27 19:43:15 +08:00
1 parent 8f5af46ec2
commit 8f75e12871
168 files changed
+76575

No files matched your search

@@ -0,0 +1,235 @@
.agent-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 14px;
margin-bottom: 16px;
overflow: hidden;
box-shadow: var(--shadow-card);
}
.agent-card-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface-hover);
}
.agent-card-name {
font-weight: 700;
font-size: 14px;
color: var(--text);
}
.agent-card-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-left: auto;
}
/* Card sections */
.agent-card-section {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.agent-card-section:last-child {
border-bottom: none;
}
.agent-card-section-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 8px;
}
/* Streaming card highlight */
.agent-card-streaming {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
/* Streaming badge */
.badge-streaming {
background: var(--accent-soft);
color: var(--accent);
border: 1px solid var(--accent-soft-strong);
border-radius: 4px;
padding: 1px 6px;
font-size: 10px;
font-weight: 600;
animation: pulse-badge 1.5s ease-in-out infinite;
}
@keyframes pulse-badge {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Streaming plain-text area */
.streaming-text {
font-size: 13px;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.7;
}
/* Blinking cursor */
.streaming-cursor {
display: inline-block;
width: 2px;
height: 0.9em;
background: var(--accent);
margin-left: 2px;
vertical-align: text-bottom;
animation: blink-cursor 0.9s step-end infinite;
}
@keyframes blink-cursor {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Content */
.agent-card-content-text {
font-size: 13px;
color: var(--text);
}
/* Thinking section */
.agent-card-thinking-toggle {
display: flex;
align-items: center;
gap: 6px;
background: transparent;
border: none;
padding: 0;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
margin-bottom: 8px;
}
.agent-card-thinking-toggle:hover {
color: var(--text);
}
.agent-card-thinking-text {
font-size: 12px;
color: var(--text-muted);
background: rgba(139, 148, 158, 0.06);
border-left: 2px solid var(--border);
padding: 8px 12px;
border-radius: 0 4px 4px 0;
}
.agent-card-thinking-text .markdown-body {
color: var(--text-muted);
font-size: 12px;
font-style: italic;
}
.agent-card-thinking-text .markdown-body strong {
color: var(--text-muted);
}
/* Dimension output */
.dim-output-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.dim-output-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.dim-output-key {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent);
}
.dim-output-value {
font-size: 13px;
color: var(--text);
}
/* Retrieval */
.retrieval-source-group {
margin-bottom: 10px;
}
.retrieval-source-group:last-child {
margin-bottom: 0;
}
.retrieval-query-label {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
}
.retrieval-adopted-badge {
background: rgba(63, 185, 80, 0.15);
color: var(--green);
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
}
.retrieval-not-adopted-badge {
background: rgba(139, 148, 158, 0.1);
color: var(--text-muted);
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
}
.retrieval-item {
display: flex;
gap: 6px;
margin-bottom: 4px;
align-items: flex-start;
}
.retrieval-item-num {
flex-shrink: 0;
background: var(--accent-soft);
color: var(--accent);
border-radius: 3px;
padding: 0 4px;
font-size: 10px;
font-family: monospace;
line-height: 18px;
margin-top: 1px;
}
.retrieval-item-text {
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
word-break: break-word;
}
.retrieval-source-label {
font-size: 10px;
color: var(--text-muted);
opacity: 0.7;
margin-left: 4px;
}
@@ -0,0 +1,193 @@
import React, { useState, useEffect, useRef } from 'react';
import './AgentCard.css';
import MarkdownContent from './MarkdownContent';
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';
}
}
const PHASE_LABELS: Record<string, string> = {
starting: '启动',
diverging: '辩论',
closing: '收敛',
done: '完成',
};
type RetrievalLogItem = {
query?: string;
source_name?: string;
source_id?: string;
source?: string;
adopted?: boolean;
content?: any[];
content_ids?: string[];
};
type Props = {
agentId: string;
agentName?: string;
dimension?: string;
round: number;
phase: string;
content?: string;
thinking?: string;
dimensionOutput?: Record<string, any>;
retrievalLog?: RetrievalLogItem[];
retrievalCount?: number;
referenceMap?: Record<string, string>;
streaming?: boolean;
};
export default function AgentCard({
agentId,
agentName,
dimension,
round,
phase,
content,
thinking,
dimensionOutput,
retrievalLog,
retrievalCount = 0,
referenceMap,
streaming = false,
}: Props): JSX.Element {
const [thinkingOpen, setThinkingOpen] = useState(false);
const autoOpenedRef = useRef(false);
useEffect(() => {
if (streaming && thinking && thinking.length > 0 && !autoOpenedRef.current) {
setThinkingOpen(true);
autoOpenedRef.current = true;
}
}, [streaming, thinking]);
const streamingPhase = streaming
? (content && content.length > 0 ? 'content' : 'thinking')
: null;
const hasThinking = thinking && thinking.trim().length > 0;
const hasRetrieval = retrievalCount > 0 && retrievalLog && retrievalLog.length > 0;
const hasDimOutput = dimensionOutput && Object.keys(dimensionOutput).length > 0;
return (
<div className={`agent-card${streaming ? ' agent-card-streaming' : ''}`}>
<div className="agent-card-header">
<span className="agent-card-name">{agentName || agentId}</span>
<div className="agent-card-badges">
{dimension && (
<span className="badge badge-default">{dimension}</span>
)}
<span className="badge badge-default">第 {round} 轮</span>
<span className={getPhaseBadgeClass(phase)}>
{PHASE_LABELS[phase] || phase}
</span>
{streaming && (
<span className="badge badge-streaming">生成中</span>
)}
</div>
</div>
{(hasThinking || streamingPhase === 'thinking') && (
<div className="agent-card-section">
<button
className="agent-card-thinking-toggle"
onClick={() => setThinkingOpen(o => !o)}
>
<span>{thinkingOpen ? '▾' : '▸'}</span>
<span>思考过程</span>
{streamingPhase === 'thinking' && (
<span className="streaming-cursor" style={{ marginLeft: 6 }} />
)}
</button>
{thinkingOpen && (
<div className="agent-card-thinking-text">
{streaming ? (
<span className="streaming-text" style={{ fontStyle: 'italic', fontSize: 12 }}>
{thinking}
</span>
) : (
<MarkdownContent content={thinking} />
)}
</div>
)}
</div>
)}
{(content || streamingPhase === 'content') && (
<div className="agent-card-section">
<div className="agent-card-section-title">分析内容</div>
<div className="agent-card-content-text">
{streaming ? (
<div className="streaming-text">
{content}
{streamingPhase === 'content' && <span className="streaming-cursor" />}
</div>
) : (
<MarkdownContent content={content} referenceMap={referenceMap} />
)}
</div>
</div>
)}
{hasDimOutput && (
<div className="agent-card-section">
<div className="agent-card-section-title">维度输出</div>
<div className="dim-output-grid">
{Object.entries(dimensionOutput).map(([key, value]) => (
<div key={key} className="dim-output-item">
<div className="dim-output-key">{key}</div>
<div className="dim-output-value">
<MarkdownContent
content={typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
/>
</div>
</div>
))}
</div>
</div>
)}
{hasRetrieval && (
<div className="agent-card-section">
<div className="agent-card-section-title">检索信息 ({retrievalCount})</div>
{retrievalLog.map((log, logIdx) => (
<div key={logIdx} className="retrieval-source-group">
<div className="retrieval-query-label">
<span>"{log.query}"</span>
{(log.source_name || log.source_id || log.source) && (
<span className="retrieval-source-label">{log.source_name || log.source_id || log.source}</span>
)}
{log.adopted !== undefined && (
<span className={log.adopted ? 'retrieval-adopted-badge' : 'retrieval-not-adopted-badge'}>
{log.adopted ? '已采用' : '未采用'}
</span>
)}
</div>
{log.content && log.content.map((item, itemIdx) => {
const refNum = log.content_ids ? log.content_ids[itemIdx] : null;
const refKey = refNum ? Object.entries(referenceMap || {}).find(([, v]) => v === refNum)?.[0] : null;
return (
<div key={itemIdx} className="retrieval-item">
{refKey && <span className="retrieval-item-num">[{refKey}]</span>}
<div className="retrieval-item-text">
{typeof item === 'string'
? (item.length > 200 ? item.slice(0, 200) + '…' : item)
: JSON.stringify(item).slice(0, 200)}
</div>
</div>
);
})}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,467 @@
.config-panel {
width: 100%;
max-width: 1080px;
margin: 0 auto;
}
.config-panel-title {
text-align: center;
margin-bottom: 24px;
padding-top: 4px;
}
.config-title-eyebrow {
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
padding: 4px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface-raised);
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
}
.config-panel-title h1 {
font-size: 32px;
line-height: 1.1;
font-weight: 800;
color: var(--accent);
letter-spacing: 1px;
margin-bottom: 8px;
}
.config-panel-title p {
color: var(--text-muted);
font-size: 14px;
}
.config-layout {
display: flex;
flex-direction: column;
gap: 18px;
}
.config-task-section,
.config-card,
.config-advanced-section {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 18px;
box-shadow: var(--shadow-card);
}
.config-task-section {
padding: 24px;
}
.config-task-section label {
display: block;
font-weight: 700;
font-size: 13px;
color: var(--text);
margin-bottom: 10px;
}
.config-task-textarea,
.config-textarea,
.config-input,
.config-json-editor {
width: 100%;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: none;
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.03);
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.config-task-textarea,
.config-textarea,
.config-json-editor {
padding: 14px 16px;
resize: vertical;
line-height: 1.7;
}
.config-task-textarea {
min-height: 150px;
}
.config-textarea {
min-height: 108px;
}
.config-textarea-sm {
min-height: 78px;
}
.config-input {
padding: 11px 13px;
}
.config-json-editor {
min-height: 320px;
font-family: 'SFMono-Regular', Consolas, monospace;
}
.config-task-textarea:focus,
.config-textarea:focus,
.config-input:focus,
.config-json-editor:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
background: var(--surface-raised);
}
.config-submit-row {
display: flex;
align-items: center;
gap: 12px;
justify-content: flex-end;
margin-top: 18px;
flex-wrap: wrap;
padding-top: 4px;
}
.config-mode-switch {
display: inline-flex;
background: color-mix(in srgb, var(--surface) 96%, var(--accent) 4%);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
padding: 3px;
flex-shrink: 0;
}
.config-mode-option {
background: transparent;
color: var(--text-muted);
border: none;
border-radius: 9px;
padding: 8px 14px;
font-size: 12px;
font-weight: 700;
}
.config-mode-option.active {
background: var(--accent);
color: var(--button-text);
box-shadow: 0 8px 18px var(--accent-soft-strong);
}
.config-mode-option:not(.active):hover:not(:disabled) {
background: var(--surface-hover-strong);
color: var(--text);
}
.config-run-mode-row {
display: grid;
grid-template-columns: auto minmax(260px, 1fr) auto;
align-items: center;
gap: 14px;
margin-top: 16px;
}
.config-run-mode-row .config-field-hint {
margin-top: 0;
min-width: 0;
}
.config-mode-hint {
padding: 8px 12px;
border-radius: 10px;
background: var(--surface-hover);
}
.config-scenario-button {
flex-shrink: 0;
white-space: nowrap;
}
.config-run-options-row {
display: grid;
grid-template-columns: 80px minmax(120px, 1fr) minmax(260px, 1.2fr);
align-items: center;
gap: 12px;
margin-top: 14px;
}
.config-run-options-row label {
margin-bottom: 0;
min-width: 0;
color: var(--text-muted);
}
.config-number-input {
width: 100%;
max-width: 96px;
}
.config-run-options-row .config-field-hint {
margin-top: 0;
min-width: 0;
padding-left: 2px;
}
.btn-start {
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: var(--button-text);
font-weight: 800;
font-size: 14px;
padding: 11px 30px;
border-radius: 12px;
letter-spacing: 0.3px;
box-shadow: 0 12px 26px var(--accent-soft-strong);
}
.btn-start:hover:not(:disabled) {
opacity: 0.85;
}
.config-card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.config-card {
padding: 22px 24px;
}
.config-card-header {
margin-bottom: 14px;
}
.config-card-header h3 {
font-size: 18px;
color: var(--text);
margin-bottom: 6px;
}
.config-card-header p {
font-size: 12px;
color: var(--text-muted);
line-height: 1.6;
}
.config-field {
margin-top: 14px;
}
.config-field:first-child {
margin-top: 0;
}
.config-field label {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.config-field-hint {
margin-top: 6px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.6;
}
.config-task-textarea::placeholder,
.config-textarea::placeholder,
.config-input::placeholder,
.config-json-editor::placeholder {
color: color-mix(in srgb, var(--text-muted) 72%, transparent);
}
.config-fixed-mode {
margin-right: auto;
color: var(--text-muted);
font-size: 12px;
line-height: 1.5;
}
.config-agents,
.config-report-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.config-agent-card,
.config-report-card {
background: var(--surface-hover);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
}
.config-agents.readonly,
.config-report-grid.readonly {
margin-top: 10px;
}
.config-report-card p,
.config-agent-card p {
color: var(--text-muted);
font-size: 12px;
line-height: 1.65;
margin: 0;
}
.config-report-card pre,
.config-readonly-block pre {
margin: 12px 0 0;
max-height: 260px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-muted);
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
line-height: 1.55;
padding: 10px;
}
.config-readonly-block {
margin-top: 18px;
}
.config-readonly-block:first-child {
margin-top: 0;
}
.config-readonly-title {
color: var(--text);
font-size: 13px;
font-weight: 700;
margin-bottom: 10px;
}
.config-readonly-content {
color: var(--text-muted);
}
.config-readonly-meta {
margin-top: 8px !important;
color: var(--accent) !important;
}
.config-empty-detail {
color: var(--text-muted);
font-size: 13px;
padding: 12px 0 0;
}
.config-agent-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.config-agent-head span {
font-size: 14px;
font-weight: 700;
color: var(--text);
}
.config-agent-head code {
color: var(--accent);
background: var(--accent-soft);
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
}
.config-subgrid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.config-advanced-section {
overflow: hidden;
}
.config-advanced-toggle {
width: 100%;
background: transparent;
border: none;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 13px;
font-weight: 700;
cursor: pointer;
text-align: left;
border-radius: 0;
}
.config-advanced-toggle:hover {
color: var(--accent);
background: var(--surface-hover);
}
.config-advanced-chevron {
margin-left: auto;
transition: transform 0.2s;
}
.config-advanced-chevron.open {
transform: rotate(180deg);
}
.config-advanced-body {
padding: 0 20px 20px;
border-top: 1px solid var(--border);
}
@media (max-width: 900px) {
.config-task-section,
.config-card {
padding: 18px;
}
.config-run-mode-row,
.config-run-options-row {
grid-template-columns: 1fr;
align-items: stretch;
}
.config-mode-switch {
width: 100%;
}
.config-mode-option {
flex: 1;
}
.config-scenario-button,
.config-number-input {
width: 100%;
max-width: none;
}
.config-card-grid,
.config-agents,
.config-report-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,419 @@
import React, { useEffect, useMemo, useState } from 'react';
import { DEFAULT_BACKEND_URL, DEFAULT_CONFIG } from '@/config/defaultConfig';
import { clone, hasTemplateOverride, loadTemplateByMeta, loadTemplateIndex } from '@/config/templateStore';
import './ConfigPanel.css';
import { useChatContext } from '@/contexts/ChatContext';
function Card({ title, description, children }) {
return (
<section className="config-card">
<div className="config-card-header">
<h3>{title}</h3>
{description && <p>{description}</p>}
</div>
{children}
</section>
);
}
function ReadOnlyBlock({ title, children }) {
return (
<div className="config-readonly-block">
<div className="config-readonly-title">{title}</div>
<div className="config-readonly-content">{children}</div>
</div>
);
}
export default function ConfigPanel({ onStart, isRunning, onStop, onOpenScenarioConfig }) {
const { startConfig } = useChatContext();
const [taskContext, setTaskContext] = useState(startConfig?.task_context || '');
const backendUrl = DEFAULT_BACKEND_URL;
const [config, setConfig] = useState(() => clone(DEFAULT_CONFIG));
const [rawOpen, setRawOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [templates, setTemplates] = useState([]);
const [templateLoading, setTemplateLoading] = useState(false);
const [templateError, setTemplateError] = useState('');
const [llmPresets, setLlmPresets] = useState([]);
const [llmLoading, setLlmLoading] = useState(false);
const [llmError, setLlmError] = useState('');
const updateConfig = (updater) => {
setConfig(prev => {
const next = clone(prev);
updater(next);
return next;
});
};
const setCoordinatorMode = (mode) => {
updateConfig(next => {
next.coordinator_mode = mode;
});
};
const setDivergeRounds = (value) => {
const parsed = Number.parseInt(value, 10);
const rounds = Number.isFinite(parsed) ? Math.min(8, Math.max(1, parsed)) : 3;
updateConfig(next => {
next.phase_config = {
...(next.phase_config || {}),
diverge_rounds: rounds,
};
});
};
const setLlmPreset = (presetId) => {
updateConfig(next => {
next.llm_preset_id = presetId;
});
};
const setConsistencyMode = (mode) => {
updateConfig(next => {
next.consistency_mode = mode;
});
};
const handleSubmit = (e) => {
e.preventDefault();
if (!taskContext.trim() || !config.template_id) return;
console.log('------------------点击了')
onStart({
...config,
task_context: taskContext.trim(),
coordinator_mode: config.coordinator_mode || 'auto',
}, backendUrl);
};
const loadTemplates = async () => {
setTemplateLoading(true);
setTemplateError('');
try {
setTemplates(await loadTemplateIndex());
} catch (error) {
setTemplateError(error.message || '模板列表加载失败');
} finally {
setTemplateLoading(false);
}
};
const loadConfigOptions = async () => {
setLlmLoading(true);
setLlmError('');
try {
const response = await fetch(`${backendUrl}/config/options`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const presets = data.llm_presets || [];
setLlmPresets(presets);
updateConfig(next => {
if (!next.llm_preset_id) {
next.llm_preset_id = data.default_llm_preset_id || presets.find(item => item.is_default)?.id || '';
}
});
} catch (error) {
setLlmError(error.message || '模型配置加载失败');
} finally {
setLlmLoading(false);
}
};
const applyTemplate = async (templateId) => {
if (!templateId) return;
const meta = templates.find(item => item.template_id === templateId);
setTemplateLoading(true);
setTemplateError('');
try {
const template = await loadTemplateByMeta(meta || templateId);
updateConfig(next => {
next.template_id = template.template_id || templateId;
next.template_name = template.template_name || '';
next.template_brief = template.template_brief || '';
next.consistency_mode = template.consistency_mode || next.consistency_mode || 'free';
next.report_schema = clone(template.report_schema || {});
next.report_examples = clone(template.report_examples || {});
next.field_requirements = clone(template.field_requirements || {});
next.agents = clone(template.agents || next.agents || []);
next.coordinator_mode = next.coordinator_mode || 'auto';
next.coordinator_config = clone(template.coordinator_config || DEFAULT_CONFIG.coordinator_config);
next.agent_adaptation_policy = clone(template.agent_adaptation_policy || {});
});
} catch (error) {
setTemplateError(error.message || '模板加载失败');
} finally {
setTemplateLoading(false);
}
};
useEffect(() => {
if (!backendUrl) return;
loadTemplates();
loadConfigOptions();
}, [backendUrl]);
useEffect(() => {
if(startConfig) {
setConfig((pev) => ({
...pev,
template_id: startConfig.template_id,
}))
}
}, []);
const selectedLlmPreset = useMemo(
() => llmPresets.find(item => item.id === config.llm_preset_id) || null,
[llmPresets, config.llm_preset_id]
);
const rawConfig = useMemo(() => JSON.stringify({ ...config, task_context: '<在上方输入>' }, null, 2), [config]);
const canStart = taskContext.trim() && config.template_id;
return (
<div className="config-panel">
<form onSubmit={handleSubmit} className="config-layout">
<div className="config-task-section">
<label htmlFor="task-context">任务背景</label>
<textarea
id="task-context"
className="config-task-textarea"
value={taskContext}
onChange={e => setTaskContext(e.target.value)}
placeholder="请描述事件背景、传播目标、限制条件和你关心的判断标准。"
disabled={isRunning}
/>
<div className="config-run-mode-row">
<div className="config-mode-switch" role="radiogroup" aria-label="协调者模式">
<button
type="button"
className={`config-mode-option ${(config.coordinator_mode || 'auto') === 'auto' ? 'active' : ''}`}
onClick={() => setCoordinatorMode('auto')}
disabled={isRunning}
>
自动完成
</button>
<button
type="button"
className={`config-mode-option ${config.coordinator_mode === 'human_review' ? 'active' : ''}`}
onClick={() => setCoordinatorMode('human_review')}
disabled={isRunning}
>
人工逐轮确认
</button>
</div>
<div className="config-field-hint config-mode-hint">
自动完成会由协调者连续推进;人工逐轮确认会在每轮协调者提案后等待确认或修改。
</div>
<button
type="button"
className="btn-secondary config-scenario-button"
onClick={onOpenScenarioConfig}
disabled={isRunning}
>
场景配置
</button>
</div>
<div className="config-run-options-row">
<label htmlFor="diverge-rounds">讨论轮次</label>
<input
id="diverge-rounds"
className="config-input config-number-input"
type="number"
min="1"
max="8"
step="1"
value={config.phase_config?.diverge_rounds || 3}
onChange={e => setDivergeRounds(e.target.value)}
disabled={isRunning}
/>
<div className="config-field-hint">
控制协调者推进的中间讨论轮数;轮次越多,材料更充分但耗时更长。
</div>
</div>
<div className="config-run-options-row">
<label htmlFor="llm-preset">运行模型</label>
<select
id="llm-preset"
className="config-input"
value={config.llm_preset_id || ''}
onChange={e => setLlmPreset(e.target.value)}
disabled={isRunning || llmLoading || llmPresets.length === 0}
>
<option value="">选择模型...</option>
{llmPresets.map(preset => (
<option key={preset.id} value={preset.id}>
{preset.label}
{preset.is_default ? '(默认)' : ''}
</option>
))}
</select>
<div className="config-field-hint">
{selectedLlmPreset
? `${selectedLlmPreset.description || '后端预设模型'} · ${selectedLlmPreset.model_name}`
: '模型预设由后端 config.py 管理,前端只选择预设。'}
</div>
</div>
<div className="config-run-options-row">
<label htmlFor="consistency-mode">一致性模式</label>
<select
id="consistency-mode"
className="config-input"
value={config.consistency_mode || 'free'}
onChange={e => setConsistencyMode(e.target.value)}
disabled={isRunning}
>
<option value="free">free</option>
<option value="soft">soft</option>
</select>
<div className="config-field-hint">
{config.consistency_mode === 'soft'
? 'soft 会优先承接依赖字段、统一命名口径,并在最终成文时增强跨模块衔接。'
: 'free 只做基础一致性控制,适合依赖关系较弱或开放式的场景模板。'}
</div>
</div>
<div className="config-submit-row">
<div className="config-fixed-mode">报告结构、智能体和协调者策略由所选模板固定。</div>
{isRunning ? (
<button type="button" className="btn-danger" onClick={onStop}>停止分析</button>
) : (
<button type="submit" className="btn-start" disabled={!canStart}>
{config.coordinator_mode === 'human_review' ? '开始并逐轮确认' : '开始分析'}
</button>
)}
</div>
{!config.template_id && (
<div className="config-field-hint">开始前需要先选择一个报告模板。</div>
)}
{llmError && (
<div className="human-review-error">模型配置加载失败:{llmError}</div>
)}
</div>
<Card title="报告模板" description="选择后会自动固定报告字段、字段标准、参与智能体和协调者策略。">
<div className="config-submit-row">
<select
className="config-input"
value={config.template_id || ''}
onChange={e => applyTemplate(e.target.value)}
disabled={isRunning || templateLoading}
>
<option value="">选择模板...</option>
{templates.map(template => (
<option key={template.template_id} value={template.template_id}>
{template.template_name || template.template_id}
{hasTemplateOverride(template.template_id) ? '(已修改)' : ''}
</option>
))}
</select>
<button
type="button"
className="btn-primary"
onClick={loadTemplates}
disabled={isRunning || templateLoading}
>
{templateLoading ? '加载中' : '刷新模板'}
</button>
</div>
{config.template_name && (
<div className="config-field-hint">
当前模板:{config.template_name}
{config.template_brief ? ` · ${config.template_brief}` : ''}
</div>
)}
{templateError && (
<div className="human-review-error">模板加载失败:{templateError}</div>
)}
</Card>
<div className="config-field-hint">
运行模型预设由后端 `config.py` 管理;前端只选择预设 id,不接触密钥。
</div>
<div className="config-advanced-section">
<button
type="button"
className="config-advanced-toggle"
onClick={() => setDetailOpen(open => !open)}
>
<span>模板详情(只读)</span>
<span className={`config-advanced-chevron ${detailOpen ? 'open' : ''}`}>▾</span>
</button>
{detailOpen && (
<div className="config-advanced-body">
{!config.template_id ? (
<div className="config-empty-detail">选择模板后可查看字段、智能体和协调者策略。</div>
) : (
<>
<ReadOnlyBlock title="报告字段">
<div className="config-report-grid readonly">
{Object.entries(config.report_schema || {}).map(([field, desc]) => (
<div key={field} className="config-report-card">
<div className="config-agent-head">
<span>{field}</span>
</div>
<p>{desc}</p>
{config.field_requirements?.[field] && (
<pre>{JSON.stringify(config.field_requirements[field], null, 2)}</pre>
)}
</div>
))}
</div>
</ReadOnlyBlock>
<ReadOnlyBlock title="参与智能体">
<div className="config-agents readonly">
{(config.agents || []).map(agent => (
<div key={agent.id} className="config-agent-card">
<div className="config-agent-head">
<span>{agent.name}</span>
<code>{agent.id}</code>
</div>
<p className="config-readonly-meta">{agent.dimension}</p>
<p>{agent.expertise || agent.description || agent.skill}</p>
{(agent.relevant_fields || []).length > 0 && (
<p className="config-readonly-meta">关联字段:{agent.relevant_fields.join('、')}</p>
)}
</div>
))}
</div>
</ReadOnlyBlock>
<ReadOnlyBlock title="协调者策略">
<pre>{JSON.stringify(config.coordinator_config || {}, null, 2)}</pre>
</ReadOnlyBlock>
</>
)}
</div>
)}
</div>
<div className="config-advanced-section">
<button
type="button"
className="config-advanced-toggle"
onClick={() => setRawOpen(open => !open)}
>
<span>原始配置预览</span>
<span className={`config-advanced-chevron ${rawOpen ? 'open' : ''}`}>▾</span>
</button>
{rawOpen && (
<div className="config-advanced-body">
<textarea className="config-json-editor" value={rawConfig} readOnly spellCheck={false} />
</div>
)}
</div>
</form>
</div>
);
}
@@ -0,0 +1,167 @@
.final-report {
background: var(--surface-raised);
border: 1px solid var(--green);
border-radius: 16px;
overflow: hidden;
margin-top: 24px;
box-shadow: var(--shadow-card);
}
.final-report-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: rgba(63, 185, 80, 0.05);
}
.final-report-icon {
font-size: 20px;
}
.final-report-title {
font-size: 18px;
font-weight: 700;
color: var(--green);
}
.final-report-meta {
margin-left: auto;
font-size: 12px;
color: var(--text-muted);
}
/* Report sections */
.final-report-sections {
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.final-report-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.final-report-section-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--green);
padding-bottom: 6px;
border-bottom: 1px solid rgba(63, 185, 80, 0.2);
}
.final-report-section-content {
font-size: 14px;
color: var(--text);
}
.final-report-section-content .markdown-body {
font-size: 14px;
}
/* Dimensions section */
.final-report-dimensions-toggle {
width: 100%;
background: transparent;
border: none;
border-top: 1px solid var(--border);
padding: 12px 20px;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-align: left;
border-radius: 0;
transition: color 0.15s, background 0.15s;
}
.final-report-dimensions-toggle:hover {
color: var(--text);
background: var(--surface-hover);
}
.final-report-dimensions-chevron {
margin-left: auto;
transition: transform 0.2s;
}
.final-report-dimensions-chevron.open {
transform: rotate(180deg);
}
.final-report-dimensions-body {
padding: 16px 20px 20px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 20px;
}
.dim-detail {
display: flex;
flex-direction: column;
gap: 8px;
}
.dim-detail-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent);
display: flex;
align-items: center;
gap: 8px;
}
.dim-detail-sublabel {
font-size: 11px;
color: var(--text-muted);
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.dim-detail-analysis {
font-size: 13px;
color: var(--text);
}
.dim-detail-output {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
padding: 8px 12px;
background: var(--surface-hover);
border-radius: 6px;
border-left: 2px solid var(--accent);
}
.dim-detail-output-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.dim-detail-output-key {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.dim-detail-output-value {
font-size: 12px;
color: var(--text);
}
@@ -0,0 +1,98 @@
import React, { useState } from 'react';
import './FinalReport.css';
import MarkdownContent from './MarkdownContent';
const REPORT_FIELD_LABELS: Record<string, string> = {
core_strategy: '核心策略',
action_plan: '行动计划',
key_messages: '核心话术',
risk_warnings: '风险预警',
};
type Props = {
output?: any;
reportSchema?: Record<string, string>;
};
export default function FinalReport({ output, reportSchema }: Props): JSX.Element | null {
const [dimOpen, setDimOpen] = useState(false);
if (!output) return null;
const { report, dimensions, meta } = output;
const reportKeys = reportSchema && Object.keys(reportSchema).length > 0
? Object.keys(reportSchema)
: (report ? Object.keys(report) : []);
return (
<div className="final-report">
<div className="final-report-header">
<span className="final-report-icon">✦</span>
<span className="final-report-title">最终方案</span>
{meta && meta.total_rounds != null && (
<span className="final-report-meta">共 {meta.total_rounds} 轮分析</span>
)}
</div>
{report && reportKeys.length > 0 && (
<div className="final-report-sections">
{reportKeys.map(key => {
const content = report[key];
if (content == null) return null;
const label = (reportSchema && reportSchema[key])
? reportSchema[key]
: (REPORT_FIELD_LABELS[key] || key);
return (
<div key={key} className="final-report-section">
<div className="final-report-section-label">{label}</div>
<div className="final-report-section-content">
<MarkdownContent
content={typeof content === 'string' ? content : JSON.stringify(content, null, 2)}
/>
</div>
</div>
);
})}
</div>
)}
{dimensions && Object.keys(dimensions).length > 0 && (
<>
<button
className="final-report-dimensions-toggle"
onClick={() => setDimOpen(o => !o)}
>
<span>维度详情</span>
<span className={`final-report-dimensions-chevron ${dimOpen ? 'open' : ''}`}>▾</span>
</button>
{dimOpen && (
<div className="final-report-dimensions-body">
{Object.entries(dimensions).map(([dim, dimData]: [string, any]) => (
<div key={dim} className="dim-detail">
<div className="dim-detail-label">
<span>{dim}</span>
{typeof dimData === 'object' && dimData?.label && (
<span className="dim-detail-sublabel">{dimData.label}</span>
)}
</div>
{typeof dimData === 'string' && (
<div className="dim-detail-analysis">
<MarkdownContent content={dimData} />
</div>
)}
{typeof dimData === 'object' && dimData?.latest_analysis && (
<div className="dim-detail-analysis">
<MarkdownContent content={dimData.latest_analysis} />
</div>
)}
</div>
))}
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,131 @@
.markdown-body {
line-height: 1.7;
color: var(--text-primary);
word-break: break-word;
}
.markdown-body > *:first-child { margin-top: 0; }
.markdown-body > *:last-child { margin-bottom: 0; }
/* Headings */
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin: 1em 0 0.4em;
font-weight: 600;
line-height: 1.3;
color: var(--text-primary);
}
.markdown-body h1 { font-size: 1.3em; border-bottom: 1px solid var(--border-color); padding-bottom: 0.3em; }
.markdown-body h2 { font-size: 1.15em; border-bottom: 1px solid var(--border-color); padding-bottom: 0.2em; }
.markdown-body h3 { font-size: 1.05em; }
.markdown-body h4 { font-size: 0.95em; }
/* Paragraphs */
.markdown-body p {
margin: 0.5em 0;
}
/* Lists */
.markdown-body ul,
.markdown-body ol {
margin: 0.5em 0;
padding-left: 1.6em;
}
.markdown-body li {
margin: 0.25em 0;
}
.markdown-body li > ul,
.markdown-body li > ol {
margin: 0.2em 0;
}
/* Bold & italic */
.markdown-body strong { font-weight: 600; color: var(--text-primary); }
.markdown-body em { font-style: italic; color: var(--text-secondary); }
/* Inline code */
.markdown-body code {
background: rgba(110, 118, 129, 0.15);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.1em 0.4em;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.875em;
color: var(--red);
}
/* Code blocks */
.markdown-body pre {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1em;
overflow-x: auto;
margin: 0.6em 0;
}
.markdown-body pre code {
background: none;
border: none;
padding: 0;
font-size: 0.85em;
color: var(--text-primary);
}
/* Blockquote */
.markdown-body blockquote {
border-left: 3px solid var(--accent-blue);
margin: 0.5em 0;
padding: 0.3em 0.8em;
color: var(--text-secondary);
background: rgba(88, 166, 255, 0.05);
border-radius: 0 4px 4px 0;
}
.markdown-body blockquote p { margin: 0; }
/* Horizontal rule */
.markdown-body hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 1em 0;
}
/* Links */
.markdown-body a {
color: var(--accent-blue);
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
/* Tables (remark-gfm) */
.markdown-body table {
border-collapse: collapse;
width: 100%;
margin: 0.6em 0;
font-size: 0.9em;
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--border-color);
padding: 0.4em 0.7em;
text-align: left;
}
.markdown-body th {
background: var(--bg-secondary);
font-weight: 600;
color: var(--text-primary);
}
.markdown-body tr:nth-child(even) td {
background: var(--surface-hover);
}
/* Task lists (remark-gfm) */
.markdown-body input[type='checkbox'] {
margin-right: 0.4em;
accent-color: var(--accent-blue);
}
@@ -0,0 +1,68 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import './MarkdownContent.css';
type Props = {
content?: string;
referenceMap?: Record<string, string>;
className?: string;
};
function rehypeHighlightRefs(referenceMap?: Record<string, string>) {
return function (tree: any) {
function walk(node: any) {
if (!node.children) return;
const next = [];
for (const child of node.children) {
if (child.type === 'text') {
const parts = child.value.split(/(\[\d+\])/g);
if (parts.length > 1) {
for (const part of parts) {
if (!part) continue;
const m = part.match(/^\[(\d+)\]$/);
if (m) {
const num = m[1];
const itemId = referenceMap?.[num];
next.push({
type: 'element',
tagName: 'span',
properties: {
className: 'ref-cite',
'data-tooltip': itemId ? `ID: ${itemId}` : `引用 ${num}`,
},
children: [{ type: 'text', value: part }],
});
} else {
next.push({ type: 'text', value: part });
}
}
} else {
next.push(child);
}
} else {
walk(child);
next.push(child);
}
}
node.children = next;
}
walk(tree);
};
}
export default function MarkdownContent({ content, referenceMap, className = '' }: Props): JSX.Element | null {
if (!content) return null;
const rehypePlugins = [() => rehypeHighlightRefs(referenceMap)];
return (
<div className={`markdown-body ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={rehypePlugins}
>
{content}
</ReactMarkdown>
</div>
);
}
@@ -0,0 +1,123 @@
.progress-sidebar {
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.progress-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px;
box-shadow: var(--shadow-card);
}
.progress-title {
font-size: 12px;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
.progress-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.progress-phase,
.progress-status,
.progress-text,
.field-current,
.empty-hint,
.memory-subtitle,
.field-memory-status {
font-size: 11px;
color: var(--text-muted);
}
.progress-line {
height: 6px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--border);
overflow: hidden;
}
.progress-line-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--green));
transition: width 0.25s ease;
}
.progress-text,
.field-current {
margin-top: 8px;
}
.field-current {
color: var(--accent);
}
.memory-list,
.field-memory-list,
.agent-memory-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.memory-item,
.agent-memory-card,
.field-memory-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px;
}
.memory-item {
font-size: 11px;
color: var(--text-muted);
line-height: 1.55;
}
.memory-subtitle {
margin: 8px 0 6px;
}
.field-memory-card.status-filled {
border-left: 2px solid var(--green);
}
.field-memory-card.status-partial {
border-left: 2px solid var(--yellow);
}
.field-memory-card.status-blocked {
border-left: 2px solid var(--red);
}
.field-memory-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.field-memory-title,
.agent-memory-name {
font-size: 12px;
font-weight: 700;
color: var(--text);
}
.agent-memory-position {
font-size: 11px;
color: var(--text);
line-height: 1.55;
}
@@ -0,0 +1,141 @@
import './ProgressSidebar.css';
type MemoryListProps = {
items?: any[];
emptyText: string;
};
type Props = {
phase?: string;
status?: string;
currentRound?: number;
divergeRounds?: number;
reportSchema?: Record<string, any>;
sharedMemory?: Record<string, any>;
finalReportPartial?: Record<string, string>;
currentFinalField?: string;
};
function MemoryList({ items, emptyText }: MemoryListProps): JSX.Element {
if (!items || items.length === 0) {
return <div className="empty-hint">{emptyText}</div>;
}
return (
<div className="memory-list">
{items.map((item, index) => (
<div key={`${index}-${String(item).slice(0, 20)}`} className="memory-item">{item}</div>
))}
</div>
);
}
export default function ProgressSidebar({
phase,
status,
currentRound,
divergeRounds,
reportSchema,
sharedMemory,
finalReportPartial,
currentFinalField,
}: Props): JSX.Element {
const fields = Object.keys(reportSchema || {});
const finalFilled = fields.filter(field => (finalReportPartial?.[field] || '').trim()).length;
const progressPercent = fields.length > 0 ? Math.round((finalFilled / fields.length) * 100) : 0;
const fieldMemoryEntries = Object.entries(sharedMemory?.field_memory || {});
const agentMemoryEntries = Object.entries(sharedMemory?.agent_memory || {});
const displayStatus = status || '等待中';
return (
<div className="progress-sidebar">
<div className="progress-card">
<div className="progress-title">方案进度</div>
<div className="progress-meta">
<span className={`progress-phase phase-${phase || 'starting'}`}>{phase || 'starting'}</span>
<span className="progress-status">{displayStatus}</span>
</div>
<div className="progress-line">
<div className="progress-line-fill" style={{ width: `${progressPercent}%` }} />
</div>
<div className="progress-text">第 {currentRound || 0} 轮 / 发散 {divergeRounds || 0} 轮</div>
<div className="progress-text">最终报告已生成字段 {finalFilled}/{fields.length || 0}</div>
{currentFinalField && <div className="field-current">当前字段:{currentFinalField}</div>}
</div>
<div className="progress-card">
<div className="progress-title">稳定判断</div>
<MemoryList items={sharedMemory?.stable_claims || []} emptyText="尚未形成稳定判断" />
</div>
<div className="progress-card">
<div className="progress-title">关键依据</div>
<MemoryList items={sharedMemory?.key_evidence || []} emptyText="尚未沉淀关键依据" />
</div>
<div className="progress-card">
<div className="progress-title">冲突与待核</div>
<MemoryList items={sharedMemory?.conflicts || []} emptyText="当前没有显式冲突" />
<div className="memory-subtitle">待核问题</div>
<MemoryList items={sharedMemory?.open_questions || []} emptyText="当前没有待核问题" />
</div>
<div className="progress-card">
<div className="progress-title">字段记忆</div>
{fieldMemoryEntries.length === 0 ? (
<div className="empty-hint">尚未形成字段记忆</div>
) : (
<div className="field-memory-list">
{fieldMemoryEntries.map(([field, item]: [string, any]) => (
<div key={field} className={`field-memory-card status-${item.status || 'partial'}`}>
<div className="field-memory-head">
<span className="field-memory-title">{item.title || field}</span>
<span className="field-memory-status">{item.status || 'partial'}</span>
</div>
{item.current_takeaways?.length > 0 && (
<>
<div className="memory-subtitle">当前判断</div>
<MemoryList items={item.current_takeaways} emptyText="" />
</>
)}
{item.missing_points?.length > 0 && (
<>
<div className="memory-subtitle">待补内容</div>
<MemoryList items={item.missing_points} emptyText="" />
</>
)}
</div>
))}
</div>
)}
</div>
<div className="progress-card">
<div className="progress-title">角色记忆</div>
{agentMemoryEntries.length === 0 ? (
<div className="empty-hint">尚未形成角色记忆</div>
) : (
<div className="agent-memory-list">
{agentMemoryEntries.map(([agentId, item]: [string, any]) => (
<div key={agentId} className="agent-memory-card">
<div className="agent-memory-name">{item.name || agentId}</div>
{item.latest_position && <div className="agent-memory-position">{item.latest_position}</div>}
{item.useful_findings?.length > 0 && (
<>
<div className="memory-subtitle">有效发现</div>
<MemoryList items={item.useful_findings} emptyText="" />
</>
)}
{item.open_issues?.length > 0 && (
<>
<div className="memory-subtitle">待继续澄清</div>
<MemoryList items={item.open_issues} emptyText="" />
</>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,337 @@
.scenario-page {
width: 100%;
max-width: 1280px;
min-height: 100vh;
padding: 28px;
}
.scenario-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
margin-bottom: 18px;
}
.scenario-kicker {
color: var(--accent);
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
.scenario-header h1 {
color: var(--text);
font-size: 30px;
margin: 4px 0 6px;
}
.scenario-header p,
.scenario-card-desc {
color: var(--text-muted);
font-size: 13px;
}
.scenario-header-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.scenario-toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px;
margin-bottom: 14px;
}
.scenario-select,
.scenario-input,
.scenario-textarea,
.scenario-json {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: none;
}
.scenario-select {
min-width: 280px;
padding: 9px 12px;
flex: 1;
}
.scenario-select-inline {
min-width: 0;
width: 100%;
flex: 0 0 auto;
margin-top: 6px;
}
.scenario-input {
width: 100%;
padding: 10px 12px;
}
.scenario-textarea,
.scenario-json {
width: 100%;
padding: 12px;
line-height: 1.65;
resize: vertical;
}
.scenario-textarea {
min-height: 120px;
}
.scenario-textarea-sm {
min-height: 80px;
}
.scenario-textarea-lg {
min-height: 180px;
}
.scenario-json {
min-height: 460px;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 12px;
}
.scenario-select:focus,
.scenario-input:focus,
.scenario-textarea:focus,
.scenario-json:focus {
border-color: var(--accent);
}
.scenario-message,
.scenario-error,
.scenario-empty {
border-radius: 10px;
padding: 10px 14px;
margin-bottom: 14px;
font-size: 13px;
}
.scenario-message {
background: rgba(63, 185, 80, 0.1);
border: 1px solid rgba(63, 185, 80, 0.45);
color: var(--green);
}
.scenario-error {
background: rgba(248, 81, 73, 0.1);
border: 1px solid rgba(248, 81, 73, 0.45);
color: var(--red);
}
.scenario-empty {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-muted);
}
.scenario-layout {
display: flex;
flex-direction: column;
gap: 16px;
}
.scenario-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 18px;
}
.scenario-card-title {
color: var(--text);
font-size: 16px;
font-weight: 700;
margin-bottom: 12px;
}
.scenario-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 14px;
}
.scenario-card-head .scenario-card-title {
margin-bottom: 2px;
}
.scenario-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.scenario-card label {
display: block;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
margin-top: 14px;
}
.scenario-card label:first-child {
margin-top: 0;
}
.scenario-card label > input,
.scenario-card label > textarea {
margin-top: 6px;
}
.scenario-inline-hint {
margin-top: 6px;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
line-height: 1.55;
}
.scenario-field-key {
color: var(--accent);
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
font-weight: 500;
margin-left: 8px;
}
.scenario-coordinator-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.scenario-agent-layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 16px;
}
.scenario-agent-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.scenario-agent-tab {
background: var(--card);
border: 1px solid var(--border);
color: var(--text-muted);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 10px 12px;
text-align: left;
}
.scenario-agent-tab.active {
border-color: var(--accent);
background: rgba(88, 166, 255, 0.1);
color: var(--text);
}
.scenario-agent-tab span {
font-weight: 700;
}
.scenario-agent-tab code {
color: var(--accent);
font-size: 11px;
}
.scenario-agent-editor {
background: rgba(255,255,255,0.02);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
}
.scenario-field-picker {
margin-top: 6px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.scenario-field-option {
display: flex !important;
gap: 10px;
align-items: flex-start;
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
margin-top: 0 !important;
cursor: pointer;
}
.scenario-field-option.selected {
border-color: var(--accent);
background: rgba(88, 166, 255, 0.08);
}
.scenario-field-option input[type='checkbox'] {
margin-top: 2px;
accent-color: var(--accent);
}
.scenario-field-option-body {
min-width: 0;
}
.scenario-field-option-title {
color: var(--text);
font-size: 12px;
font-weight: 700;
}
.scenario-field-option-key {
color: var(--accent);
font-size: 11px;
font-family: 'SFMono-Regular', Consolas, monospace;
margin-top: 2px;
}
.scenario-field-option-desc {
color: var(--text-muted);
font-size: 11px;
line-height: 1.5;
margin-top: 5px;
}
@media (max-width: 960px) {
.scenario-page {
padding: 18px;
}
.scenario-header,
.scenario-card-head {
flex-direction: column;
}
.scenario-grid-2,
.scenario-coordinator-grid,
.scenario-agent-layout,
.scenario-field-picker {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,416 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
clearTemplateOverride,
clone,
hasTemplateOverride,
loadOriginalTemplate,
loadTemplateByMeta,
loadTemplateIndex,
saveTemplateOverride,
} from '@/config/templateStore';
import './ScenarioConfigPage.css';
const COORDINATOR_FIELDS = [
['system_prompt_prefix', '协调者全局规则'],
['round1_guidance', '首轮策略'],
['middle_round_guidance', '中间轮策略'],
['final_round_guidance', '末轮策略'],
['speaker_selection_rules', '选人规则'],
['focus_instruction_rules', '任务指令规则'],
['dependency_driving_rules', '依赖推进规则'],
['consistency_check_rules', '一致性/冲突检查规则'],
['revise_prompt_prefix', '人工修改解释规则'],
];
function updateAtIndex(items, index, updater) {
return items.map((item, current) => current === index ? updater(clone(item)) : item);
}
function linesToList(value) {
return value
.split('\n')
.map(item => item.trim())
.filter(Boolean);
}
export default function ScenarioConfigPage({ onBack }) {
const [templates, setTemplates] = useState([]);
const [selectedId, setSelectedId] = useState('');
const [draft, setDraft] = useState(null);
const [rawJson, setRawJson] = useState('');
const [activeAgentIndex, setActiveAgentIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const selectedMeta = useMemo(
() => templates.find(item => item.template_id === selectedId),
[templates, selectedId],
);
const fieldOptions = useMemo(() => {
const schema = draft?.report_schema || {};
const requirements = draft?.field_requirements || {};
return Object.keys(schema).map(field => ({
key: field,
title: requirements?.[field]?.title || field,
description: schema[field] || '',
}));
}, [draft]);
const loadTemplates = async () => {
setLoading(true);
setError('');
try {
const list = await loadTemplateIndex();
setTemplates(list);
if (!selectedId && list[0]) {
setSelectedId(list[0].template_id);
}
} catch (err) {
setError(err.message || '模板列表加载失败');
} finally {
setLoading(false);
}
};
const loadTemplate = async (templateId) => {
if (!templateId) return;
const meta = templates.find(item => item.template_id === templateId) || templateId;
setLoading(true);
setError('');
setMessage('');
try {
const template = await loadTemplateByMeta(meta);
setDraft(template);
setRawJson(JSON.stringify(template, null, 2));
setActiveAgentIndex(0);
} catch (err) {
setError(err.message || '模板加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTemplates();
}, []);
useEffect(() => {
if (selectedId && templates.length > 0) {
loadTemplate(selectedId);
}
}, [selectedId, templates.length]);
const syncDraft = (updater) => {
setDraft(prev => {
const next = clone(prev);
updater(next);
setRawJson(JSON.stringify(next, null, 2));
return next;
});
setMessage('');
};
const setCoordinatorField = (key, value) => {
syncDraft(next => {
next.coordinator_config = {
...(next.coordinator_config || {}),
[key]: value,
};
});
};
const setAgentField = (index, key, value) => {
syncDraft(next => {
next.agents = updateAtIndex(next.agents || [], index, agent => ({
...agent,
[key]: value,
}));
});
};
const applyRawJson = () => {
setError('');
setMessage('');
try {
const parsed = JSON.parse(rawJson);
if (!parsed.template_id) throw new Error('JSON 中缺少 template_id');
setDraft(parsed);
setSelectedId(parsed.template_id);
setMessage('完整 JSON 已应用,保存后生效。');
} catch (err) {
setError(err.message || 'JSON 解析失败');
}
};
const saveDraft = () => {
if (!draft?.template_id) return;
try {
saveTemplateOverride(draft.template_id, draft);
setMessage('已保存到浏览器本地覆盖。返回首页选择该模板即可使用修改后的提示。');
setError('');
} catch (err) {
setError(err.message || '保存失败');
}
};
const resetTemplate = async () => {
if (!selectedId) return;
setLoading(true);
setMessage('');
setError('');
try {
clearTemplateOverride(selectedId);
const original = await loadOriginalTemplate(selectedMeta || selectedId);
setDraft(original);
setRawJson(JSON.stringify(original, null, 2));
setMessage('已恢复为静态模板原始内容。');
} catch (err) {
setError(err.message || '恢复失败');
} finally {
setLoading(false);
}
};
const activeAgent = draft?.agents?.[activeAgentIndex];
const activeRelevantFields = new Set(activeAgent?.relevant_fields || []);
return (
<div className="scenario-page">
<header className="scenario-header">
<div>
<div className="scenario-kicker">场景配置</div>
<h1>模板提示资产</h1>
<p>查看和修改场景模板对应的协调者提示、推荐智能体配置及智能体提示。新增模板暂不支持。</p>
</div>
<div className="scenario-header-actions">
<button type="button" className="btn-secondary" onClick={onBack}>返回首页</button>
</div>
</header>
<section className="scenario-toolbar">
<select
className="scenario-select"
value={selectedId}
onChange={event => setSelectedId(event.target.value)}
disabled={loading}
>
<option value="">选择场景模板...</option>
{templates.map(template => (
<option key={template.template_id} value={template.template_id}>
{template.template_name || template.template_id}
{hasTemplateOverride(template.template_id) ? '(已修改)' : ''}
</option>
))}
</select>
<button type="button" className="btn-primary" disabled>新增模板(暂不支持)</button>
<button type="button" className="btn-secondary" onClick={loadTemplates} disabled={loading}>
刷新
</button>
<button type="button" className="btn-secondary" onClick={resetTemplate} disabled={!draft || loading}>
恢复原始模板
</button>
<button type="button" className="btn-primary" onClick={saveDraft} disabled={!draft || loading}>
保存修改
</button>
</section>
{message && <div className="scenario-message">{message}</div>}
{error && <div className="scenario-error">{error}</div>}
{!draft ? (
<div className="scenario-empty">请选择一个模板。</div>
) : (
<main className="scenario-layout">
<section className="scenario-card scenario-template-card">
<div className="scenario-card-title">模板信息</div>
<div className="scenario-grid-2">
<label>
模板 ID
<input className="scenario-input" value={draft.template_id || ''} readOnly />
</label>
<label>
模板名称
<input
className="scenario-input"
value={draft.template_name || ''}
onChange={event => syncDraft(next => { next.template_name = event.target.value; })}
/>
</label>
</div>
<label>
模板说明
<textarea
className="scenario-textarea scenario-textarea-sm"
value={draft.template_brief || ''}
onChange={event => syncDraft(next => { next.template_brief = event.target.value; })}
/>
</label>
<label>
一致性模式
<select
className="scenario-select scenario-select-inline"
value={draft.consistency_mode || 'free'}
onChange={event => syncDraft(next => { next.consistency_mode = event.target.value; })}
>
<option value="free">free</option>
<option value="soft">soft</option>
</select>
<div className="scenario-inline-hint">
`free` 适合开放式模板;`soft` 会要求后续字段尽量承接前文依赖和命名。
</div>
</label>
{draft.usage_notes && (
<label>
使用说明
<textarea
className="scenario-textarea"
value={draft.usage_notes || ''}
onChange={event => syncDraft(next => { next.usage_notes = event.target.value; })}
/>
</label>
)}
</section>
<section className="scenario-card">
<div className="scenario-card-title">协调者提示</div>
<div className="scenario-coordinator-grid">
{COORDINATOR_FIELDS.map(([key, label]) => (
<label key={key}>
{label}
<span className="scenario-field-key">{key}</span>
<textarea
className="scenario-textarea"
value={draft.coordinator_config?.[key] || ''}
onChange={event => setCoordinatorField(key, event.target.value)}
/>
</label>
))}
</div>
</section>
<section className="scenario-card">
<div className="scenario-card-head">
<div>
<div className="scenario-card-title">推荐智能体及提示</div>
<div className="scenario-card-desc">当前支持修改已有智能体,不支持新增或删除。</div>
</div>
<button type="button" className="btn-primary" disabled>新增智能体(暂不支持)</button>
</div>
<div className="scenario-agent-layout">
<div className="scenario-agent-list">
{(draft.agents || []).map((agent, index) => (
<button
type="button"
key={agent.id || index}
className={`scenario-agent-tab ${index === activeAgentIndex ? 'active' : ''}`}
onClick={() => setActiveAgentIndex(index)}
>
<span>{agent.name || agent.id || `agent-${index + 1}`}</span>
<code>{agent.id}</code>
</button>
))}
</div>
{activeAgent && (
<div className="scenario-agent-editor">
<div className="scenario-grid-2">
<label>
ID
<input className="scenario-input" value={activeAgent.id || ''} readOnly />
</label>
<label>
名称
<input
className="scenario-input"
value={activeAgent.name || ''}
onChange={event => setAgentField(activeAgentIndex, 'name', event.target.value)}
/>
</label>
</div>
<label>
分析维度
<input
className="scenario-input"
value={activeAgent.dimension || ''}
onChange={event => setAgentField(activeAgentIndex, 'dimension', event.target.value)}
/>
</label>
<label>
智能体提示 / 专家角色
<textarea
className="scenario-textarea scenario-textarea-lg"
value={activeAgent.expertise || ''}
onChange={event => setAgentField(activeAgentIndex, 'expertise', event.target.value)}
/>
</label>
<label>
分析契约(每行一条)
<textarea
className="scenario-textarea"
value={(activeAgent.analysis_contract || []).join('\n')}
onChange={event => setAgentField(activeAgentIndex, 'analysis_contract', linesToList(event.target.value))}
/>
</label>
<label>
关联字段
<div className="scenario-field-picker">
{fieldOptions.map(option => (
<label key={option.key} className={`scenario-field-option ${activeRelevantFields.has(option.key) ? 'selected' : ''}`}>
<input
type="checkbox"
checked={activeRelevantFields.has(option.key)}
onChange={event => {
const current = activeAgent.relevant_fields || [];
const next = event.target.checked
? [...current, option.key]
: current.filter(item => item !== option.key);
setAgentField(activeAgentIndex, 'relevant_fields', next);
}}
/>
<div className="scenario-field-option-body">
<div className="scenario-field-option-title">{option.title}</div>
<div className="scenario-field-option-key">{option.key}</div>
{option.description && (
<div className="scenario-field-option-desc">{option.description}</div>
)}
</div>
</label>
))}
</div>
</label>
<label>
检索补充说明
<textarea
className="scenario-textarea scenario-textarea-sm"
value={activeAgent.retrieval_instruction || ''}
onChange={event => setAgentField(activeAgentIndex, 'retrieval_instruction', event.target.value)}
/>
</label>
</div>
)}
</div>
</section>
<section className="scenario-card">
<div className="scenario-card-head">
<div>
<div className="scenario-card-title">完整模板 JSON</div>
<div className="scenario-card-desc">用于修改页面暂未拆出的提示资产,例如字段要求、样例和报告结构。</div>
</div>
<button type="button" className="btn-secondary" onClick={applyRawJson}>应用 JSON</button>
</div>
<textarea
className="scenario-json"
value={rawJson}
onChange={event => setRawJson(event.target.value)}
spellCheck={false}
/>
</section>
</main>
)}
</div>
);
}
@@ -0,0 +1,238 @@
.sidebar {
padding: 18px 14px;
}
.sidebar-section {
margin-bottom: 24px;
}
.sidebar-section-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 12px;
padding: 0 4px;
}
/* Phase stepper */
.phase-stepper {
display: flex;
flex-direction: column;
gap: 0;
}
.phase-step {
display: flex;
align-items: flex-start;
gap: 10px;
position: relative;
}
.phase-step:not(:last-child) .phase-step-line {
position: absolute;
left: 11px;
top: 24px;
bottom: 0;
width: 2px;
background: var(--border);
}
.phase-step-dot-col {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
padding-top: 3px;
}
.phase-step-dot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--border);
background: var(--surface);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
transition: all 0.2s;
position: relative;
z-index: 1;
}
.phase-step.active .phase-step-dot {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--accent);
}
.phase-step.active.phase-diverging .phase-step-dot {
border-color: var(--yellow);
background: rgba(210, 153, 34, 0.15);
color: var(--yellow);
}
.phase-step.active.phase-closing .phase-step-dot {
border-color: var(--green);
background: rgba(63, 185, 80, 0.15);
color: var(--green);
}
.phase-step.done .phase-step-dot {
border-color: var(--green);
background: rgba(63, 185, 80, 0.15);
color: var(--green);
}
.phase-step-content {
flex: 1;
padding-bottom: 16px;
}
.phase-step-label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
line-height: 22px;
transition: color 0.2s;
}
.phase-step.active .phase-step-label {
color: var(--text);
}
.phase-step.done .phase-step-label {
color: var(--text-muted);
}
.phase-step-sublabel {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
/* Diverge round sub-steps */
.diverge-rounds {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.diverge-round-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-muted);
padding: 2px 0;
}
.diverge-round-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--border);
flex-shrink: 0;
}
.diverge-round-item.active .diverge-round-dot {
background: var(--yellow);
box-shadow: 0 0 6px rgba(210, 153, 34, 0.5);
}
.diverge-round-item.done .diverge-round-dot {
background: var(--green);
}
.diverge-round-item.active {
color: var(--yellow);
}
.diverge-round-item.done {
color: var(--text-muted);
}
/* Connector line between phase steps */
.phase-connector {
width: 2px;
height: 8px;
background: var(--border);
margin-left: 10px;
}
/* Agent roster */
.agent-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.agent-item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px;
border-radius: 10px;
transition: background 0.15s, color 0.15s, transform 0.15s;
background: transparent;
}
.agent-item-clickable {
cursor: pointer;
}
.agent-item-clickable:hover {
background: var(--surface-raised);
transform: translateX(2px);
}
.agent-item-active {
background: var(--accent-soft);
border-left: 2px solid var(--accent);
padding-left: 6px;
}
.agent-status-icon {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
flex-shrink: 0;
}
.agent-status-idle {
color: var(--border);
}
.agent-status-thinking {
color: var(--yellow);
}
.agent-status-done {
color: var(--green);
}
.agent-item-info {
flex: 1;
overflow: hidden;
}
.agent-item-name {
font-size: 12px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-item-dim {
font-size: 10px;
color: var(--text-muted);
}
@@ -0,0 +1,174 @@
import './Sidebar.css';
type Agent = {
id: string;
name?: string;
dimension?: string;
};
type AgentState = {
status?: string;
name?: string;
dimension?: string;
};
type Props = {
agents: Agent[];
agentStates: Record<string, AgentState>;
phase: string;
currentRound: number;
divergeRounds: number;
};
const PHASES = [
{ id: 'starting', label: '启动', dotLabel: '1' },
{ id: 'diverging', label: '辩论', dotLabel: '2' },
{ id: 'closing', label: '收敛', dotLabel: '3' },
{ id: 'done', label: '完成', dotLabel: '✓' },
];
const PHASE_ORDER = ['starting', 'diverging', 'closing', 'done'];
function getPhaseIndex(phase: string): number {
return PHASE_ORDER.indexOf(phase);
}
function scrollToTimelineElement(selector: string): void {
const appMain = document.querySelector('.app-main') as HTMLElement | null;
if (!appMain) return;
const el = appMain.querySelector(selector) as HTMLElement | null;
if (!el) return;
const targetRect = el.getBoundingClientRect();
const containerRect = appMain.getBoundingClientRect();
const targetScrollTop = appMain.scrollTop + targetRect.top - containerRect.top - 20;
(window as any).__mopeProgrammaticScroll = ((window as any).__mopeProgrammaticScroll || 0) + 1;
appMain.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
}
function scrollToDivergeRound(roundNum: number): void {
const appMain = document.querySelector('.app-main') as HTMLElement | null;
if (!appMain) return;
const divergeRounds = appMain.querySelectorAll('[data-phase="diverging"][data-round]');
const target = divergeRounds[roundNum - 1] as HTMLElement | undefined;
if (!target) return;
const targetRect = target.getBoundingClientRect();
const containerRect = appMain.getBoundingClientRect();
const targetScrollTop = appMain.scrollTop + targetRect.top - containerRect.top - 20;
(window as any).__mopeProgrammaticScroll = ((window as any).__mopeProgrammaticScroll || 0) + 1;
appMain.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
}
export default function Sidebar({
agents,
agentStates,
phase,
currentRound,
divergeRounds,
}: Props): JSX.Element {
const currentPhaseIdx = getPhaseIndex(phase);
return (
<div className="sidebar">
<div className="sidebar-section">
<div className="sidebar-section-title">进度</div>
<div className="phase-stepper">
{PHASES.map((p, idx) => {
const isActive = phase === p.id;
const isDone = currentPhaseIdx > idx;
const stepClass = `phase-step phase-${p.id}${isActive ? ' active' : ''}${isDone ? ' done' : ''}`;
return (
<div
key={p.id}
className={stepClass}
onClick={() => scrollToTimelineElement(`[data-phase="${p.id}"]`)}
title={`点击跳转到${p.label}阶段`}
style={{ cursor: 'pointer' }}
>
{idx < PHASES.length - 1 && <div className="phase-step-line" />}
<div className="phase-step-dot-col">
<div className="phase-step-dot">
{isDone ? '✓' : p.dotLabel}
</div>
</div>
<div className="phase-step-content">
<div className="phase-step-label">{p.label}</div>
{p.id === 'diverging' && (isDone || isActive) && divergeRounds > 0 && (
<div className="diverge-rounds">
{Array.from({ length: divergeRounds }, (_, i) => {
const roundNum = i + 1;
const isActiveDiverge = isActive && roundNum === (currentRound - 1);
const isDoneDiverge = isDone || (isActive && (currentRound - 1) > roundNum);
const itemClass = `diverge-round-item${isActiveDiverge ? ' active' : ''}${isDoneDiverge ? ' done' : ''}`;
return (
<div
key={roundNum}
className={itemClass}
onClick={(e) => {
e.stopPropagation();
scrollToDivergeRound(roundNum);
}}
title={`点击跳转到辩论轮 ${roundNum}`}
style={{ cursor: 'pointer' }}
>
<div className="diverge-round-dot" />
<span>辩论轮 {roundNum}</span>
</div>
);
})}
</div>
)}
{p.id === 'diverging' && !isDone && !isActive && divergeRounds > 0 && (
<div className="phase-step-sublabel">{divergeRounds} 轮</div>
)}
</div>
</div>
);
})}
</div>
</div>
{agents && agents.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">智能体</div>
<div className="agent-list">
{agents.map(agent => {
const agentState = agentStates[agent.id] || {};
const status = agentState.status || 'idle';
const handleClick = () => {
const cards = document.querySelectorAll(`[data-agent-id="${agent.id}"]`);
if (cards.length > 0) {
(cards[cards.length - 1] as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
return (
<div
key={agent.id}
className={`agent-item agent-item-clickable${status === 'thinking' ? ' agent-item-active' : ''}`}
onClick={handleClick}
title="点击跳转到最新输出"
>
<div className="agent-status-icon">
{status === 'idle' && <span className="agent-status-idle">○</span>}
{status === 'thinking' && <span className="agent-status-thinking spinner">⟳</span>}
{status === 'done' && <span className="agent-status-done">✓</span>}
</div>
<div className="agent-item-info">
<div className="agent-item-name">{agent.name || agent.id}</div>
<div className="agent-item-dim">{agent.dimension}</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,670 @@
.timeline {
display: flex;
flex-direction: column;
gap: 0;
max-width: 920px;
margin: 0 auto;
padding-bottom: 16px;
}
/* Empty state */
.timeline-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text-muted);
gap: 12px;
}
.timeline-empty-spinner {
font-size: 28px;
animation: spin 1.2s linear infinite;
}
/* Round header */
.round-header {
display: flex;
align-items: center;
gap: 10px;
margin: 20px 0 12px;
padding: 0 2px;
}
.round-header:first-child {
margin-top: 0;
}
.round-header-line {
flex: 1;
height: 1px;
background: var(--border);
}
.round-header-label {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
white-space: nowrap;
letter-spacing: 0.3px;
}
.round-header-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 700;
}
/* Background retrieval card */
.bg-retrieval-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 14px;
margin-bottom: 18px;
overflow: hidden;
box-shadow: var(--shadow-card);
}
.bg-retrieval-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
background: var(--surface-hover);
user-select: none;
}
.bg-retrieval-header:hover {
background: var(--surface-hover-strong);
}
.bg-retrieval-title {
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.bg-retrieval-meta {
font-size: 12px;
color: var(--text-muted);
margin-left: auto;
}
.bg-retrieval-chevron {
color: var(--text-muted);
font-size: 12px;
transition: transform 0.2s;
}
.bg-retrieval-chevron.open {
transform: rotate(180deg);
}
.bg-retrieval-body {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 14px;
}
.bg-retrieval-dim {
display: flex;
flex-direction: column;
gap: 6px;
}
.bg-retrieval-dim-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent);
}
.bg-retrieval-result {
margin-bottom: 8px;
}
.bg-retrieval-query {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 4px;
font-style: italic;
}
.bg-retrieval-items {
display: flex;
flex-direction: column;
gap: 4px;
}
.bg-retrieval-item {
font-size: 12px;
color: var(--text);
line-height: 1.5;
padding: 4px 8px;
background: var(--surface-hover);
border-radius: 4px;
border-left: 2px solid var(--border);
}
/* Coordinator card */
.coordinator-card {
background: rgba(137, 87, 229, 0.06);
border: 1px solid rgba(137, 87, 229, 0.25);
border-radius: 14px;
padding: 14px 16px;
margin-bottom: 14px;
box-shadow: var(--shadow-card);
}
.coordinator-card-title {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--purple);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.coordinator-speakers {
font-size: 13px;
color: var(--text);
margin-bottom: 8px;
}
.coordinator-speaker-tag {
display: inline-block;
background: rgba(137, 87, 229, 0.15);
color: var(--purple);
border-radius: 4px;
padding: 1px 6px;
font-size: 12px;
margin: 0 3px;
font-weight: 600;
}
.coordinator-focus {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 6px;
}
.coordinator-focus-item {
display: flex;
gap: 8px;
align-items: flex-start;
font-size: 12px;
}
.coordinator-focus-agent {
font-weight: 700;
color: var(--purple);
flex-shrink: 0;
min-width: 60px;
}
.coordinator-focus-directive {
color: var(--text-muted);
line-height: 1.5;
}
.coordinator-round-badge {
font-weight: 400;
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
font-size: 11px;
}
.coordinator-speakers-label {
color: var(--text-muted);
margin-right: 4px;
}
/* 草稿进展区块 */
.coordinator-draft-section {
margin-bottom: 10px;
border: 1px solid rgba(137, 87, 229, 0.15);
border-radius: 6px;
overflow: hidden;
}
.coordinator-draft-header {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
background: rgba(137, 87, 229, 0.08);
cursor: pointer;
user-select: none;
}
.coordinator-draft-header:hover {
background: rgba(137, 87, 229, 0.13);
}
.coordinator-draft-title {
font-size: 12px;
font-weight: 600;
color: var(--purple);
}
.coordinator-draft-progress {
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
}
.coordinator-draft-chevron {
font-size: 12px;
color: var(--text-muted);
transition: transform 0.15s;
}
.coordinator-draft-chevron.open {
transform: rotate(180deg);
}
.coordinator-draft-body {
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.coordinator-draft-field {
display: flex;
flex-direction: column;
gap: 2px;
padding: 5px 8px;
border-radius: 4px;
font-size: 12px;
}
.coordinator-draft-field.filled {
background: rgba(34, 197, 94, 0.06);
border-left: 2px solid rgba(34, 197, 94, 0.5);
}
.coordinator-draft-field.empty {
background: rgba(239, 68, 68, 0.05);
border-left: 2px solid rgba(239, 68, 68, 0.3);
}
.coordinator-draft-field-name {
font-weight: 600;
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.coordinator-draft-field-content {
color: var(--text);
line-height: 1.5;
}
.coordinator-draft-field-empty {
color: var(--text-muted);
font-style: italic;
}
/* 协调员流式动画点 */
.coordinator-streaming-dot {
color: var(--purple);
font-size: 10px;
animation: pulse 1s ease-in-out infinite;
margin-left: 6px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* 协调员思考过程 */
.coordinator-thinking-section {
margin-bottom: 10px;
border: 1px solid rgba(137, 87, 229, 0.15);
border-radius: 6px;
overflow: hidden;
}
.coordinator-thinking-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: rgba(137, 87, 229, 0.06);
cursor: pointer;
user-select: none;
}
.coordinator-thinking-header:hover {
background: rgba(137, 87, 229, 0.1);
}
.coordinator-thinking-label {
font-size: 11px;
font-weight: 600;
color: var(--purple);
}
.coordinator-thinking-body {
padding: 8px 10px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
.coordinator-status-banner {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 7px 10px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 12px;
font-weight: 600;
}
.coordinator-status-banner-tail {
display: flex;
width: 100%;
margin-top: 10px;
margin-bottom: 0;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
box-shadow: 0 8px 18px color-mix(in srgb, var(--accent) 10%, transparent);
}
.coordinator-status-spinner {
display: inline-block;
animation: spin 1s linear infinite;
}
/* 活跃字段(正在生成)高亮 */
.coordinator-draft-field.active {
background: rgba(137, 87, 229, 0.08);
border-left: 2px solid rgba(137, 87, 229, 0.6);
}
/* 缺口列表 */
.coordinator-gaps {
margin-bottom: 10px;
}
.coordinator-gaps-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
color: #f59e0b;
margin-bottom: 4px;
}
.coordinator-gap-item {
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
padding: 1px 0;
}
.human-review-card {
background: rgba(88, 166, 255, 0.08);
border: 1px solid rgba(88, 166, 255, 0.28);
border-radius: 10px;
padding: 14px;
margin-bottom: 16px;
}
.human-review-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--accent);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
margin-bottom: 8px;
}
.human-review-hint {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 12px;
}
.human-review-chatbox {
min-height: 120px;
}
.human-review-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.human-review-field {
margin-bottom: 12px;
}
.human-review-field label {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.human-review-input,
.human-review-textarea {
width: 100%;
background: var(--card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 10px;
font-family: inherit;
font-size: 13px;
outline: none;
}
.human-review-speaker-select {
width: 100%;
}
.human-review-speaker-select .t-select__wrap,
.human-review-speaker-select .t-input,
.human-review-speaker-select .t-tag-input {
width: 100%;
}
.human-review-speaker-select .t-input,
.human-review-speaker-select .t-tag-input {
border-color: var(--border);
border-radius: 6px;
background: var(--card);
color: var(--text);
}
.human-review-speaker-select .t-input:hover,
.human-review-speaker-select .t-tag-input:hover,
.human-review-speaker-select .t-input.t-is-focused,
.human-review-speaker-select .t-tag-input.t-is-focused {
border-color: var(--accent);
}
.human-review-speaker-select .t-tag {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--border);
}
.human-review-textarea {
min-height: 220px;
resize: vertical;
font-family: 'SFMono-Regular', Consolas, monospace;
line-height: 1.5;
}
.human-review-textarea-sm {
min-height: 120px;
}
.human-review-input:focus,
.human-review-textarea:focus {
border-color: var(--accent);
}
.human-review-meta {
margin-top: 6px;
font-size: 11px;
color: var(--text-muted);
line-height: 1.5;
}
.human-review-actions {
display: flex;
justify-content: flex-end;
}
.human-review-actions-left {
justify-content: flex-start;
margin-top: 8px;
}
.human-review-error {
color: var(--red);
font-size: 12px;
margin-bottom: 10px;
}
@media (max-width: 900px) {
.human-review-grid {
grid-template-columns: 1fr;
}
}
/* 下滑按钮 */
.scroll-to-bottom-button {
position: fixed;
bottom: 34px;
left: 50%;
transform: translateX(-50%);
width: 48px;
height: 48px;
border-radius: 50%;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.34);
background:
radial-gradient(circle at 30% 22%, rgba(255, 255, 255, 0.42), transparent 34%),
linear-gradient(135deg, rgba(59, 130, 246, 0.46), rgba(124, 58, 237, 0.42));
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow:
0 16px 40px rgba(37, 99, 235, 0.28),
0 6px 18px rgba(15, 23, 42, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.24);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease,
border-color 0.2s ease;
z-index: 1000;
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
animation: scrollBtnIn 0.26s ease-out;
pointer-events: auto;
}
.scroll-to-bottom-button::before {
content: '';
position: absolute;
inset: -6px;
border-radius: inherit;
background: rgba(99, 102, 241, 0.12);
border: 1px solid rgba(255, 255, 255, 0.12);
pointer-events: none;
}
.scroll-to-bottom-button::after {
content: '';
position: absolute;
inset: 4px;
border-radius: inherit;
border: 1px solid rgba(255, 255, 255, 0.2);
pointer-events: none;
}
.scroll-to-bottom-button:hover {
background:
radial-gradient(circle at 30% 22%, rgba(255, 255, 255, 0.55), transparent 34%),
linear-gradient(135deg, rgba(59, 130, 246, 0.62), rgba(124, 58, 237, 0.58));
transform: translateX(-50%) translateY(-3px) scale(1.04);
box-shadow:
0 20px 48px rgba(37, 99, 235, 0.36),
0 10px 26px rgba(15, 23, 42, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.32);
border-color: rgba(255, 255, 255, 0.52);
}
.scroll-to-bottom-button:active {
transform: translateX(-50%) translateY(-1px) scale(0.97);
box-shadow:
0 10px 28px rgba(37, 99, 235, 0.28),
0 4px 14px rgba(15, 23, 42, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
@keyframes scrollBtnIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(12px) scale(0.86);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
}
.scroll-to-bottom-button .scroll-arrow {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
font-size: 24px;
line-height: 1;
font-weight: 800;
text-shadow: 0 2px 8px rgba(15, 23, 42, 0.32);
animation: scrollArrowBounce 1.35s ease-in-out infinite;
}
@keyframes scrollArrowBounce {
0%, 100% { transform: translateY(-1px); }
50% { transform: translateY(3px); }
}
@@ -0,0 +1,553 @@
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>
);
}