代码修改
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user