420 lines
16 KiB
TypeScript
420 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|