代码修改

This commit is contained in:
fengxiangdebaozi authored and xuhd committed 2026-05-28 18:29:37 +08:00
1 parent ef1481be63
commit a4b7ab5e9d
168 files changed
+76575

No files matched your search

File diff suppressed because it is too large. Load diff
+578
View File
@@ -0,0 +1,578 @@
import React, { useState } from 'react';
import {
Search,
History,
Bot,
Sparkles,
Database,
Layers,
FileText,
ChevronRight,
ChevronLeft,
Cpu,
Target,
Zap,
CheckCircle,
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import { PageType } from '../../components/Sidebar';
import { ChatMessage, EmptyChatState, QAComposer, TypingIndicator } from './QACommon';
interface Agent {
id: string;
name: string;
category: string;
tags: string[];
description: string;
skills: string[];
dataSources: string[];
functions: string[];
outputFields: string[];
}
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
agentName?: string;
skills?: string[];
dataSources?: string[];
functions?: string[];
structuredOutput?: Record<string, any>;
}
/**
* 智能体问答组件
* 基于特定智能体的专业能力进行问答,支持选择不同领域的智能体
* @param onNavigate - 页面导航函数
*/
const AgentQA: React.FC<{ onNavigate: (page: PageType) => void }> = ({ onNavigate }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
// 模拟智能体列表
const agents: Agent[] = [
{
id: 'audience-agent',
name: 'AudienceAgent',
category: '受众分析',
tags: ['受众分层', '画像抽取', '行为预测'],
description: '专业的受众分析智能体,能够识别目标受众特征并生成画像',
skills: ['受众分层', '核心受众识别', '画像抽取', '行为预测'],
dataSources: ['知识库', 'Y系统', 'A系统'],
functions: ['信息抽取器', '标签聚类器', '结构化输出器'],
outputFields: ['core_audience', 'key_traits', 'barriers', 'opportunities'],
},
{
id: 'brand-agent',
name: 'BrandAgent',
category: '品牌分析',
tags: ['品牌定位', '传播策略', '调性分析'],
description: '专注品牌建设与传播策略的智能体',
skills: ['品牌定位分析', '传播路径规划', '品牌调性设计'],
dataSources: ['知识库', '行业数据库'],
functions: ['品牌诊断器', '策略生成器'],
outputFields: ['brand_position', 'communication_path', 'tone_guide'],
},
{
id: 'content-agent',
name: 'ContentAgent',
category: '内容创作',
tags: ['文案撰写', '创意生成', '多形式内容'],
description: '智能内容创作助手,支持多种形式的内容生成',
skills: ['文案撰写', '创意生成', '多形式内容', 'A/B测试文案'],
dataSources: ['知识库', '优秀案例库'],
functions: ['文案生成器', '风格适配器', '质量评估器'],
outputFields: ['headlines', 'body_copy', 'call_to_action', 'variants'],
},
{
id: 'channel-agent',
name: 'ChannelAgent',
category: '渠道规划',
tags: ['渠道选择', '投放策略', '效果评估'],
description: '专业的渠道分析与规划智能体',
skills: ['渠道选择', '投放策略规划', '效果评估', '预算分配'],
dataSources: ['Y系统', 'A系统', '行业数据'],
functions: ['渠道分析器', '投放优化器', 'ROI预测器'],
outputFields: ['recommended_channels', 'budget_allocation', 'expected_roi'],
},
];
// 状态管理
// 当前选中的智能体
const [selectedAgent, setSelectedAgent] = useState<Agent>(agents[0]);
// 智能体搜索关键词
const [searchQuery, setSearchQuery] = useState('');
// 聊天消息列表
const [messages, setMessages] = useState<Message[]>([]);
// 输入框内容
const [inputValue, setInputValue] = useState('');
// 加载状态
const [isLoading, setIsLoading] = useState(false);
// 左侧侧边栏折叠状态
const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
// 过滤智能体
const filteredAgents = agents.filter(agent =>
agent.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
agent.category.toLowerCase().includes(searchQuery.toLowerCase()) ||
agent.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
);
/**
* 处理发送消息
* 发送用户消息并模拟智能体响应
*/
const handleSend = () => {
if (!inputValue.trim()) return;
setIsLoading(true);
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: inputValue,
timestamp: new Date(),
};
setMessages([...messages, userMessage]);
setInputValue('');
// 模拟AI响应
setTimeout(() => {
const response: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `基于${selectedAgent.name}的专业能力,我来为您分析:\n\n根据您的问题,我识别到核心受众特征如下:\n\n**核心受众识别**:\n- 主要年龄段:25-35岁\n- 职业特征:都市白领\n- 消费能力:中高端\n- 关注点:品质生活、个人成长\n\n**关键特质**:\n1. 追求个性化表达\n2. 注重社交认同\n3. 愿意为品质付费\n\n**可能存在的阻力**:\n- 价格敏感度较高\n- 对品牌忠诚度有限\n\n以上分析是否满足您的需求?如需调整维度,请继续补充信息。`,
timestamp: new Date(),
agentName: selectedAgent.name,
skills: selectedAgent.skills,
dataSources: selectedAgent.dataSources,
functions: selectedAgent.functions,
structuredOutput: {
core_audience: '25-35岁都市白领',
key_traits: ['追求个性化', '注重社交认同', '品质导向'],
barriers: ['价格敏感', '品牌忠诚度有限'],
opportunities: ['社交媒体影响大', '口碑传播效果好'],
},
};
setMessages(prev => [...prev, response]);
setIsLoading(false);
}, 1500);
};
/**
* 处理停止操作
* 停止当前正在进行的智能体响应
*/
const handleStop = () => {
setIsLoading(false);
};
/**
* 处理键盘事件
* 按Enter键发送消息(不按Shift)
*/
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
/**
* 保存问答记录
*/
const handleSaveRecord = () => {
alert('问答记录已保存');
};
/**
* 收藏当前对话
*/
const handleCollect = () => {
alert('已添加到收藏');
};
return (
<div className={`h-[calc(100vh-120px)] flex flex-col theme-bg rounded-xl ${isDark ? 'bg-slate-800/50' : 'bg-white'} border ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
{/* 顶部工具栏 */}
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className="flex items-center gap-4">
<h1 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
智能体问答
</h1>
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${isDark ? 'bg-slate-700/50' : 'bg-red-50'}`}>
<Bot className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
当前智能体:
</span>
<span className={`text-sm font-medium ${isDark ? 'text-blue-400' : 'text-red-600'}`}>
{selectedAgent.name}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onNavigate('qa-records')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
isDark
? 'bg-slate-700/50 text-slate-300 hover:bg-slate-600/50'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<History className="w-4 h-4" />
历史记录
</button>
</div>
</div>
{/* 主体内容区 */}
<div className="flex-1 flex overflow-hidden">
{/* 左侧智能体区 */}
<div className={`${isLeftSidebarCollapsed ? 'w-14 p-2' : 'w-72'} border-r flex-shrink-0 overflow-y-auto transition-all duration-300 ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'}`}>
<div className={`flex items-center ${isLeftSidebarCollapsed ? 'justify-center' : 'justify-between'} p-2 ${isLeftSidebarCollapsed ? '' : 'px-4 pt-4 pb-0'}`}>
{!isLeftSidebarCollapsed && (
<span className={`text-xs font-medium uppercase tracking-wider ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
智能体配置
</span>
)}
<button
type="button"
onClick={() => setIsLeftSidebarCollapsed(!isLeftSidebarCollapsed)}
className={`p-2 rounded-lg transition-all ${
isDark
? 'text-slate-400 hover:text-white hover:bg-slate-700/50'
: 'text-gray-500 hover:text-red-600 hover:bg-red-50'
}`}
title={isLeftSidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}
>
{isLeftSidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
{!isLeftSidebarCollapsed && (<>
{/* 智能体搜索 */}
<div className="p-4">
<div className={`relative ${isDark ? 'bg-slate-700/50' : 'bg-white'} rounded-lg`}>
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${isDark ? 'text-slate-400' : 'text-gray-400'}`} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索智能体..."
className={`w-full pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none ${
isDark
? 'bg-transparent text-white placeholder-slate-500'
: 'bg-transparent text-gray-800 placeholder-gray-400'
}`}
/>
</div>
</div>
{/* 智能体列表 */}
<div className="px-4 pb-4">
<h3 className={`text-xs font-medium uppercase tracking-wider mb-3 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
智能体列表
</h3>
<div className="space-y-2">
{filteredAgents.map((agent) => (
<button
key={agent.id}
onClick={() => setSelectedAgent(agent)}
className={`w-full text-left p-3 rounded-lg transition-all ${
selectedAgent.id === agent.id
? isDark
? 'bg-blue-600/20 border border-blue-500/50'
: 'bg-red-50 border border-red-200'
: isDark
? 'bg-slate-700/30 border border-transparent hover:bg-slate-700/50'
: 'bg-white border border-gray-200 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
selectedAgent.id === agent.id
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'bg-slate-600 text-slate-300'
: 'bg-gray-100 text-gray-600'
}`}>
<Bot className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-gray-900'}`}>
{agent.name}
</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{agent.category}
</div>
</div>
<ChevronRight className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />
</div>
</button>
))}
</div>
</div>
{/* 当前智能体能力摘要 */}
<div className={`p-4 border-t ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<h3 className={`text-xs font-medium uppercase tracking-wider mb-3 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
能力摘要
</h3>
{/* 核心职责 */}
<div className="mb-4">
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Target className="w-4 h-4" />
<span className="text-xs">核心职责</span>
</div>
<p className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
{selectedAgent.description}
</p>
</div>
{/* 已启用技能 */}
<div className="mb-4">
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Sparkles className="w-4 h-4" />
<span className="text-xs">已启用技能</span>
</div>
<div className="flex flex-wrap gap-1">
{selectedAgent.skills.map((skill, idx) => (
<span
key={idx}
className={`px-2 py-0.5 rounded text-xs ${
isDark ? 'bg-blue-600/30 text-blue-400' : 'bg-blue-100 text-blue-600'
}`}
>
{skill}
</span>
))}
</div>
</div>
{/* 可访问数据源 */}
<div className="mb-4">
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Database className="w-4 h-4" />
<span className="text-xs">可访问数据源</span>
</div>
<div className="flex flex-wrap gap-1">
{selectedAgent.dataSources.map((source, idx) => (
<span
key={idx}
className={`px-2 py-0.5 rounded text-xs ${
isDark ? 'bg-green-600/30 text-green-400' : 'bg-green-100 text-green-600'
}`}
>
{source}
</span>
))}
</div>
</div>
{/* 可调用功能 */}
<div>
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Layers className="w-4 h-4" />
<span className="text-xs">可调用功能</span>
</div>
<div className="flex flex-wrap gap-1">
{selectedAgent.functions.map((func, idx) => (
<span
key={idx}
className={`px-2 py-0.5 rounded text-xs ${
isDark ? 'bg-purple-600/30 text-purple-400' : 'bg-purple-100 text-purple-600'
}`}
>
{func}
</span>
))}
</div>
</div>
</div>
</>)}
</div>
{/* 中间对话区 */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 ? (
<EmptyChatState
icon={<Bot className="w-11 h-11 opacity-60" />}
title={`开始与${selectedAgent.name}对话`}
description="基于智能体专业能力获取精准回答"
/>
) : (
messages.map((msg) => (
<ChatMessage
key={msg.id}
message={msg}
assistantLabel={msg.agentName || selectedAgent.name}
renderAssistantExtra={(assistantMessage) => (
<div className={`ml-12 p-3 rounded-xl ${isDark ? 'bg-slate-800/50' : 'bg-gray-50'}`}>
<div className="mb-3">
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>本轮启用技能</h4>
<div className="flex flex-wrap gap-1">
{assistantMessage.skills?.map((skill, idx) => (
<span key={idx} className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-blue-600/30 text-blue-400' : 'bg-blue-100 text-blue-600'}`}>
{skill}
</span>
))}
</div>
</div>
<div className="mb-3">
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>本轮访问数据源</h4>
<div className="flex flex-wrap gap-1">
{assistantMessage.dataSources?.map((source, idx) => (
<span key={idx} className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-green-600/30 text-green-400' : 'bg-green-100 text-green-600'}`}>
{source}
</span>
))}
</div>
</div>
<div className="mb-3">
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>本轮调用功能</h4>
<div className="flex flex-wrap gap-1">
{assistantMessage.functions?.map((func, idx) => (
<span key={idx} className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-purple-600/30 text-purple-400' : 'bg-purple-100 text-purple-600'}`}>
{func}
</span>
))}
</div>
</div>
{assistantMessage.structuredOutput && (
<div>
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>结构化输出</h4>
<div className={`p-3 rounded-lg ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
{Object.entries(assistantMessage.structuredOutput).map(([key, value]) => (
<div key={key} className="flex items-start gap-2 mb-1 last:mb-0">
<span className={`text-xs font-mono ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>{key}:</span>
<span className={`text-xs ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
{Array.isArray(value) ? value.join(', ') : String(value)}
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
/>
))
)}
{isLoading && <TypingIndicator label={`${selectedAgent.name} 正在调用能力生成回复`} assistantLabel={selectedAgent.name} />}
</div>
{/* 底部输入区 */}
<QAComposer
value={inputValue}
onChange={setInputValue}
onKeyDown={handleKeyDown}
onSubmit={handleSend}
onStop={handleStop}
onSave={handleSaveRecord}
onCollect={handleCollect}
isLoading={isLoading}
placeholder="输入问题或任务要求..."
showUpload={false}
/>
</div>
{/* 右侧执行详情区 */}
<div className={`w-72 border-l flex-shrink-0 overflow-y-auto p-4 ${
isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'
}`}>
<h3 className={`text-sm font-medium mb-4 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
执行详情
</h3>
{/* 本轮启用技能 */}
<div className="mb-6">
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Sparkles className="w-4 h-4" />
<span className="text-xs font-medium">本轮启用技能</span>
</div>
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<div className="space-y-2">
{selectedAgent.skills.map((skill, idx) => (
<div key={idx} className="flex items-center gap-2">
<CheckCircle className={`w-3 h-3 ${isDark ? 'text-blue-400' : 'text-green-500'}`} />
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>{skill}</span>
</div>
))}
</div>
</div>
</div>
{/* 本轮访问数据源 */}
<div className="mb-6">
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Database className="w-4 h-4" />
<span className="text-xs font-medium">本轮访问数据源</span>
</div>
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<div className="space-y-2">
{selectedAgent.dataSources.map((source, idx) => (
<div key={idx} className="flex items-center gap-2">
<Database className={`w-3 h-3 ${isDark ? 'text-green-400' : 'text-green-500'}`} />
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>{source}</span>
</div>
))}
</div>
</div>
</div>
{/* 本轮调用功能 */}
<div className="mb-6">
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Layers className="w-4 h-4" />
<span className="text-xs font-medium">本轮调用功能</span>
</div>
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<div className="space-y-2">
{selectedAgent.functions.map((func, idx) => (
<div key={idx} className="flex items-center gap-2">
<Cpu className={`w-3 h-3 ${isDark ? 'text-purple-400' : 'text-purple-500'}`} />
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>{func}</span>
</div>
))}
</div>
</div>
</div>
{/* 结构化输出字段 */}
<div>
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<FileText className="w-4 h-4" />
<span className="text-xs font-medium">输出字段</span>
</div>
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<div className="space-y-2">
{selectedAgent.outputFields.map((field, idx) => (
<div key={idx} className="flex items-center gap-2">
<Zap className={`w-3 h-3 ${isDark ? 'text-yellow-400' : 'text-yellow-500'}`} />
<span className={`text-xs font-mono ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>{field}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default AgentQA;
+189
View File
@@ -0,0 +1,189 @@
import React, { useState } from 'react';
import { CheckCircle, ChevronLeft, ChevronRight, History, MessageCircle, Users } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import { PageType } from '../../components/Sidebar';
import { ChatMessage, EmptyChatState, QAComposer, TypingIndicator } from './QACommon';
interface CollaborativeQAProps {
onNavigate: (page: PageType) => void;
}
interface CollaborativeMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
agentName?: string;
}
const collaborativeAgents = [
{ id: 'goal-agent', name: 'GoalAgent', category: '目标规划', color: 'blue' },
{ id: 'audience-agent', name: 'AudienceAgent', category: '受众分析', color: 'green' },
{ id: 'risk-agent', name: 'RiskAgent', category: '风险识别', color: 'purple' },
];
/**
* 多智能体协同问答组件
* 多个智能体从不同视角联合分析问题,生成综合建议
* @param onNavigate - 页面导航函数
*/
const CollaborativeQA: React.FC<CollaborativeQAProps> = ({ onNavigate }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [messages, setMessages] = useState<CollaborativeMessage[]>([]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [selectedAgents, setSelectedAgents] = useState(collaborativeAgents.map((agent) => agent.id));
const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
const activeAgents = collaborativeAgents.filter((agent) => selectedAgents.includes(agent.id));
const handleSend = () => {
if (!inputValue.trim()) return;
const userMessage: CollaborativeMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: inputValue,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInputValue('');
setIsLoading(true);
setTimeout(() => {
const agentNames = activeAgents.map((agent) => agent.name).join('、');
const assistantMessage: CollaborativeMessage = {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: `已组织 ${agentNames || '默认协同组'} 完成一轮联合研判:\n\n1. GoalAgent 建议先明确任务目标与阶段性 KPI,避免讨论发散。\n2. AudienceAgent 认为应优先锁定核心受众画像,并补充行为触点数据。\n3. RiskAgent 提醒需要同步评估内容合规、舆情扩散和资源约束。\n\n综合建议:先形成“目标-受众-风险”三列表,再进入方案细化与执行拆解。`,
timestamp: new Date(),
agentName: '协同编排助手',
};
setMessages((prev) => [...prev, assistantMessage]);
setIsLoading(false);
}, 1800);
};
const handleStop = () => {
setIsLoading(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const toggleAgent = (agentId: string) => {
setSelectedAgents((prev) =>
prev.includes(agentId) ? prev.filter((id) => id !== agentId) : [...prev, agentId]
);
};
return (
<div className={`h-[calc(100vh-120px)] flex flex-col theme-bg rounded-xl ${isDark ? 'bg-slate-800/50' : 'bg-white'} border ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className="flex items-center gap-4">
<h1 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>多智能体协同问答</h1>
<span className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>多角色联合研判、共识汇总与风险补充</span>
</div>
<button
onClick={() => onNavigate('qa-records')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
isDark ? 'bg-slate-700/50 text-slate-300 hover:bg-slate-600/50' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<History className="w-4 h-4" />
历史记录
</button>
</div>
<div className="flex-1 flex overflow-hidden">
<aside className={`${isLeftSidebarCollapsed ? 'w-14 p-2' : 'w-72 p-4'} border-r flex-shrink-0 overflow-y-auto transition-all duration-300 ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'}`}>
<div className={`flex items-center ${isLeftSidebarCollapsed ? 'justify-center' : 'justify-between'} mb-4`}>
{!isLeftSidebarCollapsed && (
<h3 className={`text-sm font-medium ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>参与智能体</h3>
)}
<button
type="button"
onClick={() => setIsLeftSidebarCollapsed(!isLeftSidebarCollapsed)}
className={`p-2 rounded-lg transition-all ${
isDark
? 'text-slate-400 hover:text-white hover:bg-slate-700/50'
: 'text-gray-500 hover:text-red-600 hover:bg-red-50'
}`}
title={isLeftSidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}
>
{isLeftSidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
{!isLeftSidebarCollapsed && (<>
<div className="space-y-2">
{collaborativeAgents.map((agent) => {
const active = selectedAgents.includes(agent.id);
return (
<button
key={agent.id}
onClick={() => toggleAgent(agent.id)}
className={`w-full p-3 rounded-xl text-left border transition-all ${
active
? isDark ? 'bg-blue-600/20 border-blue-500/50' : 'bg-red-50 border-red-200'
: isDark ? 'bg-slate-700/30 border-transparent hover:bg-slate-700/50' : 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-2xl flex items-center justify-center ${active ? 'bg-gradient-to-br from-red-500 to-orange-400 text-white' : isDark ? 'bg-slate-600 text-slate-300' : 'bg-gray-100 text-gray-600'}`}>
<Users className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-gray-900'}`}>{agent.name}</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{agent.category}</div>
</div>
{active && <CheckCircle className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />}
</div>
</button>
);
})}
</div>
</>)}
</aside>
<section className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 ? (
<EmptyChatState
icon={<MessageCircle className="w-11 h-11 opacity-60" />}
title="发起一次协同会商"
description="输入任务背景,多个智能体会从不同视角生成联合建议"
/>
) : (
messages.map((message) => (
<ChatMessage key={message.id} message={message} assistantLabel={message.agentName || '协同编排助手'} />
))
)}
{isLoading && <TypingIndicator label="多智能体正在协同分析并汇总观点" assistantLabel="协同编排助手" />}
</div>
<QAComposer
value={inputValue}
onChange={setInputValue}
onKeyDown={handleKeyDown}
onSubmit={handleSend}
onStop={handleStop}
onSave={() => alert('问答记录已保存')}
onCollect={() => alert('已添加到收藏')}
isLoading={isLoading}
placeholder="输入协同研判主题或任务背景..."
showUpload={false}
/>
</section>
</div>
</div>
);
};
export default CollaborativeQA;
+29
View File
@@ -0,0 +1,29 @@
import { Outlet, useNavigate } from 'react-router-dom';
import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
const zncmUrl = import.meta.env.VITE_AIPI_CONSUMER_URL || '';
import { requestCrudService } from '@/service/request-axios-service';
const service = requestCrudService;
const userInfo = localStorage.getItem('userInfo');
const deerFlowUrl = 'https://ch1.b.uat.4.cn/mopeweb/#/login/';
interface TaskPgFromProps {
gopath: string
}
const GAFromQA: React.FC<TaskPgFromProps> = ({gopath}) => {
const [deerFlow, setDeerFlow] = useState('https://ch1.b.uat.4.cn/mopeweb/#/login/'); // http://localhost:5174/#/
useEffect(() => {
if(userInfo) {
const userName = JSON.parse(userInfo).userName;
const gaUrl = deerFlowUrl + userName + `?goPath=${gopath}`
console.log('gaUrl', gaUrl)
setDeerFlow(gaUrl);
}
}, [gopath]);
return (
<div className="h-full flex flex-col">
<iframe className="h-[calc(100vh-92px)]" src={deerFlow}></iframe>
</div>
);
}
export default GAFromQA
+29
View File
@@ -0,0 +1,29 @@
import { Outlet, useNavigate } from 'react-router-dom';
import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
const zncmUrl = import.meta.env.VITE_AIPI_CONSUMER_URL || '';
import { requestCrudService } from '@/service/request-axios-service';
const service = requestCrudService;
const userInfo = localStorage.getItem('userInfo');
const deerFlowUrl = 'https://ch1.b.uat.4.cn/xwtweb/#/login/';
interface TaskPgFromProps {
gopath: string
}
const GAFromNewQA: React.FC<TaskPgFromProps> = ({gopath}) => {
const [deerFlow, setDeerFlow] = useState('https://ch1.b.uat.4.cn/xwtweb/#/login/'); // http://localhost:5174/#/
useEffect(() => {
if(userInfo) {
const userName = JSON.parse(userInfo).userName;
const gaUrl = deerFlowUrl + userName + `?pathTo=${gopath}`
console.log('gaUrl', gaUrl)
setDeerFlow(gaUrl);
}
}, [gopath]);
return (
<div className="h-full flex flex-col">
<iframe className="h-[calc(100vh-92px)]" src={deerFlow}></iframe>
</div>
);
}
export default GAFromNewQA
File diff suppressed because it is too large. Load diff
+450
View File
@@ -0,0 +1,450 @@
import type { QASingleRagTrace, QAThinkingTrace } from './QACommon';
import type { ComparePane, KnowledgeBaseChunk, Message, QAMode, SourceItem } from './GeneralQA.types';
/**
* 解析单轮 RAG 接口的 `<think>...</think>` 流式文本。
*
* 后端会把“思考过程”和“最终回答”混在同一个 answer 字段里返回,
* UI 需要分别展示在 `SingleRagAnswer` 的思考区和答案区。这个函数只做纯文本拆分,
* 不依赖 React 状态,因此既可用于模拟数据,也可作为对比模式里的兜底空 trace。
*/
export const parseSingleRagStream = (chunks: { answer: string }[]): QASingleRagTrace => {
let thinkingMarkdown = '';
let answerMarkdown = '';
let inThink = false;
let thinkingComplete = false;
const appendToken = (token: string) => {
let remaining = token;
while (remaining) {
if (inThink) {
const endIdx = remaining.indexOf('</think>');
if (endIdx === -1) {
thinkingMarkdown += remaining;
break;
}
thinkingMarkdown += remaining.slice(0, endIdx);
inThink = false;
thinkingComplete = true;
remaining = remaining.slice(endIdx + '</think>'.length);
} else {
const startIdx = remaining.indexOf('<think>');
if (startIdx === -1) {
answerMarkdown += remaining;
break;
}
answerMarkdown += remaining.slice(0, startIdx);
inThink = true;
remaining = remaining.slice(startIdx + '<think>'.length);
}
}
};
chunks.forEach((chunk) => appendToken(chunk.answer));
return {
title: '单轮 RAG 思考过程',
thinkingMarkdown,
answerMarkdown,
thinkingComplete,
};
};
/**
* 构造单轮 RAG 的演示 trace。
*
* 首屏默认需要一条完整的示例问答,帮助用户理解“思考过程 + 答案 + 引用”如何展示。
* 这里保留原组件中的演示内容,避免拆分后默认展示行为发生变化。
*/
export const createSingleRagTrace = (): QASingleRagTrace => parseSingleRagStream([
{ answer: '<think>' },
{ answer: '需要回答用户关于 **RAG 工作原理** 的问题。\n\n' },
{ answer: '先根据知识库召回内容组织核心流程:\n\n' },
{ answer: '- 检索阶段:查找相关知识片段\n' },
{ answer: '- 增强阶段:将证据注入提示词\n' },
{ answer: '- 生成阶段:模型基于上下文生成答案\n\n' },
{ answer: '回答需要保持结构清晰,并说明它为什么能降低幻觉。' },
{ answer: '</think>' },
{ answer: 'RAG(Retrieval-Augmented Generation,检索增强生成)是一种把 **知识库检索** 和 **大模型生成** 结合起来的问答方式。\n\n' },
{ answer: '### 工作流程\n\n' },
{ answer: '1. **问题理解**:系统接收用户问题并生成检索查询。\n' },
{ answer: '2. **知识检索**:从知识库中召回相关文档片段。\n' },
{ answer: '3. **上下文增强**:把检索到的内容和用户问题一起放入 Prompt。\n' },
{ answer: '4. **模型生成**:大模型基于增强后的上下文生成回答。\n\n' },
{ answer: '这种方式可以让回答更贴近已有资料,同时提升可追溯性。' },
]);
/**
* 构造多轮/深度检索的演示思考链。
*
* 真实多轮请求会通过 SSE 持续补齐 rounds;深度检索当前仍走本地模拟,
* 因此这个函数保留不同模式下的轮次差异,用于未接入真实接口时的展示。
*/
export const createThinkingTrace = (mode: QAMode): QAThinkingTrace => {
// 初始化轮次数组,第一轮总是基础检索
const rounds = [
{
id: 'round-1', // 第一轮唯一标识
title: '理解问题并拆解检索方向', // 本轮主题:理解问题并确定检索方向
thoughtMarkdown: '用户询问 **RAG 技术工作原理**。需要先明确核心链路:\n\n- 问题理解与向量化\n- 知识库召回\n- 上下文增强\n- 大模型生成\n\n第一轮优先检索基础定义和主流程。', // 模型思考过程,展示分析步骤
query: 'RAG 检索增强生成 工作原理 基础流程', // 发送给检索系统的查询词
queryShow: true,
retrievals: [ // 第一轮检索返回的资料列表
{
id: 'r1-1',
title: 'RAG技术白皮书',
content: 'RAG 将外部知识检索结果作为上下文注入生成模型,能够降低幻觉并提升答案的时效性和可追溯性。',
source: '知识库 / RAG技术白皮书.pdf',
},
{
id: 'r1-2',
title: 'LLM应用实践:检索增强生成',
content: '典型 RAG 流程包括查询改写、向量检索、片段重排、Prompt 组装和答案生成。',
source: '知识库 / LLM应用实践.docx',
},
],
},
];
// 多轮模式和深度检索模式需要第二轮检索,补充更多细节
if (mode !== 'single-round') {
// 添加第二轮检索:补充多轮检索与重排机制的差异
rounds.push({
id: 'round-2', // 第二轮的唯一标识
title: '补充多轮检索与重排机制', // 本轮检索的主题
thoughtMarkdown: '基础链路已经明确,继续检索 **多轮 RAG** 和 **深度检索** 的差异:\n\n1. 是否需要 Query 改写\n2. 是否需要对子问题分别召回\n3. 是否需要结果重排与证据融合',
query: '多轮 RAG Query 改写 子问题检索 结果重排', // 本轮发送给检索系统的查询词
// 本轮检索返回的资料列表
queryShow: true,
retrievals: [
{
id: 'r2-1',
title: '高级RAG技术指南',
content: '复杂问题通常需要分解为多个子问题,并对每个子问题分别检索,再通过重排模型筛选高相关证据。',
source: '资料库 / 高级RAG技术指南.md',
},
{
id: 'r2-2',
title: 'RAG召回与重排最佳实践',
content: '重排阶段可使用 Cross Encoder 或 LLM 评分,按相关性、覆盖度和可信度筛选最终上下文。',
source: '行业资料 / RAG最佳实践.html',
},
{
id: 'r2-3',
title: '检索增强系统评测报告',
content: '多阶段检索可提升复杂问题的召回率,但会增加延迟和计算成本,需要在效果与响应速度之间平衡。',
source: '评测库 / 检索增强系统评测报告.xlsx',
},
],
});
}
// 深度检索模式需要第三轮,综合所有证据并形成最终回答结构
if (mode === 'deep-search') {
rounds.push({
id: 'round-3', // 第三轮的唯一标识
title: '综合证据并形成回答结构', // 本轮的主题:整合前面检索到的所有证据,组织成完整的回答
thoughtMarkdown: '当前证据已覆盖定义、流程和优化机制。最后需要组织成清晰结构:\n\n- 一句话定义\n- 分阶段流程\n- 技术价值\n- 适用场景与注意事项',
query: 'RAG 技术价值 幻觉 时效性 可追溯性 应用场景', // 本轮查询:关于 RAG 价值和应用场景的信息
queryShow: true,
retrievals: [
{
id: 'r3-1',
title: '企业知识问答系统建设方案',
content: 'RAG 在企业知识问答中常用于连接内部文档、制度库和业务数据库,使回答具备来源依据。',
source: '方案库 / 企业知识问答系统建设方案.pptx',
},
{
id: 'r3-2',
title: '生成式AI落地风险控制',
content: 'RAG 不能完全消除幻觉,仍需要上下文质量控制、引用展示、权限过滤和答案审核机制。',
source: '风控库 / 生成式AI落地风险控制.pdf',
},
],
});
}
return {
title: mode === 'single-round' ? '单轮检索思考过程' : mode === 'multi-round' ? '多轮检索思考过程' : '深度检索思考过程',
rounds,
finalAnswerMarkdown: 'RAG(Retrieval-Augmented Generation,检索增强生成)是一种把 **外部知识检索** 和 **大模型生成** 结合起来的技术架构。\n\n### 核心流程\n\n1. **理解问题**:系统解析用户问题,并可能进行 Query 改写或问题拆解。\n2. **检索证据**:从知识库、文档库或业务数据源中召回相关片段。\n3. **筛选重排**:对召回片段按相关性和可信度排序,保留高质量上下文。\n4. **增强生成**:将问题和检索证据一起发送给大模型,生成回答。\n5. **引用溯源**:展示答案依据,便于用户核验。\n\n### 主要价值\n\n- 降低模型幻觉\n- 引入最新或私有知识\n- 提升回答可解释性和可追溯性\n- 适合企业知识问答、政策解读、资料分析等场景',
};
};
/**
* 构造流式多轮检索的初始空 trace。
*
* 请求刚建立时还没有真正的工具调用结果,先渲染一个“正在准备检索”的轮次,
* 可以让用户立即看到助手进入工作状态,后续 SSE 事件会继续更新这个 trace。
*/
export const createMultiRoundStreamTrace = (): QAThinkingTrace => ({
title: '多轮检索思考过程',
rounds: [{
id: `round-${Date.now()}`,
title: '正在准备检索',
thoughtMarkdown: '',
query: '',
queryShow: true,
queryLoading: true,
retrievals: [],
}],
thinkingComplete: false,
finalAnswerMarkdown: '',
});
/**
* 为对比模式创建左右面板的初始状态。
*
* 对比模式会并发运行两个不同的问答模式;每个面板需要独立维护 loading、trace 和 sources。
* 该工厂函数把不同模式的初始 trace 统一封装,避免页面组件重复判断。
*/
export const createComparePane = (mode: QAMode): ComparePane => ({
mode,
loading: true,
singleRagTrace: (mode === 'single-round' || mode === 'single-chat')
? {
title: '单轮 RAG 思考过程',
thinkingMarkdown: '',
answerMarkdown: '',
thinkingComplete: false,
}
: undefined,
thinkingTrace: (mode === 'single-round' || mode === 'single-chat') ? undefined : createMultiRoundStreamTrace(),
sources: [],
});
/**
* 首屏演示消息。
*
* 页面首次进入时,如果没有用户消息,会展示两轮预置问答,说明引用、思考过程和追问的展示形态。
* 拆分时完整保留原始 mock 数据,确保首屏体验不变。
*/
export const createMockSingleRagMessages = (): Message[] => ([
{
id: 'mock-user-1',
role: 'user',
content: 'RAG 在企业知识问答里具体怎么落地?',
timestamp: new Date(),
},
{
id: 'mock-assistant-1',
role: 'assistant',
content: '',
timestamp: new Date(),
singleRagTrace: {
title: '单轮 RAG 思考过程',
thinkingMarkdown: '先明确用户要的是“可落地方案”,所以需要回答:\n\n1. 数据准备与切片策略\n2. 检索与重排链路\n3. 答案引用与可追溯性\n\n优先给出一套可直接上线的最小闭环。',
answerMarkdown: '可以按“**数据层 → 检索层 → 生成层 → 评估层**”四步落地:\n\n1. **数据层**:把制度、FAQ、SOP 文档切片并向量化,按业务域建索引。[[1]]\n2. **检索层**:先向量召回,再用重排模型做精排,提升命中质量。[[2]]\n3. **生成层**:将 TopK 证据注入 Prompt,要求回答必须带引用编号。[[3]]\n4. **评估层**:持续监控命中率、引用准确率和用户满意度。\n\n如果你要,我可以下一步给你一份“可直接给研发执行”的字段级配置清单。',
thinkingComplete: true,
},
sources: [
{
title: '企业知识问答平台建设指南',
type: '知识库',
snippet: '建议按业务域拆分索引,并保持文档版本可追溯。',
source: '内部架构组',
time: '2026-03-18',
url: 'https://example.com/rag-enterprise-guide',
content: '企业落地 RAG 时,建议先从高频问答域切入,采用“文档切片 + 向量索引 + 重排”三层方案,确保上线初期可控。',
},
{
title: 'RAG 重排实践报告',
type: '技术报告',
snippet: '仅向量召回会引入噪声,重排可显著提升答案相关性。',
source: 'NLP 团队',
time: '2026-01-09',
url: 'https://example.com/rag-rerank-report',
content: '在 20 万条文档规模下,向量 Top50 + CrossEncoder 重排到 Top6,可使人工评测正确率提升约 12%。',
},
{
title: '可追溯生成规范',
type: '规范',
snippet: '回答输出应绑定证据编号,便于用户核验。',
source: 'AI 治理委员会',
time: '2025-12-22',
url: 'https://example.com/traceable-generation-spec',
content: '推荐在答案中输出 [[n]] 形式的引用标识,并支持点击查看证据明细、来源与时间信息。',
},
],
},
{
id: 'mock-user-2',
role: 'user',
content: '那单轮和多轮检索的差异是什么?',
timestamp: new Date(),
},
{
id: 'mock-assistant-2',
role: 'assistant',
content: '',
timestamp: new Date(),
singleRagTrace: {
title: '单轮 RAG 思考过程',
thinkingMarkdown: '用户在做方案选型,回答要突出“复杂问题覆盖率”和“时延成本”两个维度。',
answerMarkdown: '**单轮检索**更快,适合事实性问题;**多轮检索**覆盖更全,适合复杂推理问题。通常实践里会先单轮,召回不足再升级多轮。[[1]][[2]]',
thinkingComplete: true,
},
sources: [
{
title: 'RAG 模式选型对比',
type: '白皮书',
snippet: '单轮偏性能,多轮偏完整性。',
source: '智能搜索实验室',
time: '2026-02-11',
url: 'https://example.com/rag-mode-comparison',
content: '复杂查询在多轮检索中召回覆盖更高,但会增加 20%-60% 延迟,需按业务 SLA 选择。',
},
{
title: '检索系统时延评测',
type: '评测',
snippet: '多轮策略会增加查询与重排调用次数。',
source: '平台性能组',
time: '2026-02-27',
url: 'https://example.com/rag-latency-benchmark',
content: '在同等资源下,多轮检索平均时延高于单轮检索,但对于复杂问题的回答完整度更高。',
},
],
},
]);
/**
* 将多轮检索接口返回的 references 项映射为通用引用来源。
*/
export const mapReferenceToSource = (item: { title?: string; content?: string; snippet?: string }): SourceItem => ({
title: item.title || '未命名来源',
type: '参考资料',
snippet: item.snippet || item.content || '',
content: item.content || item.snippet || '',
});
/**
* 将知识库单轮接口返回的 docs 映射为页面引用来源。
*/
export const mapDocsToSources = (docs: KnowledgeBaseChunk['docs']): SourceItem[] => {
const normalizedDocs = Array.isArray(docs) ? docs : docs ? [docs] : [];
return normalizedDocs.map((item, idx) => {
console.log(item,'检索资料')
if (typeof item === 'string') {
return {
title: '',
type: '知识库',
snippet: item,
source: '',
time: '',
content: item,
};
}
return {
title: item.title || item.source || `参考资料 ${idx + 1}`,
type: '知识库',
snippet: item.snippet || item.content || item.url || '',
source: item.source || item.title || '',
time: '',
url: item.url,
content: item.content || item.snippet || '',
};
});
};
export const chatDataJSON = (chatJSON: string) => {
let chatJSONText = chatJSON.replace(/}{"/g, '},{"');
chatJSONText = '[' + chatJSONText + ']';
// console.log('解析chat',chatJSONText);
let chatData = [];
let chatTitle: any = '';
const docsList: any[] = [];
try {
chatData = JSON.parse(chatJSONText);
chatData.forEach((item: any) => {
if (item['answer']) {
chatTitle += item['answer'];
} else if (item['docs']) {
docsList.push(item);
}
});
return {
chatTitle,
docsList,
};
} catch (error) {
return {
chatTitle,
docsList,
};
}
};
/**
* 从流式文本中提取完整 JSON 对象。
*
* SSE 响应可能把一个 JSON 切成多段返回;该函数通过花括号深度和字符串状态,
* 找到已经完整闭合的对象,并把未闭合尾巴放回 `rest` 等待下一批数据拼接。
*/
export const extractJsonObjects = (raw: string): { objects: string[]; rest: string } => {
const objects: string[] = [];
let depth = 0;
let stringQuote: '"' | "'" | null = null;
let escaped = false;
let start = -1;
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (stringQuote) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === stringQuote) {
stringQuote = null;
}
continue;
}
if (ch === '"' || ch === "'") {
stringQuote = ch;
continue;
}
if (ch === '{') {
if (depth === 0) start = i;
depth += 1;
continue;
}
if (ch === '}') {
depth -= 1;
if (depth === 0 && start !== -1) {
objects.push(raw.slice(start, i + 1));
start = -1;
}
}
}
if (depth > 0 && start !== -1) {
return { objects, rest: raw.slice(start) };
}
return { objects, rest: '' };
};
// 处理平台数据
export const platformTypeChange = (platform: string) => {
let platformType: string = '';
if (platform.includes('fb')) {
platformType = 'facebook';
} else if (platform.includes('x')) {
platformType = 'x';
} else if (platform.includes('in')) {
platformType = 'instagram';
} else if (platform.includes('re')) {
platformType = 'reddit';
} else if (platform.includes('th')) {
platformType = 'threads';
} else {
platformType = 'facebook';
}
return platformType;
}
+100
View File
@@ -0,0 +1,100 @@
import type { QASingleRagTrace, QAThinkingTrace } from './QACommon';
import { LucideIcon } from 'lucide-react';
export type QAMode = 'single-chat' | 'single-round' | 'multi-round' | 'deep-search' | 'hx-search' | 'news-report';
export type ViewMode = 'single' | 'compare';
export type KnowledgeScope = 'all' | 'specific-kb' | 'specific-domain';
export type AnswerStyle = 'concise' | 'detailed';
export type CompareLayout = 'horizontal' | 'vertical';
export interface SourceItem {
title: string;
type: string;
snippet: string;
source?: string;
time?: string;
url?: string;
keywords?: string;
taskDirection?: string;
content?: string;
}
export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
singleRagTrace?: QASingleRagTrace;
thinkingTrace?: QAThinkingTrace;
sources?: SourceItem[];
}
export interface ToolCallData {
tool_name: string;
tool_input: {
text?: string;
query?: string;
q?: string;
keyword?: string;
platform?: string;
user_fig?: string;
result?: string;
s?: string;
e?: string;
};
}
export interface KnowledgeBaseDoc {
title?: string;
content?: string;
source?: string;
url?: string;
snippet?: string;
}
export interface KnowledgeBaseChunk {
answer?: string;
docs?: KnowledgeBaseDoc[] | KnowledgeBaseDoc | string;
}
export interface ComparePane {
mode: QAMode;
loading: boolean;
singleRagTrace?: QASingleRagTrace;
thinkingTrace?: QAThinkingTrace;
sources: SourceItem[];
}
export interface CompareResult {
id: string | number;
compareUserInput: string;
left: ComparePane;
right: ComparePane;
differences?: {
consensus: string;
differences: string;
leftAdvantage: string;
rightAdvantage: string;
};
}
export interface SearchProcessState {
currentMode: string;
rounds: number;
hits: number;
queryRewrite: string;
}
export interface stateItem {
compareIndex: number;
compareId: string;
messageList: Message[],
compareResult: CompareResult[];
}
export interface qaModeItem {
value: QAMode;
label: string;
icon: LucideIcon;
desc: string;
}
@@ -0,0 +1,261 @@
import { BookOpen, Bot, Brain, ChevronLeft, ChevronRight, Sparkles, User2Icon } from 'lucide-react';
import type { AnswerStyle, CompareLayout, KnowledgeScope, QAMode, qaModeItem, ViewMode } from './GeneralQA.types';
/**
* 左侧配置栏 Props。
*
* 组件内不保存业务状态,所有选中值和 setter 都来自父组件。
* 这样父组件可以统一处理“生成中切换需要确认停止”的逻辑,配置栏只负责触发用户意图。
*/
type Props = {
isDark: boolean; // 当前是否为暗黑主题,用于样式适配
viewMode: ViewMode; // 当前视图模式:'single' 单一模式 或 'compare' 对比模式
qaMode: QAMode; // 单一模式下的问答模式:'single-round' 单轮RAG / 'multi-round' 多轮RAG / 'deep-search' 深度检索
compareLeftMode: QAMode; // 对比模式下左侧的问答模式
compareRightMode: QAMode; // 对比模式下右侧的问答模式
compareLayout: CompareLayout; // 对比模式的布局方式:'horizontal' 水平 或 'vertical' 垂直
knowledgeScope: KnowledgeScope; // 知识范围设置:'all' 全部 / 'specific-kb' 指定知识库 / 'specific-domain' 指定领域
answerStyle: AnswerStyle; // 回答风格偏好:'concise' 简洁 或 'detailed' 详细
showSources: boolean; // 是否在回答中展示引用来源
showProcess: boolean; // 是否展示模型的思考过程
autoSummarizeDiff: boolean; // 对比模式下是否自动总结左右差异
recommendBetter: boolean; // 对比模式下是否推荐更好的回答方案
isCollapsed: boolean; // 左侧配置面板是否折叠
qaModeOptions?: qaModeItem[];
setQaMode: (mode: QAMode) => void; // 设置单一模式的问答模式
setCompareLeftMode: (mode: QAMode) => void; // 设置对比模式左侧的模式
setCompareRightMode: (mode: QAMode) => void; // 设置对比模式右侧的模式
setCompareLayout: (layout: CompareLayout) => void; // 设置对比布局方式
setKnowledgeScope: (scope: KnowledgeScope) => void; // 设置知识范围
setAnswerStyle: (style: AnswerStyle) => void; // 设置回答风格
setShowSources: (show: boolean) => void; // 设置是否展示引用来源
setShowProcess: (show: boolean) => void; // 设置是否展示思考过程
setAutoSummarizeDiff: (enabled: boolean) => void; // 设置是否自动总结差异
setRecommendBetter: (enabled: boolean) => void; // 设置是否推荐更好的方案
setIsCollapsed: (collapsed: boolean) => void; // 设置配置面板折叠状态
};
/**
* 单一模式下可选的问答模式。
*
* 每个选项同时提供展示文案、图标和简短说明,便于配置栏用统一结构渲染卡片。
*/
const qaModeList: qaModeItem[] = [
{ value: 'single-chat' as const, label: '快速', icon: Bot, desc: '快速直接' },
{ value: 'single-round' as const, label: '单轮RAG', icon: Sparkles, desc: '快速直接' },
{ value: 'multi-round' as const, label: '多轮RAG', icon: Brain, desc: '深入理解' },
{ value: 'deep-search' as const, label: '任务检索', icon: BookOpen, desc: '检索相关任务' },
{ value: 'hx-search' as const, label: '画像检索', icon: User2Icon, desc: '检索相关账号' },
{ value: 'news-report' as const, label: '新闻报告生成', icon: User2Icon, desc: '检索相关新闻' },
];
/**
* 知识范围选项。
*
* 当前只影响 UI 配置状态,后续接入真实知识库筛选时可以直接把该值映射到请求参数。
*/
const knowledgeScopeOptions = [
{ value: 'all' as const, label: '全部知识源' },
{ value: 'specific-kb' as const, label: '指定知识库' },
{ value: 'specific-domain' as const, label: '指定资源域' },
];
/**
* 左侧问答配置栏。
*
* 该组件集中承载页面的“模式选择、知识范围、回答偏好、对比设置”等表单控件。
* 父组件仍是唯一状态源,配置栏通过 setter 把用户选择同步回父组件;这样不会改变原有请求逻辑,
* 同时把 300 多行配置 JSX 从 `GeneralQA.tsx` 中移出,便于后续维护表单项和样式。
*/
export default function GeneralQAConfigSidebar({
isDark,
viewMode,
qaMode,
compareLeftMode,
compareRightMode,
compareLayout,
knowledgeScope,
answerStyle,
showSources,
showProcess,
autoSummarizeDiff,
recommendBetter,
isCollapsed,
qaModeOptions = qaModeList,
setQaMode,
setCompareLeftMode,
setCompareRightMode,
setCompareLayout,
setKnowledgeScope,
setAnswerStyle,
setShowSources,
setShowProcess,
setAutoSummarizeDiff,
setRecommendBetter,
setIsCollapsed,
}: Props): JSX.Element {
/**
* 自定义勾选图标。
*
* 原生 checkbox 被隐藏后,所有勾选项都通过这个 SVG 保持一致的视觉风格。
*/
const renderCheckIcon = () => (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
);
return (
<div className={`${isCollapsed ? 'w-14 p-2' : 'w-72 p-4'} border-r flex-shrink-0 overflow-y-auto transition-all duration-300 ${
isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'
}`}>
<div className={`flex items-center ${isCollapsed ? 'justify-center' : 'justify-between'} mb-4`}>
{!isCollapsed && (
<span className={`text-xs font-medium uppercase tracking-wider ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
问答配置
</span>
)}
<button
type="button"
onClick={() => setIsCollapsed(!isCollapsed)}
className={`p-2 rounded-lg transition-all ${
isDark
? 'text-slate-400 hover:text-white hover:bg-slate-700/50'
: 'text-gray-500 hover:text-red-600 hover:bg-red-50'
}`}
title={isCollapsed ? '展开侧边栏' : '收起侧边栏'}
>
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
{!isCollapsed && (
<>
{viewMode === 'single' ? (
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>问答模式</h3>
<div className="space-y-2">
{qaModeOptions.map((mode) => {
const Icon = mode.icon;
return (
<button
key={mode.value}
onClick={() => setQaMode(mode.value)}
className={`w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left ${
qaMode === mode.value
? isDark
? 'bg-blue-600/20 border border-blue-500/50 text-blue-400'
: 'bg-red-50 border border-red-200 text-red-600'
: isDark
? 'bg-slate-700/30 border border-transparent text-slate-400 hover:bg-slate-700/50'
: 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
<Icon className="w-5 h-5" />
<div className="flex-1">
<div className="text-sm font-medium">{mode.label}</div>
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{mode.desc}</div>
</div>
</button>
)
})}
</div>
</div>
) : (
<>
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比模式设置</h3>
<div className="mb-4">
<label className={`text-xs mb-2 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>模式A</label>
<select value={compareLeftMode} onChange={(e) => setCompareLeftMode(e.target.value as QAMode)} className={`w-full px-3 py-2 rounded-lg text-sm ${isDark ? 'bg-slate-700/50 border border-slate-600 text-white' : 'bg-white border border-gray-200 text-gray-700'}`}>
{
qaModeOptions.filter(item => item.value !== compareRightMode).map(item => (
<option value={item.value} key={item.value}>{item.label}</option>
))
}
</select>
</div>
<div className={`text-center my-2 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}><span className="text-lg">VS</span></div>
<div className="mb-4">
<label className={`text-xs mb-2 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>模式B</label>
<select value={compareRightMode} onChange={(e) => setCompareRightMode(e.target.value as QAMode)} className={`w-full px-3 py-2 rounded-lg text-sm ${isDark ? 'bg-slate-700/50 border border-slate-600 text-white' : 'bg-white border border-gray-200 text-gray-700'}`}>
{
qaModeOptions.filter(item => item.value !== compareLeftMode).map(item => (
<option value={item.value} key={item.value}>{item.label}</option>
))
}
</select>
</div>
</div>
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比布局</h3>
<div className="flex gap-2">
{(['horizontal', 'vertical'] as const).map((layout) => (
<button key={layout} onClick={() => setCompareLayout(layout)} className={`flex-1 px-3 py-2 rounded-lg text-sm transition-all ${compareLayout === layout ? isDark ? 'bg-blue-600 text-white' : 'bg-red-500 text-white' : isDark ? 'bg-slate-700/50 text-slate-300' : 'bg-gray-100 text-gray-600'}`}>
{layout === 'horizontal' ? '左右对比' : '上下对比'}
</button>
))}
</div>
</div>
</>
)}
{/* <div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>知识范围</h3>
<div className="space-y-2">
{knowledgeScopeOptions.map((option) => (
<label key={option.value} className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${knowledgeScope === option.value ? isDark ? 'bg-blue-600/20 text-blue-400' : 'bg-red-50 text-red-600' : isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="radio" name="knowledge-scope" checked={knowledgeScope === option.value} onChange={() => setKnowledgeScope(option.value)} className="sr-only" />
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${knowledgeScope === option.value ? isDark ? 'border-blue-500 bg-blue-500' : 'border-red-500 bg-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>
{knowledgeScope === option.value && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
</div>
<span className="text-sm">{option.label}</span>
</label>
))}
</div>
</div> */}
{/* <div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>回答偏好</h3>
<div className="space-y-3">
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="checkbox" checked={answerStyle === 'detailed'} onChange={() => setAnswerStyle(answerStyle === 'detailed' ? 'concise' : 'detailed')} className="sr-only" />
<div className={`w-4 h-4 rounded border flex items-center justify-center ${answerStyle === 'detailed' ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{answerStyle === 'detailed' && renderCheckIcon()}</div>
<span className="text-sm">详细回答</span>
</label>
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="checkbox" checked={showSources} onChange={() => setShowSources(!showSources)} className="sr-only" />
<div className={`w-4 h-4 rounded border flex items-center justify-center ${showSources ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{showSources && renderCheckIcon()}</div>
<span className="text-sm">展示引用来源</span>
</label>
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="checkbox" checked={showProcess} onChange={() => setShowProcess(!showProcess)} className="sr-only" />
<div className={`w-4 h-4 rounded border flex items-center justify-center ${showProcess ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{showProcess && renderCheckIcon()}</div>
<span className="text-sm">展示检索过程</span>
</label>
</div>
</div> */}
{/* {viewMode === 'compare' && (
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比设置</h3>
<div className="space-y-3">
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="checkbox" checked={autoSummarizeDiff} onChange={() => setAutoSummarizeDiff(!autoSummarizeDiff)} className="sr-only" />
<div className={`w-4 h-4 rounded border flex items-center justify-center ${autoSummarizeDiff ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{autoSummarizeDiff && renderCheckIcon()}</div>
<span className="text-sm">自动总结差异</span>
</label>
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="checkbox" checked={recommendBetter} onChange={() => setRecommendBetter(!recommendBetter)} className="sr-only" />
<div className={`w-4 h-4 rounded border flex items-center justify-center ${recommendBetter ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{recommendBetter && renderCheckIcon()}</div>
<span className="text-sm">推荐更优答案</span>
</label>
</div>
</div>
)} */}
</>
)}
</div>
);
}
@@ -0,0 +1,85 @@
import { History } from 'lucide-react';
import type { PageType } from '../../components/Sidebar';
import type { ViewMode } from './GeneralQA.types';
/**
* 顶部栏 Props。
*
* `setViewMode` 和 `onNavigate` 由父组件传入,父组件会在生成中切换时做确认拦截;
* Header 本身不直接感知 loading,保持纯展示和事件转发。
*/
type Props = {
isDark: boolean; // 当前是否为暗黑主题,用于样式适配
viewMode: ViewMode; // 当前视图模式:'single' 单一模式 或 'compare' 对比模式
chatHeadTitle?: string;
chatDesign?: string;
compareShow?: boolean;
setViewMode: (mode: ViewMode) => void; // 设置视图模式回调函数
onNavigate: (page: PageType) => void; // 页面导航回调函数,用于跳转到其他页面(如历史记录)
};
/**
* 通用问答页面顶部栏。
*
* 该组件只负责页面标题、模式切换和历史记录入口,所有业务状态仍由父组件维护。
* 这样拆分后,顶部导航的样式和交互不会和中间问答流处理逻辑混在一起,后续如果要新增
* “保存模板”“导出记录”等入口,也可以只改这个文件。
*/
export default function GeneralQAHeader({ isDark, viewMode, chatHeadTitle = '通用问答', chatDesign = '支持单轮RAG、多轮RAG、深度检索及结果对比', compareShow = true, setViewMode, onNavigate }: Props): JSX.Element {
return (
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className="flex items-center gap-4">
<h1 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{chatHeadTitle}
</h1>
<span className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{chatDesign}
</span>
</div>
{
compareShow && <div className="flex items-center gap-2">
<div className={`flex rounded-lg p-1 ${isDark ? 'bg-slate-700/50' : 'bg-red-50'}`}>
<button
onClick={() => setViewMode('single')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${viewMode === 'single'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
单一模式
</button>
<button
onClick={() => setViewMode('compare')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${viewMode === 'compare'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
对比模式
</button>
</div>
{/* <button
onClick={() => onNavigate('qa-records')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
isDark
? 'bg-slate-700/50 text-slate-300 hover:bg-slate-600/50'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<History className="w-4 h-4" />
历史记录
</button> */}
</div>
}
</div>
);
}
File diff suppressed because it is too large. Load diff
+410
View File
@@ -0,0 +1,410 @@
.mope-chat {
--bg: #ffffff;
--surface: #ffffff;
--surface-raised: #ffffff;
--card: #ffffff;
--border: #fee2e2;
--border-strong: #fecaca;
--text: #111827;
--text-muted: #6b7280;
--accent: #dc2626;
--accent-hover: #ef4444;
--green: #16a34a;
--yellow: #f59e0b;
--red: #dc2626;
--purple: #7c3aed;
--button-text: #ffffff;
--surface-hover: rgba(220, 38, 38, 0.04);
--surface-hover-strong: rgba(220, 38, 38, 0.08);
--accent-soft: rgba(220, 38, 38, 0.08);
--accent-soft-strong: rgba(220, 38, 38, 0.16);
--danger-soft: rgba(220, 38, 38, 0.10);
--shadow-soft: 0 14px 40px rgba(15, 23, 42, 0.07);
--shadow-card: 0 8px 24px rgba(15, 23, 42, 0.05);
--text-primary: var(--text);
--text-secondary: var(--text-muted);
--border-color: var(--border);
--bg-secondary: var(--surface);
--accent-blue: var(--accent);
--phase-starting: var(--accent);
--phase-diverging: var(--yellow);
--phase-closing: var(--green);
--phase-done: var(--purple);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 14px;
line-height: 1.6;
color: var(--text);
background: var(--bg);
}
.mope-chat.mope-chat-dark {
--bg: #0f172a;
--surface: #1e293b;
--surface-raised: #111827;
--card: #0f172a;
--border: rgba(30, 64, 175, 0.45);
--border-strong: rgba(59, 130, 246, 0.45);
--text: #f8fafc;
--text-muted: #94a3b8;
--accent: #3b82f6;
--accent-hover: #60a5fa;
--green: #10b981;
--yellow: #f59e0b;
--red: #f43f5e;
--purple: #8b5cf6;
--button-text: #ffffff;
--surface-hover: rgba(255, 255, 255, 0.03);
--surface-hover-strong: rgba(255, 255, 255, 0.06);
--accent-soft: rgba(59, 130, 246, 0.14);
--accent-soft-strong: rgba(59, 130, 246, 0.22);
--danger-soft: rgba(244, 63, 94, 0.12);
--shadow-soft: 0 18px 45px rgba(0, 0, 0, 0.28);
--shadow-card: 0 12px 28px rgba(0, 0, 0, 0.22);
}
.mope-chat,
.mope-chat * {
box-sizing: border-box;
}
.mope-chat {
color: var(--text);
min-height: 100%;
transition: background 0.2s ease, color 0.2s ease;
}
.mope-chat input,
.mope-chat textarea,
.mope-chat select,
.mope-chat button {
font: inherit;
}
.mope-chat p,
.mope-chat h1,
.mope-chat h2,
.mope-chat h3,
.mope-chat h4,
.mope-chat pre {
margin-top: 0;
}
.mope-chat ::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.mope-chat ::-webkit-scrollbar-track {
background: var(--surface);
}
.mope-chat ::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
.mope-chat ::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Idle: centered form */
.mope-chat.app-idle {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 96px);
padding: 36px 40px 42px;
background:
radial-gradient(circle at top center, var(--accent-soft) 0, transparent 34%),
var(--bg);
}
.mope-chat.app-idle-scroll {
align-items: flex-start;
overflow-y: auto;
}
/* Running / done: two-column */
.mope-chat.app-running {
display: flex;
flex-direction: column;
height: calc(100vh - 96px);
overflow: hidden;
background: var(--surface-raised);
border-radius: 18px;
border: 1px solid var(--border);
box-shadow: var(--shadow-card);
}
.mope-chat .app-header {
background: color-mix(in srgb, var(--surface) 92%, var(--accent) 8%);
border-bottom: 1px solid var(--border);
padding: 14px 18px;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.mope-chat .app-header-logo {
font-weight: 700;
font-size: 15px;
color: var(--accent);
letter-spacing: 0.5px;
}
.mope-chat .app-header-task {
flex: 1;
color: var(--text-muted);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mope-chat .app-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.mope-chat .app-body {
display: flex;
flex: 1;
overflow: hidden;
}
.mope-chat .app-sidebar {
width: 250px;
flex-shrink: 0;
overflow-y: auto;
border-right: 1px solid var(--border);
background: color-mix(in srgb, var(--surface) 96%, var(--accent) 4%);
}
.mope-chat .app-main {
flex: 1;
overflow-y: auto;
padding: 24px;
background: var(--bg);
}
.mope-chat .app-progress-sidebar {
width: 320px;
flex-shrink: 0;
overflow-y: auto;
border-left: 1px solid var(--border);
background: color-mix(in srgb, var(--surface) 96%, var(--accent) 4%);
}
.mope-chat .app-side-panel {
position: relative;
transition: width 0.22s ease, background 0.2s ease;
}
.mope-chat .app-side-panel.collapsed {
width: 44px;
overflow: hidden;
}
.mope-chat .app-side-toggle {
position: sticky;
top: 10px;
z-index: 2;
width: 28px;
height: 28px;
min-width: 28px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface-raised);
color: var(--text-muted);
box-shadow: var(--shadow-card);
}
.mope-chat .app-side-toggle:hover:not(:disabled) {
color: var(--accent);
border-color: var(--border-strong);
background: var(--surface-hover-strong);
transform: translateY(-1px);
}
.mope-chat .app-side-toggle-left {
float: right;
margin: 10px 10px 0 0;
}
.mope-chat .app-side-toggle-right {
float: left;
margin: 10px 0 0 10px;
}
.mope-chat .app-side-panel.collapsed .app-side-toggle-left,
.mope-chat .app-side-panel.collapsed .app-side-toggle-right {
float: none;
margin: 10px auto 0;
}
/* Buttons */
.mope-chat button {
cursor: pointer;
border: none;
border-radius: 10px;
padding: 8px 16px;
font-size: 13px;
font-family: inherit;
transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s, background 0.15s, border-color 0.15s, color 0.15s;
}
.mope-chat button:hover:not(:disabled) {
transform: translateY(-1px);
}
.mope-chat button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mope-chat .btn-primary {
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: var(--button-text);
font-weight: 600;
box-shadow: 0 10px 22px var(--accent-soft-strong);
}
.mope-chat .btn-primary:hover:not(:disabled) {
opacity: 0.85;
}
.mope-chat .btn-danger {
background: transparent;
color: var(--red);
border: 1px solid var(--red);
}
.mope-chat .btn-danger:hover:not(:disabled) {
background: var(--danger-soft);
}
.mope-chat .btn-secondary {
background: var(--surface-raised);
color: var(--text-muted);
border: 1px solid var(--border);
}
.mope-chat .btn-secondary:hover:not(:disabled) {
color: var(--text);
border-color: var(--border-strong);
background: var(--surface-hover);
}
.mope-chat .btn-ghost {
background: transparent;
color: var(--text-muted);
padding: 4px 8px;
font-size: 12px;
}
.mope-chat .btn-ghost:hover {
color: var(--text);
}
/* Badges */
.mope-chat .badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
}
.mope-chat .badge-starting { background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); }
.mope-chat .badge-diverging { background: color-mix(in srgb, var(--yellow) 15%, transparent); color: var(--yellow); }
.mope-chat .badge-closing { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); }
.mope-chat .badge-done { background: color-mix(in srgb, var(--purple) 15%, transparent); color: var(--purple); }
.mope-chat .badge-default { background: color-mix(in srgb, var(--text-muted) 15%, transparent); color: var(--text-muted); }
/* Spinner */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.mope-chat .spinner {
display: inline-block;
animation: spin 1s linear infinite;
}
/* Reference cite */
.mope-chat .ref-cite {
display: inline-block;
background: var(--accent-soft-strong);
color: var(--accent);
border-radius: 4px;
padding: 0 3px;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
cursor: pointer;
position: relative;
}
.mope-chat .ref-cite:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
background: var(--card);
border: 1px solid var(--border);
color: var(--text);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
white-space: nowrap;
z-index: 100;
font-family: inherit;
pointer-events: none;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Error banner */
.mope-chat .error-banner {
background: var(--danger-soft);
border: 1px solid var(--red);
border-radius: 6px;
padding: 8px 12px;
color: var(--red);
font-size: 13px;
margin-bottom: 12px;
}
@media (max-width: 1200px) {
.mope-chat .app-progress-sidebar {
width: 260px;
}
}
@media (max-width: 960px) {
.mope-chat .app-body {
flex-direction: column;
}
.mope-chat .app-sidebar,
.mope-chat .app-progress-sidebar {
width: 100%;
border-right: none;
border-left: none;
border-bottom: 1px solid var(--border);
max-height: 260px;
}
.mope-chat .app-main {
padding: 14px;
}
}
+349
View File
@@ -0,0 +1,349 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useSession } from '@/hooks/useSession';
import { useTheme } from '../../contexts/ThemeContext';
import ConfigPanel from './components/ConfigPanel';
import Sidebar from './components/Sidebar';
import Timeline from './components/Timeline';
import ProgressSidebar from './components/ProgressSidebar';
import ScenarioConfigPage from './components/ScenarioConfigPage';
import './MopeChat.css';
import { useChatContext } from '@/contexts/ChatContext';
import { requestCrudService } from '@/service/request-axios-service';
type StartConfig = {
task_context?: string;
[key: string]: any;
};
type SessionError = {
agent_id?: string;
error?: string;
};
type SessionHistoryPayload = {
typeName: string;
templateName: string;
resultJson: Record<string, any>;
};
const MOPE_HISTORY_API_BASE = '/api/mope/history';
const backendUrls = import.meta.env.VITE_AIPI_MOPECHAT_URL || '';
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
const service = requestCrudService;
/**
* 保存模型完整生成结果。
*
* 接口暂未提供,先把调用方法和请求体结构固定下来:
* - typeName:类型名字,目前使用用户输入的任务背景。
* - templateName:用户选择的生成模板名称。
* - resultJson:完整结果 JSON,包含 timeline、agentStates、reportSchema、finalReport 等恢复页面所需数据。
*/
async function saveMopeHistory(payload: SessionHistoryPayload) {
console.log('保存了吗',payload )
const response = await fetch(MOPE_HISTORY_API_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`保存 MOPE 历史失败:HTTP ${response.status}`);
}
return response.json().catch(() => null);
}
/**
* 获取当前用户最近一次 MOPE 生成历史。
*
* 接口暂未提供,先约定返回值就是保存时的 resultJson。
* 如果接口没有历史数据,应返回 null / 空对象 / 404,页面会保持当前未生成状态。
*/
async function fetchMopeHistory(): Promise<Record<string, any> | null> {
const response = await fetch(MOPE_HISTORY_API_BASE, { method: 'GET' });
if (response.status === 404 || response.status === 204) return null;
if (!response.ok) {
throw new Error(`获取 MOPE 历史失败:HTTP ${response.status}`);
}
const data = await response.json();
return data?.resultJson || data || null;
}
export default function MopeChat() {
const session = useSession() as any;
const { theme } = useTheme() as { theme?: string };
const { startConfig } = useChatContext();
const isDark = theme === 'dark';
const [taskContext, setTaskContext] = useState<string>('');
const [idlePage, setIdlePage] = useState<string>('home');
const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
const [isRightSidebarCollapsed, setIsRightSidebarCollapsed] = useState(false);
const {
status,
phase,
coordinatorMode,
currentRound,
divergeRounds,
reportSchema,
agents,
agentStates,
timeline,
sharedMemory,
finalReportPartial,
currentFinalField,
pendingReview,
reviewSubmitting,
reviewRevising,
errors,
startSession,
stopSession,
approveCoordinator,
reviseCoordinator,
hydrateFromSnapshot,
reset,
} = session;
const historyLoadedRef = useRef(false);
const savedHistoryKeyRef = useRef<string | null>(null);
const currentConfigRef = useRef<StartConfig | null>(null);
const buildHistorySnapshot = useCallback(() => ({
status,
phase,
coordinatorMode,
currentRound,
divergeRounds,
reportSchema,
agents,
agentStates,
timeline,
sharedMemory,
finalReportPartial,
currentFinalField,
config: currentConfigRef.current,
}), [
status,
phase,
coordinatorMode,
currentRound,
divergeRounds,
reportSchema,
agents,
agentStates,
timeline,
sharedMemory,
finalReportPartial,
currentFinalField,
]);
const handleStart = (config: StartConfig, backendUrl: string) => {
currentConfigRef.current = config;
savedHistoryKeyRef.current = null;
setTaskContext(config.task_context || '');
console.log('config', config)
startSession(config, backendUrl);
};
const handleStop = () => {
console.log('state', )
stopSession();
const snapshot = buildHistorySnapshot();
const templateName = currentConfigRef.current?.template_name || '未命名模板';
const typeName = taskContext || currentConfigRef.current?.task_context || '未命名任务';
const saveKey = `${typeName}::${templateName}::${timeline.length}`;
if (savedHistoryKeyRef.current === saveKey) return;
savedHistoryKeyRef.current = saveKey;
void saveMopeHistory({
typeName,
templateName,
resultJson: {
...snapshot,
typeName,
templateName,
savedAt: new Date().toISOString(),
},
}).catch((error) => {
console.warn('MOPE 历史记录保存失败:', error);
savedHistoryKeyRef.current = null;
});
};
const handleReset = () => {
reset();
currentConfigRef.current = null;
savedHistoryKeyRef.current = null;
setTaskContext('');
};
useEffect(() => {
if (startConfig) {
handleStart({
...startConfig,
coordinator_mode: 'human_review',
}, backendUrls);
}
console.log('startConfig', startConfig)
}, [])
useEffect(() => {
if (historyLoadedRef.current) return;
historyLoadedRef.current = true;
void fetchMopeHistory()
.then((snapshot) => {
if (!snapshot || !Array.isArray(snapshot.timeline) || snapshot.timeline.length === 0) return;
currentConfigRef.current = snapshot.config || null;
setTaskContext(snapshot.config?.task_context || snapshot.typeName || '');
hydrateFromSnapshot(snapshot);
})
.catch((error) => {
console.warn('MOPE 历史记录获取失败,保持默认空状态:', error);
});
}, [hydrateFromSnapshot]);
useEffect(() => {
console.log('status', status)
if (status !== 'done' || !timeline?.length) return;
const snapshot = buildHistorySnapshot();
const templateName = currentConfigRef.current?.template_name || '未命名模板';
const typeName = taskContext || currentConfigRef.current?.task_context || '未命名任务';
const saveKey = `${typeName}::${templateName}::${timeline.length}`;
if (savedHistoryKeyRef.current === saveKey) return;
savedHistoryKeyRef.current = saveKey;
void saveMopeHistory({
typeName,
templateName,
resultJson: {
...snapshot,
typeName,
templateName,
savedAt: new Date().toISOString(),
},
}).catch((error) => {
console.warn('MOPE 历史记录保存失败:', error);
savedHistoryKeyRef.current = null;
});
}, [status, timeline, taskContext, buildHistorySnapshot]);
if (status === 'idle') {
if (idlePage === 'scenario_config') {
return (
<div className={`mope-chat app-idle app-idle-scroll ${isDark ? 'mope-chat-dark' : 'mope-chat-light'}`}>
<ScenarioConfigPage onBack={() => setIdlePage('home')} />
</div>
);
}
return (
<div className={`mope-chat app-idle ${isDark ? 'mope-chat-dark' : 'mope-chat-light'}`}>
<ConfigPanel
onStart={handleStart}
isRunning={false}
onStop={handleStop}
onOpenScenarioConfig={() => setIdlePage('scenario_config')}
/>
</div>
);
}
return (
<div className={`mope-chat app-running ${isDark ? 'mope-chat-dark' : 'mope-chat-light'}`}>
<header className="app-header">
<span className="app-header-logo">MOPE</span>
<span className="app-header-task">
{taskContext ? `"${taskContext.slice(0, 80)}${taskContext.length > 80 ? '…' : ''}"` : '分析中…'}
</span>
<span className="badge badge-default">
{coordinatorMode === 'human_review' ? '人工确认' : '全自动'}
</span>
{status === 'waiting_human' && (
<span className="badge badge-diverging">等待人工确认</span>
)}
<div className="app-header-actions">
{(status === 'running' || status === 'waiting_human') && (
<button className="btn-danger" onClick={handleStop}>
停止
</button>
)}
{(status === 'done' || status === 'error') && (
<button className="btn-secondary" onClick={handleReset}>
重新开始
</button>
)}
</div>
</header>
<div className="app-body">
<aside className={`app-sidebar app-side-panel${isLeftSidebarCollapsed ? ' collapsed' : ''}`}>
<button
type="button"
className="app-side-toggle app-side-toggle-left"
onClick={() => setIsLeftSidebarCollapsed(v => !v)}
aria-label={isLeftSidebarCollapsed ? '展开左侧栏' : '收起左侧栏'}
title={isLeftSidebarCollapsed ? '展开左侧栏' : '收起左侧栏'}
>
{isLeftSidebarCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</button>
{!isLeftSidebarCollapsed && (
<Sidebar
agents={agents}
agentStates={agentStates}
phase={phase}
currentRound={currentRound}
divergeRounds={divergeRounds}
/>
)}
</aside>
<main className="app-main">
{errors.length > 0 && errors.map((e: SessionError, i: number) => (
<div key={i} className="error-banner">
{e.agent_id ? `[${e.agent_id}] ` : ''}{e.error}
</div>
))}
<Timeline
timeline={timeline}
agentStates={agentStates}
reportSchema={reportSchema}
pendingReview={pendingReview}
onApproveReview={approveCoordinator}
onReviseReview={reviseCoordinator}
reviewSubmitting={reviewSubmitting}
reviewRevising={reviewRevising}
/>
</main>
<aside className={`app-progress-sidebar app-side-panel${isRightSidebarCollapsed ? ' collapsed' : ''}`}>
<button
type="button"
className="app-side-toggle app-side-toggle-right"
onClick={() => setIsRightSidebarCollapsed(v => !v)}
aria-label={isRightSidebarCollapsed ? '展开右侧栏' : '收起右侧栏'}
title={isRightSidebarCollapsed ? '展开右侧栏' : '收起右侧栏'}
>
{isRightSidebarCollapsed ? <ChevronLeft size={16} /> : <ChevronRight size={16} />}
</button>
{!isRightSidebarCollapsed && (
<ProgressSidebar
phase={phase}
status={status}
currentRound={currentRound}
divergeRounds={divergeRounds}
reportSchema={reportSchema}
sharedMemory={sharedMemory}
finalReportPartial={finalReportPartial}
currentFinalField={currentFinalField}
/>
)}
</aside>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
+9
View File
@@ -0,0 +1,9 @@
import { Outlet } from 'react-router-dom';
export default function QACenterPage() {
return (
<div>
<Outlet />
</div>
);
}
+935
View File
@@ -0,0 +1,935 @@
import React, { useEffect, useState } from 'react';
import { Dialog, Textarea } from 'tdesign-react';
import {
Bookmark,
BrainCircuit,
Bot,
ChevronDown,
FileSearch,
Loader2,
RotateCcw,
Search,
Send,
Settings,
Sparkles,
Square,
Copy,
Trash2,
Edit3,
Upload,
UserRound,
ExternalLink,
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import MarkdownRenderer from '../MarkdownRenderer';
import { SourceItem } from './GeneralQA.types';
/**
* 单条检索结果。
*
* 多轮检索过程中每一轮可能返回多条资料,这里只保留 UI 展示所需的标题、摘要和来源。
*/
export interface QARetrievalItem {
id?: string;
title: string;
content: string;
keywords?: string;
taskDirection?: string;
type?: string;
time?: string;
source?: string;
recUuid?: string;
platform?: string;
fans?: number;
comment_count?: number;
message_id?: string;
hot_num?: number;
}
/**
* 多轮检索中的单轮思考节点。
*
* `queryLoading` 控制该轮检索结果是否仍显示 loading;retrievals 到达后再渲染资料列表。
*/
export interface QAThinkingRound {
id: string; // 唯一标识符,用于列表渲染时的 key
title: string; // 该轮检索的标题,如"第1轮检索"或"背景资料收集"
thoughtMarkdown: string; // 模型思考过程的 Markdown 内容,展示思考步骤和推理逻辑
queryTitle?: string; // 检索内容
query: string; // 该轮发送给检索系统的查询语句
queryLoading?: boolean; // 该轮检索是否仍在进行中,true 时显示加载动画
textidContent?: string; // 新闻详细内容
retrievals: QARetrievalItem[]; // 该轮返回的检索结果列表
queryShow: boolean; // 检索列表为空时是否显示
}
/**
* 多轮/深度检索的完整思考链。
*
* rounds 展示检索过程,finalAnswerMarkdown 展示最终回答;两者分开便于流式过程中逐步更新。
*/
export interface QAThinkingTrace {
title?: string; // 整体思考链的标题
rounds: QAThinkingRound[]; // 多轮检索过程中的所有轮次,每轮包含思考和检索结果
finalAnswerMarkdown: string; // 基于多轮检索结果生成的最终回答内容
thinkingComplete?: boolean; // 思考过程是否已完成,true 时思考区域可折叠
}
/**
* 单轮 RAG 的回答结构。
*
* 后端可能把 `<think>...</think>` 和答案混在同一个 answer 流中,解析后分别放到 thinkingMarkdown 与 answerMarkdown。
*/
export interface QASingleRagTrace {
title?: string; // 思考过程的标题,如"单轮 RAG 思考过程"
thinkingMarkdown: string; // 模型在生成回答前的思考过程内容,使用 Markdown 格式
answerMarkdown: string; // 最终生成的回答内容,使用 Markdown 格式
thinkingComplete?: boolean; // 思考过程是否已完成,true 时思考区域可折叠
}
export interface QACommonMessage {
id: string; // 消息唯一标识符
role: 'user' | 'assistant'; // 消息发送者角色:user 为用户消息,assistant 为 AI 回复
content: string; // 消息的纯文本内容(当使用 thinkingTrace 或 singleRagTrace 时为空)
timestamp?: Date; // 消息发送时间
thinkingTrace?: QAThinkingTrace; // 多轮/深度检索模式的思考链,包含多轮检索过程和最终回答
sources?: { // 消息引用的参考资料列表
title: string; // 参考资料标题
type: string; // 资料类型,如"文档"、"网页"等
snippet: string; // 资料摘要内容
source?: string; // 来源名称或 URL
time?: string; // 发布时间
url?: string; // 资料链接
content?: string; // 完整内容(可能用于详情弹窗)
}[];
}
export interface QACommonMessage {
singleRagTrace?: QASingleRagTrace; // 单轮 RAG 模式的思考过程和回答结构
}
interface QAAvatarProps {
role: 'user' | 'assistant';
isActive?: boolean;
label?: string;
variant?: 'default' | 'blue' | 'purple';
}
const getAssistantAvatarClass = (variant: QAAvatarProps['variant'], isDark: boolean) => {
switch (variant) {
case 'blue':
return isDark
? 'bg-gradient-to-br from-blue-400 via-blue-600 to-cyan-700 text-white'
: 'bg-gradient-to-br from-blue-300 via-blue-500 to-cyan-500 text-white';
case 'purple':
return isDark
? 'bg-gradient-to-br from-purple-400 via-violet-600 to-fuchsia-700 text-white'
: 'bg-gradient-to-br from-purple-300 via-violet-500 to-fuchsia-500 text-white';
default:
return isDark
? 'bg-gradient-to-br from-cyan-500 via-blue-600 to-violet-600 text-white'
: 'bg-gradient-to-br from-red-400 via-rose-500 to-orange-400 text-white';
}
};
/**
* 统一头像组件。
*
* 单一模式使用默认助手色;对比模式可通过 variant 指定蓝/紫色系,让头像与左右面板标签保持一致。
* `isActive` 会触发呼吸和星光动画,用来提示当前助手正在生成。
*/
export const QAAvatar: React.FC<QAAvatarProps> = ({ role, isActive = false, label, variant = 'default' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const Icon = role === 'assistant' ? Bot : UserRound;
return (
<div
className={`relative w-9 h-9 rounded-2xl flex items-center justify-center flex-shrink-0 overflow-hidden shadow-sm ${role === 'assistant'
? getAssistantAvatarClass(variant, isDark)
: isDark
? 'bg-gradient-to-br from-slate-500 via-slate-700 to-slate-900 text-white'
: 'bg-gradient-to-br from-sky-400 via-indigo-500 to-violet-500 text-white'
} ${isActive ? 'qa-avatar-active' : ''}`}
title={label}
>
<div className="absolute inset-0 bg-white/20 opacity-0 qa-avatar-shine" />
<Icon className={`w-5 h-5 relative z-10 ${isActive ? 'qa-avatar-icon-active' : ''}`} />
{role === 'assistant' && isActive && (
<Sparkles className="absolute -right-0.5 -top-0.5 w-3.5 h-3.5 text-yellow-200 qa-avatar-spark" />
)}
</div>
);
};
interface TypingIndicatorProps {
label?: string;
assistantLabel?: string;
}
/**
* 助手预连接/生成中的占位提示。
*
* 用于请求已经发出但还没有生成可展示消息时,给用户明确的等待反馈。
*/
export const TypingIndicator: React.FC<TypingIndicatorProps> = ({ label = '正在思考并生成回复', assistantLabel = 'AI 助手' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="flex gap-3">
<QAAvatar role="assistant" isActive label={assistantLabel} />
<div className={`max-w-[70%] rounded-2xl px-4 py-3 shadow-sm border ${isDark ? 'bg-slate-700/60 border-slate-600/50 text-slate-200' : 'bg-white border-red-100 text-gray-700'
}`}>
<div className="flex items-center gap-3">
<Loader2 className={`w-4 h-4 animate-spin ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
<span
className={`text-sm font-medium qa-generating-marquee ${isDark ? 'qa-generating-marquee-dark' : 'qa-generating-marquee-light'}`}
>
{label}
</span>
{/* <div className="flex gap-1">
{[0, 1, 2].map((idx) => (
<span
key={idx}
className={`w-1.5 h-1.5 rounded-full qa-typing-dot ${isDark ? 'bg-blue-300' : 'bg-red-400'}`}
style={{
animationDelay: `${idx * 160}ms`,
}}
/>
))}
</div> */}
</div>
</div>
</div>
);
};
interface ChatMessageProps<T extends QACommonMessage> {
message: T;
showSources?: boolean;
assistantLabel?: string;
assistantActive?: boolean;
onCitationClick?: (message: T, citationIndex: number) => void;
onCopyAssistant?: (message: T) => void;
onDeleteAssistant?: (message: T) => void;
onEditAssistant?: (message: T) => void;
onResendAssistant?: (message: T) => void;
renderAssistantExtra?: (message: T) => React.ReactNode;
}
/**
* 聊天消息渲染器。
*
* 根据消息类型自动选择普通文本、单轮 RAG 回答或多轮检索思考链。
* sources 区域独立折叠展示,避免引用资料过长时挤占主回答阅读空间。
*/
export function ChatMessage<T extends QACommonMessage>({
message,
showSources = true,
assistantLabel = 'AI 助手',
assistantActive = false,
onCitationClick,
onCopyAssistant,
onDeleteAssistant,
onEditAssistant,
onResendAssistant,
renderAssistantExtra,
}: ChatMessageProps<T>) {
const { theme } = useTheme();
const isDark = theme === 'dark';
const isUser = message.role === 'user';
const hasTrace = !isUser && !!(message.singleRagTrace || message.thinkingTrace);
const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL;
const [visible, setVisible] = useState(false); // 打开详情对话框
const [listData, setListData] = useState<SourceItem>({
title: '',
type: '',
time: '',
snippet: '',
source: '',
content: '',
});
const showDialog = (item: SourceItem) => {
console.log('item', item)
if (item.url) {
window.open(`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${item.url}`);
} else {
setListData(item);
setVisible(true);
}
};
return (
<div className="space-y-3">
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
{!isUser && <QAAvatar role="assistant" isActive={assistantActive} label={assistantLabel} />}
<div className={`${hasTrace ? 'max-w-[85%] flex-1' : 'max-w-[72%]'} rounded-2xl px-4 py-3 shadow-sm ${isUser
? isDark
? 'bg-blue-600/70 text-white'
: 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
: message.singleRagTrace || message.thinkingTrace
? isDark
? 'bg-slate-800/40 text-slate-100 border border-slate-600/40'
: 'bg-white text-gray-800 border border-transparent shadow-[0px_3px_12px_rgba(0, 0, 0, 0.12)] px-0 py-0'
: isDark
? 'bg-slate-700/60 text-slate-100 border border-slate-600/40'
: 'bg-white text-gray-800 border border-gray-100'
}`}>
{message.singleRagTrace && !isUser ? (
<SingleRagAnswer
trace={message.singleRagTrace}
onCitationClick={(citationIndex) => onCitationClick?.(message, citationIndex)}
/>
) : message.thinkingTrace && !isUser ? (
<RetrievalThinkingTrace
trace={message.thinkingTrace}
onCitationClick={(citationIndex) => onCitationClick?.(message, citationIndex)}
/>
) : (
<p className="whitespace-pre-wrap leading-relaxed">{message.content}</p>
)}
{!isUser && (onCopyAssistant || onDeleteAssistant || onEditAssistant || onResendAssistant) && (
<div className={`mt-2 flex items-center justify-end gap-1.5 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{onCopyAssistant && (
<button type="button" onClick={() => onCopyAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-slate-100' : 'hover:bg-gray-100 hover:text-gray-700'}`} title="复制回答">
<Copy className="w-3.5 h-3.5" />
</button>
)}
{onDeleteAssistant && (
<button type="button" onClick={() => onDeleteAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-rose-300' : 'hover:bg-red-50 hover:text-red-600'}`} title="删除问答">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{onEditAssistant && (
<button type="button" onClick={() => onEditAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-blue-300' : 'hover:bg-blue-50 hover:text-blue-600'}`} title="编辑回答和思考过程">
<Edit3 className="w-3.5 h-3.5" />
</button>
)}
{onResendAssistant && (
<button type="button" onClick={() => onResendAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-emerald-300' : 'hover:bg-emerald-50 hover:text-emerald-600'}`} title="重新发送">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
)}
</div>
{isUser && <QAAvatar role="user" label="用户" />}
</div>
{!isUser && message.sources && message.sources.length > 0 && showSources && (
<CollapsibleSection
defaultOpen={false}
className={`ml-12 ${hasTrace ? 'max-w-[85%]' : 'max-w-[72%]'} ${isDark ? 'bg-slate-800/50 border-slate-700/50' : 'bg-gray-50 border-gray-200'}`}
title={(
<div className="flex items-center gap-2">
<BrainCircuit className={`w-3.5 h-3.5 ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
<span className={`text-xs font-medium ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>引用来源</span>
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>({message.sources.length})</span>
</div>
)}
>
<div className="space-y-2 pt-[10px]">
{message.sources.map((source, idx) => (
<div key={idx} className={`text-xs p-2 rounded-[4px] border shadow-sm transition-all duration-200 ease-out cursor-pointer hover:-translate-y-0.5 hover:scale-[1.01] ${isDark ? 'bg-slate-700/50 border-slate-600/50 shadow-black/10 hover:bg-slate-700/80 hover:border-blue-400/40 hover:shadow-lg hover:shadow-blue-950/20' : 'bg-white border-gray-100 shadow-gray-200/60 hover:border-red-200 hover:shadow-md hover:shadow-red-100/70'}`} onClick={() => showDialog(source)}>
<div className="flex items-center gap-2 mb-1 min-w-0">
<BrainCircuit className={`w-3 h-3 shrink-0 ${isDark ? 'text-blue-400' : 'text-red-400'}`} />
<span className={`font-medium truncate min-w-0 flex-1 ${isDark ? 'text-slate-300' : 'text-gray-700'}`} title={source.title}>{source.title}</span>
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs ${isDark ? 'bg-slate-600 text-slate-300' : 'bg-gray-200 text-gray-500'}`}>
{source.type}
</span>
</div>
<p className={`line-clamp-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{source.snippet}</p>
</div>
))}
</div>
</CollapsibleSection>
)}
<Dialog
visible={visible}
onClose={() => setVisible(false)}
header="提示"
footer={null}
>
<div>
<div className="pr-9">
<h3 className="text-base font-semibold">{listData.title || '引用内容'}</h3>
<div className={`mt-2 text-xs space-y-1 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<div>来源:{listData.source || '未知来源'}</div>
<div>时间:{listData.time || '未知'}</div>
</div>
</div>
{listData.url && (
<div className="mt-4">
<a
href={`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${listData.url}`}
target="_blank"
rel="noreferrer"
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm transition-all ${isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
title="打开原始链接"
>
<ExternalLink className="w-4 h-4" />
查看原文链接
</a>
</div>
)}
<div className={`mt-4 rounded-xl p-3 text-sm leading-relaxed whitespace-pre-wrap ${isDark ? 'bg-slate-800/70 text-slate-200' : 'bg-gray-50 text-gray-700'
}`}>
{listData.content || '暂无引用内容'}
</div>
</div>
</Dialog>
{!isUser && renderAssistantExtra?.(message)}
</div>
);
}
interface CollapsibleSectionProps {
title: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
className?: string;
headerClassName?: string;
}
/**
* 通用折叠面板。
*
* 思考过程、检索结果、引用来源都复用该组件,保证展开/收起动画和深浅色主题一致。
*/
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
children,
defaultOpen = true,
className = '',
headerClassName = '',
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [open, setOpen] = useState(defaultOpen);
// 当 defaultOpen 改变时,更新 open 状态
useEffect(() => {
setOpen(defaultOpen);
}, [defaultOpen]);
return (
<div className={`rounded-2xl border overflow-hidden ${isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'} ${className}`}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className={`w-full flex items-center justify-between gap-3 px-4 py-3 text-left transition-colors ${isDark ? 'hover:bg-slate-800/70' : 'hover:bg-red-50/60'
} ${headerClassName}`}
>
<div className="flex-1 min-w-0">{title}</div>
<ChevronDown
className={`w-4 h-4 flex-shrink-0 transition-transform duration-300 ease-out ${open ? 'rotate-0' : '-rotate-90'
} ${isDark ? 'text-slate-400' : 'text-gray-500'}`}
/>
</button>
<div className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}>
<div className="overflow-hidden">
<div className={`px-4 pb-4 transition-transform duration-300 ease-out ${open ? 'translate-y-0' : '-translate-y-2'} ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
{children}
</div>
</div>
</div>
</div>
);
};
interface SingleRagAnswerProps {
trace: QASingleRagTrace;
onCitationClick?: (citationIndex: number) => void;
}
/**
* 单轮 RAG 回答展示。
*
* 思考内容和最终回答分成两个区域:思考区可折叠,回答区始终展示。
* 这样既保留模型推理过程,也不会影响用户优先阅读最终答案。
*/
export const SingleRagAnswer: React.FC<SingleRagAnswerProps> = ({ trace, onCitationClick }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<CollapsibleSection
defaultOpen={!trace.thinkingComplete}
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
title={
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${isDark ? 'bg-violet-500/15 text-violet-300' : 'bg-orange-50 text-orange-500'
}`}>
<Sparkles className="w-5 h-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>{trace.title || '思考过程'}</div>
{trace.thinkingComplete ? (
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
思考已完成,可展开查看
</div>
) : (
<div className={`text-xs qa-generating-marquee ${isDark ? 'qa-generating-marquee-dark text-slate-300' : 'qa-generating-marquee-light text-gray-600'}`}>
正在接收 &lt;think&gt; 流式思考内容
</div>
)}
</div>
</div>
}
>
{trace.thinkingMarkdown ? (
<MarkdownRenderer
content={trace.thinkingMarkdown}
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
) : (
<div className={`text-sm flex items-center gap-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
等待模型输出思考内容...
</div>
)}
</CollapsibleSection>
<div className={`rounded-2xl border p-4 ${isDark ? 'bg-slate-700/60 border-slate-600/40' : 'bg-white border-gray-100'}`}>
{trace.answerMarkdown ? (
<MarkdownRenderer
content={trace.answerMarkdown}
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
) : (
<div className={`text-sm flex items-center gap-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
{trace.thinkingComplete ? '正在流式生成回答...' : '思考结束后开始生成回答...'}
</div>
)}
</div>
</div>
);
};
interface RetrievalListProps {
items: QARetrievalItem[];
}
/**
* 检索结果列表。
*
* 每条资料默认折叠,首条默认展开,便于用户快速看到最相关资料,同时保留完整资料明细。
*/
const RetrievalList: React.FC<RetrievalListProps> = ({ items }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [visible, setVisible] = useState(false); // 打开详情对话框
const [listData, setListData] = useState<QARetrievalItem>({
title: '',
content: '',
});
const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL;
const b1SearchUrl = import.meta.env.VITE_AIPI_B1COH_URL;
const b2SearchUrl = import.meta.env.VITE_AIPI_B2ACTOR_URL;
const showDialog = (item: QARetrievalItem) => {
console.log('item', item)
if (item.type === 'more') {
if (item.recUuid) {
window.open(`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${item.recUuid}`);
} else {
setListData(item);
setVisible(true);
}
} else if (item.type === 'task') {
window.open(`${b1SearchUrl}/#/task/item-aicoh/step-info-search?id=${item.id}`)
} else if (item.type === 'hx') {
window.open(`${b2SearchUrl}/fromB/toDetail?user_id=${item.id}&platform=${item.platform}`);
} else {
setListData(item);
setVisible(true);
}
};
return (
<div className="space-y-2">
{items.map((item, index) => (
<div key={index}>
{
item.type !== 'user_behavior' ? <CollapsibleSection
key={index}
defaultOpen={index === 0}
className={isDark ? 'bg-slate-800/40' : 'bg-gray-50/70'}
title={
<div className="flex items-center gap-2">
<span className={`w-6 h-6 rounded-lg flex items-center justify-center text-xs font-semibold ${isDark ? 'bg-blue-500/20 text-blue-300' : 'bg-red-100 text-red-600'
}`}>{index + 1}</span>
<div className="min-w-0">
<div className={`text-sm font-medium truncate ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{item.title}</div>
{
item.type === 'more' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>时间:{item.time || '-'}</div>
}
{
item.type === 'task' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>关键词:{item.keywords || '-'}</div>
}
{
item.type === 'hx' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}><span>平台:{item.platform || '-'}</span><span className="ml-[10px]">粉丝数:{item.fans || '-'}</span></div>
}
{
(item.type === 'taiwan_news' || item.type === 'world_news' || item.type === 'messaage_comment' || item.type === 'america_news') && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}><span>平台:{item.platform || '-'}</span>{item.comment_count && <span className="ml-[10px]">评论数:{item.comment_count || '-'}</span>}</div>
}
{
item.type === 'get_time_data' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}><span>时间:{item.time || '-'}</span><span className="ml-[10px]">热度:{item.hot_num || '-'}</span></div>
}
</div>
</div>
}
>
<p className={`text-sm leading-relaxed cursor-progress line-clamp-3 hover:underline ${isDark ? 'text-slate-300' : 'text-gray-600'}`} onClick={() => showDialog(item)}>{item.content}</p>
</CollapsibleSection>
: <div className="rounded-[5px] border border-[#f5f5f5] p-[10px]" key={index + 10}>
<span className="font-bold mr-[10px]">{index + 1}.</span>
<span>{item.title}</span>
</div>
}
</div>
))}
<Dialog
visible={visible}
onClose={() => setVisible(false)}
header="提示"
footer={null}
>
<div>
<div className="pr-9">
<h3 className="text-base font-semibold">{listData.title}</h3>
<div className={`mt-2 text-xs space-y-1 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{listData.time && <span>时间:{listData.time || '未知'}</span>}
{listData.platform && <span className="ml-[10px]">平台:{listData.platform || '未知'}</span>}
{listData.comment_count && <span className="ml-[10px]">评论数:{listData.comment_count || '未知'}</span>}
</div>
</div>
<div className={`mt-4 rounded-xl p-3 text-sm leading-relaxed whitespace-pre-wrap ${isDark ? 'bg-slate-800/70 text-slate-200' : 'bg-gray-50 text-gray-700'
}`}>
{listData.content || '暂无引用内容'}
</div>
</div>
</Dialog>
</div>
);
};
interface SearchQueryBadgeProps {
query: string;
loading?: boolean;
queryTitle?: string;
}
/**
* 检索词徽标。
*
* 多轮检索每一轮都会产生一个 query;loading 状态下展示旋转图标,表示该 query 正在召回资料。
*/
const SearchQueryBadge: React.FC<SearchQueryBadgeProps> = ({ query, loading = false, queryTitle = '检索词' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`inline-flex max-w-full items-center gap-2 rounded-full px-3 py-1.5 border ${isDark ? 'bg-blue-500/10 border-blue-400/20 text-blue-200' : 'bg-red-50 border-red-100 text-red-600'
}`}>
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
<span className="text-xs flex-shrink-0">{queryTitle}</span>
<span className={`text-xs font-medium truncate ${loading && 'qa-generating-marquee'} ${loading && (isDark ? 'qa-generating-marquee-dark' : 'qa-generating-marquee-light')}`}>
{query}
</span>
</div>
);
};
interface RetrievalThinkingTraceProps {
trace: QAThinkingTrace;
onCitationClick?: (citationIndex: number) => void;
}
/**
* 多轮/深度检索思考链展示。
*
* 组件按轮次渲染思考内容、检索词和检索结果,底部展示最终回答。
* 它只消费已经整理好的 trace 数据,不关心数据来自真实 SSE 还是本地模拟。
*/
export const RetrievalThinkingTrace: React.FC<RetrievalThinkingTraceProps> = ({ trace, onCitationClick }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<CollapsibleSection
defaultOpen={!trace.thinkingComplete}
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
title={
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
}`}>
<BrainCircuit className="w-5 h-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{trace.title || '思考过程'}
</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
共 {trace.rounds.length} 轮推理与检索,可展开查看细节
</div>
</div>
</div>
}
>
<div className="space-y-3 pt-1">
{trace.rounds.map((round, index) => (
<div key={index} className={`relative pl-5 ${index < trace.rounds.length - 1 ? 'pb-2' : ''}`}>
{index < trace.rounds.length - 1 && (
<div className={`absolute left-[7px] top-8 bottom-0 w-px ${isDark ? 'bg-slate-700' : 'bg-red-100'}`} />
)}
<div className={`absolute left-0 top-4 w-3.5 h-3.5 rounded-full border-2 ${isDark ? 'bg-slate-900 border-cyan-400' : 'bg-white border-red-400'
}`} />
<CollapsibleSection
defaultOpen
className={isDark ? 'bg-slate-900/40' : 'bg-red-50/30'}
title={
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-100 text-red-600'
}`}>第 {index + 1} 轮</span>
<span className={`text-sm font-medium ${round.queryLoading && 'qa-generating-marquee'} ${round.queryLoading && (isDark ? 'text-slate-100 qa-generating-marquee-dark' : 'text-gray-900 qa-generating-marquee-light')}`}>{round.title}</span>
</div>
}
>
<div className="space-y-3">
<CollapsibleSection
defaultOpen
className={isDark ? 'bg-slate-800/50' : 'bg-white/80'}
title={
<div className="flex items-center gap-2">
<Sparkles className={`w-4 h-4 ${isDark ? 'text-violet-300' : 'text-orange-500'}`} />
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-800'}`}>思考内容</span>
</div>
}
>
<MarkdownRenderer
content={round.thoughtMarkdown}
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
</CollapsibleSection>
{
round.queryShow &&
<div className="flex items-center">
<SearchQueryBadge query={round.query} queryTitle={round.queryTitle} loading={round.queryLoading} />
</div>
}
{
round.queryShow &&
<CollapsibleSection
defaultOpen={!round.queryLoading}
className={isDark ? 'bg-slate-800/50' : 'bg-white/80'}
title={
<div className="flex items-center gap-2">
<FileSearch className={`w-4 h-4 ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-800'}`}>检索内容</span>
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{round.retrievals.length} 条</span>
</div>
}
>
{round.queryLoading ? (
<div className={`rounded-xl px-3 py-4 text-sm flex items-center gap-2 ${isDark ? 'bg-slate-900/60 text-slate-400' : 'bg-gray-50 text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
正在召回相关内容...
</div>
) : (
<RetrievalList items={round.retrievals} />
)}
</CollapsibleSection>
}
{
round.textidContent &&
<div className="mt-[10px]">
{round.textidContent}
</div>
}
</div>
</CollapsibleSection>
</div>
))}
</div>
</CollapsibleSection>
<div className={`rounded-2xl border p-4 ${isDark ? 'bg-slate-700/60 border-slate-600/40' : 'bg-white border-gray-100'}`}>
<div className={`text-sm font-semibold mb-3 ${isDark ? 'text-white' : 'text-gray-900'}`}>最终回答</div>
{
!trace.finalAnswerMarkdown && <div className={`rounded-xl px-3 py-4 text-sm flex items-center gap-2 ${isDark ? 'bg-slate-900/60 text-slate-400' : 'bg-gray-50 text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
正在生成思考过程与检索内容...
</div>
}
<MarkdownRenderer
content={trace.finalAnswerMarkdown}
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
</div>
</div>
);
};
interface EmptyChatStateProps {
icon?: React.ReactNode;
title: string;
description: string;
}
/**
* 空状态展示。
*
* 当当前模式没有任何消息时使用,提示用户可以开始输入问题。
*/
export const EmptyChatState: React.FC<EmptyChatStateProps> = ({ icon, title, description }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`h-full flex flex-col items-center justify-center ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
<div className={`w-20 h-20 rounded-3xl mb-5 flex items-center justify-center ${isDark ? 'bg-slate-700/40' : 'bg-gradient-to-br from-red-50 to-orange-50'
}`}>
{icon ?? <Bot className="w-11 h-11 opacity-60" />}
</div>
<p className="text-lg font-medium">{title}</p>
<p className="text-sm mt-2">{description}</p>
</div>
);
};
interface QAComposerProps {
value: string;
placeholder: string;
isLoading: boolean;
canSubmit?: boolean;
onChange: (value: string) => void;
onSubmit: () => void;
onStop: () => void;
onKeyDown?: (e) => void;
onUpload?: () => void;
onReset?: () => void;
onSave?: () => void;
onCollect?: () => void;
onOpenSettings?: () => void;
showUpload?: boolean;
showReset?: boolean;
showSave?: boolean;
showCollect?: boolean;
}
/**
* 问答输入区。
*
* 负责输入、发送/停止、附件、重置、保存、收藏和模型参数设置入口。
* 具体业务动作通过 props 回调交给父组件处理,保证输入区本身不依赖某个问答模式。
*/
export const QAComposer: React.FC<QAComposerProps> = ({
value,
placeholder,
isLoading,
canSubmit,
onChange,
onSubmit,
onStop,
onKeyDown,
onUpload,
onReset,
onSave,
onCollect,
onOpenSettings,
showUpload = true,
showReset = false,
showSave = true,
showCollect = true,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const submitEnabled = canSubmit ?? Boolean(value.trim());
const iconButtonClass = `p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-600 text-slate-400' : 'hover:bg-gray-200 text-gray-500'}`;
const handleKeyDown = (val, context) => {
if (context?.e?.key === 'Enter') {
// 阻止默认的换行行为(如果是 Textarea)
context.e.preventDefault();
// 模拟发送请求
onKeyDown(val)
}
};
return (
<div className={`border-t p-4 ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className={`flex items-end gap-3 rounded-2xl p-3 shadow-inner ${isDark ? 'bg-slate-700/50' : 'bg-gray-100'}`}>
<Textarea
value={value}
onChange={(nextValue) => onChange(String(nextValue))}
onKeydown={handleKeyDown}
placeholder={placeholder}
autosize={{ minRows: 1, maxRows: 15 }}
disabled={isLoading}
className={`flex-1 !bg-transparent !border-0 !shadow-none qa-composer-textarea [&]:!border-0 [&]:!shadow-none [&]:!outline-none [&]:focus:!border-0 [&]:focus:!shadow-none [&]:focus:!outline-none [&_.t-textarea]:!border-0 [&_.t-textarea]:!shadow-none [&_.t-textarea]:!outline-none [&_.t-textarea:hover]:!border-0 [&_.t-textarea:hover]:!shadow-none [&_.t-textarea--focused]:!border-0 [&_.t-textarea--focused]:!shadow-none [&_.t-is-focused]:!border-0 [&_.t-is-focused]:!shadow-none [&_.t-textarea__inner]:!bg-transparent [&_.t-textarea__inner]:!border-0 [&_.t-textarea__inner]:!shadow-none [&_.t-textarea__inner]:!outline-none [&_.t-textarea__inner]:!resize-none [&_.t-textarea__inner]:!min-h-[40px] [&_.t-textarea__inner]:!p-0 [&_.t-textarea__inner:hover]:!border-0 [&_.t-textarea__inner:hover]:!shadow-none [&_.t-textarea__inner:focus]:!border-0 [&_.t-textarea__inner:focus]:!shadow-none [&_.t-textarea__inner:focus]:!outline-none [&_.t-textarea__inner]:disabled:!cursor-not-allowed ${isDark
? '[&_.t-textarea__inner]:!text-white [&_.t-textarea__inner]:placeholder:!text-slate-500'
: '[&_.t-textarea__inner]:!text-gray-800 [&_.t-textarea__inner]:placeholder:!text-gray-400'
}`}
/>
<div className="flex items-center gap-2">
{/* {showUpload && (
<button onClick={onUpload} className={iconButtonClass} title="上传附件" type="button">
<Upload className="w-5 h-5" />
</button>
)}
{showReset && (
<button onClick={onReset} className={iconButtonClass} title="重新提问" type="button">
<RotateCcw className="w-5 h-5" />
</button>
)}
{showSave && (
<button onClick={onSave} className={iconButtonClass} title="保存记录" type="button">
<Bookmark className="w-5 h-5" />
</button>
)}
{showCollect && (
<button onClick={onCollect} className={iconButtonClass} title="加入收藏" type="button">
<Bookmark className="w-5 h-5" />
</button>
)} */}
{onOpenSettings && (
<button onClick={onOpenSettings} className={iconButtonClass} title="模型参数设置" type="button">
<Settings className="w-5 h-5" />
</button>
)}
<button
onClick={isLoading ? onStop : onSubmit}
disabled={!isLoading && !submitEnabled}
className={`px-4 py-2 rounded-xl font-medium transition-all flex items-center gap-2 shadow-sm ${isLoading
? isDark
? 'bg-rose-600 text-white qa-stop-breathing'
: 'bg-red-600 text-white qa-stop-breathing'
: submitEnabled
? isDark
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-red-500 hover:bg-red-600 text-white'
: isDark
? 'bg-slate-600 text-slate-400 cursor-not-allowed'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
type="button"
>
{isLoading ? <Square className="w-4 h-4 fill-current" /> : <Send className="w-4 h-4" />}
{isLoading ? '停止' : '发送'}
</button>
</div>
</div>
</div>
);
};
+5
View File
@@ -0,0 +1,5 @@
import { Outlet, Navigate } from 'react-router-dom';
export default function QALayout() {
return <Outlet />;
}
+121
View File
@@ -0,0 +1,121 @@
import { CircleHelp, Bot, Layers } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
type QAProvider = 'general' | 'agent' | 'multi-agent';
type ViewMode = 'single' | 'compare';
interface QATypeSwitchProps {
qaProvider: QAProvider; // 当前问答模式:'general' 通用问答 / 'agent' 智能体问答 / 'multi-agent' 协同分析
setQaProvider: (provider: QAProvider) => void; // 设置问答模式的回调函数
selectedAgentName?: string; // 当选择智能体问答时,当前选中的智能体名称
viewMode?: ViewMode; // 当前视图模式(仅通用问答支持):'single' 单一模式 或 'compare' 对比模式
setViewMode?: (mode: ViewMode) => void; // 设置视图模式的回调函数(仅通用问答可用)
}
export default function QATypeSwitch({
qaProvider,
setQaProvider,
selectedAgentName,
viewMode = 'single',
setViewMode,
}: QATypeSwitchProps) {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="flex items-center gap-4">
{/* 问答模式切换 */}
<div className={`flex rounded-xl p-1 ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<button
onClick={() => setQaProvider('general')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
qaProvider === 'general'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
<CircleHelp className="w-4 h-4" />
通用问答
</button>
<button
onClick={() => setQaProvider('agent')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
qaProvider === 'agent'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
<Bot className="w-4 h-4" />
智能体问答
</button>
<button
onClick={() => setQaProvider('multi-agent')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
qaProvider === 'multi-agent'
? isDark
? 'bg-green-600 text-white'
: 'bg-green-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-green-600'
}`}
>
<Layers className="w-4 h-4" />
协同分析
</button>
</div>
{/* 当前智能体指示 */}
{qaProvider === 'agent' && selectedAgentName && (
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${isDark ? 'bg-blue-600/20' : 'bg-blue-50'}`}>
<Bot className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
<span className={`text-sm font-medium ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>
{selectedAgentName}
</span>
</div>
)}
{/* 对比模式切换 - 仅通用问答 */}
{qaProvider === 'general' && setViewMode && (
<div className={`flex rounded-xl p-1 ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<button
onClick={() => setViewMode('single')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
viewMode === 'single'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
单一模式
</button>
<button
onClick={() => setViewMode('compare')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
viewMode === 'compare'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
对比模式
</button>
</div>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
+638
View File
@@ -0,0 +1,638 @@
import React, { useState, useEffect, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useTheme } from '../../contexts/ThemeContext';
import {
Edit2, Plus, Trash2, Check, X, ChevronDown, ChevronRight,
Target, Users, Activity, BookOpen, Zap, AlertTriangle,
Settings, FileText, Info, RefreshCw, AlertCircle, Brain, Save,
} from 'lucide-react';
interface FieldRequirement {
title: string;
goal: string;
must_cover: string[];
done_criteria: string[];
blockers: string[];
dependencies: string[];
evidence_needs: string[];
preferred_agents: string[];
style_notes: string;
min_chars?: number;
min_items?: number;
}
interface AgentData {
id: string;
name: string;
dimension: string;
expertise: string;
relevant_fields: string[];
analysis_contract: string[];
}
interface CoordinatorConfig {
system_prompt_prefix?: string;
round1_guidance?: string;
middle_round_guidance?: string;
final_round_guidance?: string;
speaker_selection_rules?: string;
focus_instruction_rules?: string;
dependency_driving_rules?: string[];
consistency_check_rules?: string[];
revise_prompt_prefix?: string;
}
interface TemplateData {
template_id: string;
template_name: string;
template_brief: string;
usage_notes: string[];
report_schema: Record<string, string>;
report_examples: Record<string, string>;
field_requirements: Record<string, FieldRequirement>;
agents: AgentData[];
coordinator_config: CoordinatorConfig;
}
const FIELD_LABELS: Record<string, string> = {
template_id: '模板ID',
template_name: '模板名称',
template_brief: '模板简介',
usage_notes: '使用说明',
report_schema: '报告字段结构',
report_examples: '报告字段示例',
field_requirements: '字段要求配置',
agents: '智能体配置',
coordinator_config: '协调者配置',
title: '字段标题',
goal: '字段目标',
must_cover: '必须覆盖内容',
done_criteria: '完成标准',
blockers: '阻塞条件',
dependencies: '依赖字段',
evidence_needs: '证据需求',
preferred_agents: '推荐智能体',
style_notes: '风格说明',
min_chars: '最小字符数',
min_items: '最小条目数',
id: '智能体ID',
name: '智能体名称',
dimension: '分析维度',
expertise: '专业能力',
relevant_fields: '关联字段',
analysis_contract: '分析契约',
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: '修改提示前缀',
};
const REPORT_SCHEMA_LABELS: Record<string, string> = {
objective_analysis: '一、目的分析',
target_actor_analysis: '二、目标行为体分析',
expected_behavior_drivers: '三、预期行为及行为驱动因素分析',
narrative_theme_design: '四、叙事主题设计',
action_plan_list: '五、认知行动方案列表',
};
interface AgentVisual {
Icon: React.ElementType;
lightCard: string;
darkCard: string;
iconLight: string;
iconDark: string;
tagLight: string;
tagDark: string;
}
const AGENT_VISUALS: Record<string, AgentVisual> = {
objective: {
Icon: Target,
lightCard: 'from-blue-50 to-sky-50 border-blue-200 hover:border-blue-400 hover:shadow-blue-200/60',
darkCard: 'from-slate-900/80 to-blue-950/20 border-slate-700/70 hover:border-blue-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-blue-100 text-blue-600',
iconDark: 'bg-slate-800/90 text-blue-300 ring-1 ring-blue-500/20',
tagLight: 'bg-blue-100 text-blue-700 border-blue-200',
tagDark: 'bg-slate-800/70 text-blue-200 border-blue-500/25',
},
actor: {
Icon: Users,
lightCard: 'from-emerald-50 to-teal-50 border-emerald-200 hover:border-emerald-400 hover:shadow-emerald-200/60',
darkCard: 'from-slate-900/80 to-emerald-950/20 border-slate-700/70 hover:border-emerald-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-emerald-100 text-emerald-600',
iconDark: 'bg-slate-800/90 text-emerald-300 ring-1 ring-emerald-500/20',
tagLight: 'bg-emerald-100 text-emerald-700 border-emerald-200',
tagDark: 'bg-slate-800/70 text-emerald-200 border-emerald-500/25',
},
behavior: {
Icon: Activity,
lightCard: 'from-amber-50 to-orange-50 border-amber-200 hover:border-amber-400 hover:shadow-amber-200/60',
darkCard: 'from-slate-900/80 to-amber-950/20 border-slate-700/70 hover:border-amber-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-amber-100 text-amber-600',
iconDark: 'bg-slate-800/90 text-amber-300 ring-1 ring-amber-500/20',
tagLight: 'bg-amber-100 text-amber-700 border-amber-200',
tagDark: 'bg-slate-800/70 text-amber-200 border-amber-500/25',
},
narrative: {
Icon: BookOpen,
lightCard: 'from-purple-50 to-violet-50 border-purple-200 hover:border-purple-400 hover:shadow-purple-200/60',
darkCard: 'from-slate-900/80 to-purple-950/20 border-slate-700/70 hover:border-purple-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-purple-100 text-purple-600',
iconDark: 'bg-slate-800/90 text-purple-300 ring-1 ring-purple-500/20',
tagLight: 'bg-purple-100 text-purple-700 border-purple-200',
tagDark: 'bg-slate-800/70 text-purple-200 border-purple-500/25',
},
action: {
Icon: Zap,
lightCard: 'from-indigo-50 to-slate-50 border-indigo-200 hover:border-indigo-400 hover:shadow-indigo-200/60',
darkCard: 'from-slate-900/80 to-indigo-950/20 border-slate-700/70 hover:border-indigo-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-indigo-100 text-indigo-600',
iconDark: 'bg-slate-800/90 text-indigo-300 ring-1 ring-indigo-500/20',
tagLight: 'bg-indigo-100 text-indigo-700 border-indigo-200',
tagDark: 'bg-slate-800/70 text-indigo-200 border-indigo-500/25',
},
risk: {
Icon: AlertTriangle,
lightCard: 'from-rose-50 to-pink-50 border-rose-200 hover:border-rose-400 hover:shadow-rose-200/60',
darkCard: 'from-slate-900/80 to-rose-950/20 border-slate-700/70 hover:border-rose-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-rose-100 text-rose-600',
iconDark: 'bg-slate-800/90 text-rose-300 ring-1 ring-rose-500/20',
tagLight: 'bg-rose-100 text-rose-700 border-rose-200',
tagDark: 'bg-slate-800/70 text-rose-200 border-rose-500/25',
},
};
const DEFAULT_VISUAL: AgentVisual = {
Icon: Brain,
lightCard: 'from-gray-50 to-slate-50 border-gray-200 hover:border-gray-400 hover:shadow-gray-200/60',
darkCard: 'from-slate-900/80 to-slate-800/50 border-slate-700/70 hover:border-slate-500/50 hover:shadow-slate-950/50',
iconLight: 'bg-gray-100 text-gray-600',
iconDark: 'bg-slate-800/90 text-slate-300 ring-1 ring-slate-500/20',
tagLight: 'bg-gray-100 text-gray-700 border-gray-200',
tagDark: 'bg-slate-800/70 text-slate-200 border-slate-500/25',
};
const mapFieldLabel = (v: string) => REPORT_SCHEMA_LABELS[v] || v;
function EditableText({
value,
onChange,
isDark,
multiline = false,
}: {
value: string;
onChange: (v: string) => void;
isDark: boolean;
multiline?: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const confirm = () => { onChange(draft); setEditing(false); };
const cancel = () => { setDraft(value); setEditing(false); };
const inputCls = isDark
? 'bg-slate-800 border-blue-500/60 text-slate-100 focus:border-blue-400'
: 'bg-white border-blue-300 text-gray-800 focus:border-blue-500';
if (editing) {
return (
<div className="space-y-2">
{multiline ? (
<textarea
autoFocus
value={draft}
onChange={e => setDraft(e.target.value)}
rows={5}
className={`w-full px-3 py-2 text-sm rounded-lg border outline-none resize-y font-mono leading-relaxed transition-colors ${inputCls}`}
/>
) : (
<input
autoFocus
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirm(); if (e.key === 'Escape') cancel(); }}
className={`w-full px-3 py-1.5 text-sm rounded-lg border outline-none transition-colors ${inputCls}`}
/>
)}
<div className="flex gap-2">
<button onClick={confirm} className="flex items-center gap-1 px-3 py-1 text-xs rounded-lg bg-green-500 hover:bg-green-400 text-white transition-colors">
<Check className="w-3 h-3" /> 确认
</button>
<button onClick={cancel} className={`flex items-center gap-1 px-3 py-1 text-xs rounded-lg border transition-colors ${isDark ? 'border-slate-600 text-slate-400 hover:text-slate-200' : 'border-gray-300 text-gray-500 hover:text-gray-700'}`}>
<X className="w-3 h-3" /> 取消
</button>
</div>
</div>
);
}
return (
<div className="group/editable flex items-start gap-2 rounded-lg px-2 py-1.5 -mx-2 -my-1.5 transition-all duration-200 hover:bg-black/5 dark:hover:bg-white/5 cursor-pointer" onClick={() => { setDraft(value); setEditing(true); }}>
<span className={`flex-1 text-sm leading-relaxed break-words ${isDark ? 'text-slate-300' : 'text-gray-700'} ${multiline ? 'whitespace-pre-wrap font-mono text-xs' : ''}`}>
{value || <span className={isDark ? 'text-slate-600 italic' : 'text-gray-300 italic'}>(空)</span>}
</span>
<Edit2 className={`flex-shrink-0 w-3.5 h-3.5 mt-0.5 opacity-0 group-hover/editable:opacity-100 transition-all duration-200 ${isDark ? 'text-blue-400' : 'text-blue-500'}`} />
</div>
);
}
function TagList({
tags,
onChange,
isDark,
tagLight = 'bg-sky-50 text-sky-700 border-sky-200',
tagDark = 'bg-slate-800/70 text-sky-200 border-sky-500/25',
}: {
tags: string[];
onChange: (tags: string[]) => void;
isDark: boolean;
tagLight?: string;
tagDark?: string;
}) {
const [editIdx, setEditIdx] = useState<number | null>(null);
const [editVal, setEditVal] = useState('');
const [adding, setAdding] = useState(false);
const [addVal, setAddVal] = useState('');
const tagCls = isDark ? tagDark : tagLight;
const inputCls = isDark
? 'bg-slate-800 border-blue-500/60 text-slate-100 placeholder-slate-500 focus:border-blue-400'
: 'bg-white border-blue-400 text-gray-800 placeholder-gray-400 focus:border-blue-500';
const confirmEdit = () => {
if (editIdx === null) return;
const next = [...tags];
if (editVal.trim()) next[editIdx] = editVal.trim();
else next.splice(editIdx, 1);
onChange(next);
setEditIdx(null);
};
const confirmAdd = () => {
if (addVal.trim()) onChange([...tags, addVal.trim()]);
setAdding(false);
setAddVal('');
};
return (
<div className="flex flex-wrap gap-1.5 items-center">
{tags.map((tag, i) =>
editIdx === i ? (
<span key={i} className="inline-flex items-center gap-1">
<input
autoFocus
value={editVal}
onChange={e => setEditVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirmEdit(); if (e.key === 'Escape') setEditIdx(null); }}
className={`px-2 py-0.5 text-xs rounded border outline-none w-36 transition-colors ${inputCls}`}
/>
<button onClick={confirmEdit} className="text-green-500 hover:text-green-400 transition-colors"><Check className="w-3 h-3" /></button>
<button onClick={() => setEditIdx(null)} className="text-red-400 hover:text-red-300 transition-colors"><X className="w-3 h-3" /></button>
</span>
) : (
<span key={i} className="group/tag inline-flex items-center gap-1">
<span
onClick={() => { setEditIdx(i); setEditVal(tag); setAdding(false); }}
className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-xs rounded-full border cursor-pointer transition-all duration-200 hover:scale-105 hover:opacity-90 ${tagCls}`}
>
{tag}
<button
onClick={e => { e.stopPropagation(); onChange(tags.filter((_, j) => j !== i)); }}
className="opacity-0 group-hover/tag:opacity-100 transition-opacity hover:text-red-500 ml-0.5"
>
<X className="w-2.5 h-2.5" />
</button>
</span>
</span>
)
)}
{adding ? (
<span className="inline-flex items-center gap-1">
<input
autoFocus
value={addVal}
onChange={e => setAddVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') { setAdding(false); setAddVal(''); } }}
placeholder="输入内容…"
className={`px-2 py-0.5 text-xs rounded border outline-none w-36 transition-colors ${inputCls}`}
/>
<button onClick={confirmAdd} className="text-green-500 hover:text-green-400 transition-colors"><Check className="w-3 h-3" /></button>
<button onClick={() => { setAdding(false); setAddVal(''); }} className="text-red-400 hover:text-red-300 transition-colors"><X className="w-3 h-3" /></button>
</span>
) : (
<button
onClick={() => { setAdding(true); setEditIdx(null); }}
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border border-dashed transition-all duration-200 hover:scale-105 ${isDark ? 'border-slate-600 text-slate-500 hover:border-blue-500 hover:text-blue-400' : 'border-gray-300 text-gray-400 hover:border-blue-400 hover:text-blue-500'}`}
>
<Plus className="w-3 h-3" /> 添加
</button>
)}
</div>
);
}
function AgentCard({
agent,
onUpdate,
onDelete,
isDark,
}: {
agent: AgentData;
onUpdate: (a: AgentData) => void;
onDelete: () => void;
isDark: boolean;
}) {
const [editing, setEditing] = useState(false);
const [hovered, setHovered] = useState(false);
const vis = AGENT_VISUALS[agent.id] ?? DEFAULT_VISUAL;
const { Icon } = vis;
const cardCls = isDark ? vis.darkCard : vis.lightCard;
const iconCls = isDark ? vis.iconDark : vis.iconLight;
const tagCls = isDark ? vis.tagDark : vis.tagLight;
const handleFieldChange = (field: keyof AgentData, value: unknown) => {
onUpdate({ ...agent, [field]: value });
};
return (
<>
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={`relative rounded-xl border p-4 bg-gradient-to-br transition-all duration-300 ${cardCls} ${hovered ? 'scale-[1.02] shadow-lg -translate-y-0.5' : 'scale-100 shadow-sm'}`}
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-transform duration-300 ${iconCls} ${hovered ? 'scale-110 rotate-3' : ''}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<p className={`font-semibold text-sm ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{agent.name}</p>
<p className={`text-xs mt-0.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{agent.dimension}</p>
</div>
</div>
<div className={`flex items-center gap-1 transition-opacity duration-200 ${hovered ? 'opacity-100' : 'opacity-0'}`}>
<button
onClick={() => setEditing(true)}
className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700 text-slate-400 hover:text-blue-300' : 'hover:bg-white/70 text-gray-400 hover:text-blue-500'}`}
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={onDelete}
className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700 text-slate-400 hover:text-red-400' : 'hover:bg-white/70 text-gray-400 hover:text-red-500'}`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<code className={`inline-block text-xs px-2 py-0.5 rounded-full border mb-2 font-mono ${tagCls}`}>{agent.id}</code>
<div className="mb-3">
<p className={`text-xs leading-relaxed mb-1.5 line-clamp-2 ${isDark ? 'text-slate-400' : 'text-gray-600'}`}>{agent.expertise.slice(0, 80)}</p>
</div>
{agent.relevant_fields.length > 0 && (
<div className="mb-2">
<p className={`text-xs font-medium mb-1.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.relevant_fields}</p>
<div className="flex flex-wrap gap-1">
{agent.relevant_fields.map(f => (
<span key={f} className={`text-xs px-2 py-0.5 rounded-full border ${tagCls}`}>{f}</span>
))}
</div>
</div>
)}
</div>
{editing && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setEditing(false)}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div
className={`relative w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-2xl border shadow-2xl ${isDark ? 'bg-slate-900 border-slate-700' : 'bg-white border-gray-200'}`}
onClick={e => e.stopPropagation()}
>
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<h2 className={`font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>编辑智能体</h2>
<button onClick={() => setEditing(false)} className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-800 text-slate-400' : 'hover:bg-gray-100 text-gray-500'}`}><X className="w-4 h-4" /></button>
</div>
<div className="p-6 space-y-4">
{(['id', 'name', 'dimension'] as const).map(k => (
<div key={k} className="space-y-1">
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS[k]}</label>
<EditableText value={agent[k]} onChange={v => handleFieldChange(k, v)} isDark={isDark} />
</div>
))}
<div className="space-y-1">
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.expertise}</label>
<EditableText value={agent.expertise} onChange={v => handleFieldChange('expertise', v)} isDark={isDark} multiline />
</div>
<div className="space-y-1.5">
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.relevant_fields}</label>
<TagList tags={agent.relevant_fields} onChange={v => handleFieldChange('relevant_fields', v)} isDark={isDark} tagLight="bg-blue-50 text-blue-700 border-blue-200" tagDark="bg-slate-800/70 text-blue-200 border-blue-500/25" />
</div>
<div className="space-y-1.5">
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.analysis_contract}</label>
<TagList tags={agent.analysis_contract} onChange={v => handleFieldChange('analysis_contract', v)} isDark={isDark} tagLight="bg-violet-50 text-violet-700 border-violet-200" tagDark="bg-slate-800/70 text-violet-200 border-violet-500/25" />
</div>
</div>
<div className={`flex justify-end gap-3 px-6 py-4 border-t ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<button onClick={() => setEditing(false)} className={`px-4 py-2 text-sm rounded-lg border transition-colors ${isDark ? 'border-slate-600 text-slate-400 hover:text-slate-200' : 'border-gray-300 text-gray-500 hover:text-gray-700'}`}>取消</button>
<button onClick={() => setEditing(false)} className="px-4 py-2 text-sm rounded-lg bg-blue-500 hover:bg-blue-400 text-white transition-colors flex items-center gap-1.5">
<Check className="w-3.5 h-3.5" /> 保存
</button>
</div>
</div>
</div>
)}
</>
);
}
function SectionWrapper({
title,
icon: Icon,
children,
isDark,
defaultOpen = true,
}: {
title: string;
icon: React.ElementType;
children: React.ReactNode;
isDark: boolean;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={`rounded-xl border overflow-hidden transition-all duration-200 ${isDark ? 'bg-slate-800/40 border-slate-700/60' : 'bg-white border-gray-200 shadow-sm'}`}>
<button
onClick={() => setOpen(o => !o)}
className={`w-full flex items-center justify-between px-5 py-4 transition-colors duration-200 ${isDark ? 'hover:bg-slate-700/30' : 'hover:bg-gray-50'}`}
>
<div className="flex items-center gap-2.5">
<div className={`w-7 h-7 rounded-lg flex items-center justify-center ${isDark ? 'bg-slate-700' : 'bg-red-50'}`}>
<Icon className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />
</div>
<span className={`font-semibold text-sm ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{title}</span>
</div>
{open
? <ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />
: <ChevronRight className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />}
</button>
<div className={`grid transition-all duration-300 ease-in-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-70'}`}>
<div className="overflow-hidden">
<div className="px-5 pb-5 space-y-4">{children}</div>
</div>
</div>
</div>
);
}
export default function Template6BEditor() {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [template, setTemplate] = useState<TemplateData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
useEffect(() => {
fetch('https://ch1.b.uat.4.cn/zncmweb/templates/6b_template.json')
.then(r => r.json())
.then((data: TemplateData) => { setTemplate(data); setLoading(false); })
.catch(e => { setError(e.message); setLoading(false); });
}, []);
const update = <K extends keyof TemplateData>(key: K, value: TemplateData[K]) => {
setTemplate(prev => prev ? { ...prev, [key]: value } : null);
setDirty(true);
};
const handleSave = () => {
if (!template) return;
console.log('[Template6BEditor 保存]', JSON.stringify(template, null, 2));
window.alert('配置已保存,详情请查看控制台');
setDirty(false);
};
if (loading) {
return (
<div className={`flex items-center justify-center min-h-64 ${isDark ? 'text-slate-400' : 'text-gray-400'}`}>
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
<span className="text-sm">加载模板中…</span>
</div>
);
}
if (error || !template) {
return (
<div className={`flex items-center gap-2 p-4 rounded-xl ${isDark ? 'bg-red-950/30 text-red-400 border border-red-900/50' : 'bg-red-50 text-red-600 border border-red-200'}`}>
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">模板加载失败:{error}</span>
</div>
);
}
return (
<div className="h-[calc(100vh-24px)] max-h-[calc(100vh-24px)] flex flex-col max-w-5xl mx-auto px-1 pb-3">
<div className={`flex-shrink-0 flex items-center justify-between gap-4 py-3 border-b ${isDark ? 'border-slate-800' : 'border-gray-200'}`}>
<div>
<h1 className={`text-xl font-bold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>模板配置编辑器</h1>
<p className={`text-sm mt-0.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>六步行动方案 · {template.template_id}</p>
</div>
<div className="flex items-center gap-3">
{dirty && (
<div className={`flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border ${isDark ? 'bg-amber-900/20 border-amber-700/40 text-amber-300' : 'bg-amber-50 border-amber-200 text-amber-600'}`}>
<Info className="w-3.5 h-3.5" />
已有未保存修改
</div>
)}
<button
onClick={handleSave}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl border shadow-sm transition-all duration-200 hover:scale-[1.02] ${isDark ? 'bg-slate-900 border-slate-700 text-slate-100 hover:border-blue-500/40' : 'bg-white border-gray-200 text-gray-800 hover:border-blue-300'}`}
>
<Save className="w-4 h-4" /> 保存
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto py-3 pr-1 space-y-4">
<SectionWrapper title="基本信息" icon={FileText} isDark={isDark}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(['template_id', 'template_name'] as const).map(k => (
<div key={k} className="space-y-1.5">
<span className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS[k]}</span>
<EditableText value={template[k]} onChange={v => update(k, v)} isDark={isDark} />
</div>
))}
</div>
<div className="space-y-1.5">
<span className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.template_brief}</span>
<EditableText value={template.template_brief} onChange={v => update('template_brief', v)} isDark={isDark} multiline />
</div>
</SectionWrapper>
<SectionWrapper title={FIELD_LABELS.usage_notes} icon={Info} isDark={isDark}>
<TagList
tags={template.usage_notes}
onChange={v => update('usage_notes', v)}
isDark={isDark}
tagLight="bg-amber-50 text-amber-700 border-amber-200"
tagDark="bg-slate-800/70 text-amber-200 border-amber-500/25"
/>
</SectionWrapper>
<SectionWrapper title={FIELD_LABELS.report_schema} icon={Settings} isDark={isDark}>
<div className="space-y-3">
{Object.entries(template.report_schema).map(([key, desc]) => (
<div key={key} className={`group/row rounded-lg border px-4 py-3 transition-all duration-200 hover:scale-[1.005] ${isDark ? 'border-slate-700/50 bg-slate-800/30 hover:border-slate-600' : 'border-gray-200 bg-gray-50/50 hover:border-gray-300 hover:shadow-sm'}`}>
<div className="flex items-center gap-2 mb-1.5">
<code className={`text-xs px-2 py-0.5 rounded font-mono ${isDark ? 'bg-slate-700 text-slate-400' : 'bg-gray-200 text-gray-500'}`}>{key}</code>
<span className={`text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>{REPORT_SCHEMA_LABELS[key] || key}</span>
</div>
<EditableText
value={desc}
onChange={v => update('report_schema', { ...template.report_schema, [key]: v })}
isDark={isDark}
multiline
/>
</div>
))}
</div>
</SectionWrapper>
<SectionWrapper title={FIELD_LABELS.agents} icon={Brain} isDark={isDark}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{template.agents.map((agent, i) => (
<AgentCard
key={agent.id + i}
agent={agent}
onUpdate={updated => {
const next = [...template.agents];
next[i] = updated;
update('agents', next);
}}
onDelete={() => {
if (template.agents.length <= 1) {
window.alert('智能体最少必须保留 1 个');
return;
}
update('agents', template.agents.filter((_, j) => j !== i));
}}
isDark={isDark}
/>
))}
</div>
</SectionWrapper>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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>
);
}
@@ -0,0 +1,176 @@
/**
* defaultConfig.ts - 默认配置模块
*
* 本模块定义应用的默认配置值
* 包括后端服务器地址和初始配置
* 这些配置在用户没有自定义时会作为默认值使用
*/
/**
* 默认后端服务器地址
* 用于前端与后端 API 通信
*/
export const DEFAULT_BACKEND_URL = 'http://10.26.128.38:8030'
/**
* 默认应用配置
* 当用户没有选择任何模板时使用的初始配置
* 包含:
* - 协调者模式配置
* - 协调者提示词
* - 默认智能体列表
* - 阶段配置
* - 报告结构定义
*/
export const DEFAULT_CONFIG = {
/** 任务背景(用户输入) */
task_context: "",
/** 协调者模式:auto=自动, human_review=人工确认 */
coordinator_mode: "auto",
/** 协调者配置 - 包含各种提示词和规则 */
coordinator_config: {
/** 系统提示前缀 - 协调者的全局规则 */
system_prompt_prefix: "",
/** 首轮策略 - 第1轮辩论时的指导 */
round1_guidance: `这是发散阶段第1轮,草稿刚起步。
策略:优先填写草稿中空缺最关键的字段,同时识别智能体之间最尖锐的分歧——
这些分歧往往意味着草稿中某个判断还无法做出,需要通过交锋来解决。`,
/** 中间轮策略 - 中间轮辩论时的指导 */
middle_round_guidance: `这是发散阶段的中间轮次。
策略:审查草稿现状,找出仍然太模糊或有冲突的字段,
选择能推进这些字段的智能体,给出让草稿变得更具体的任务。`,
/** 末轮策略 - 最后一轮辩论时的指导 */
final_round_guidance: `这是发散阶段最后一轮。
策略:草稿必须在这轮后达到可以收尾的状态,但仍要求智能体给出完整材料包,
包括明确判断、依据链、反例、风险边界和可核验线索;最终压缩、取舍和成文由协调者完成。`,
/** 选人规则 - 如何选择下一轮发言的智能体 */
speaker_selection_rules: "优先选择最能扩充 partial/blocked 字段材料、解决关键冲突、补充证据或论证链的智能体。",
/** 任务指令规则 - 如何生成给智能体的任务 */
focus_instruction_rules: "focus 必须写成内容型任务书,不能只催促 agent 继续分析。每条至少包含:目标字段、已有材料或草稿判断、真正缺口、为什么该缺口会阻塞报告、需要补充的具体材料类型、建议切入角度、预期输出形态、风险或禁止事项。指令应鼓励 agent 展开材料、证据线索、论证链、反例、分歧和边界条件,而不是只给短结论。",
/** 依赖推进规则 - 如何处理字段间的依赖关系 */
dependency_driving_rules: "",
/** 一致性/冲突检查规则 */
consistency_check_rules: "",
/** 人工修改解释规则 */
revise_prompt_prefix: ""
},
/** 默认智能体列表 */
agents: [
{
/** 背景研判师 */
id: "situation",
name: "背景研判师",
dimension: "背景研判",
expertise: "你是一位资深的舆情和战略分析专家。你的任务是判断当前舆论场的格局:现在存在哪几条叙事,各自的势头如何,哪些认知锚点可以被打散,哪些比较维度可以被引入或重构,竞争对手的动作有哪些逻辑漏洞。不是做损失报告,而是做战略研判——找到可以操作的叙事空间。",
},
{
/** 目的策略师 */
id: "purpose",
name: "目的策略师",
dimension: "传播目标",
expertise: "你是一位认知传播专家,专门设计可实现的传播目标——即通过传播内容能够在受众头脑中建立的具体认知或情绪。你不关心商业结果,只关心:这次传播结束后,目标受众的想法和感受会发生什么具体变化?用第一人称写出目标受众在成功传播后的内心独白。也可以设计竞争性目标(让受众对竞品产生质疑)。",
},
{
/** 受众分析师 */
id: "audience",
name: "受众分析师",
dimension: "受众图谱",
expertise: "你是一位受众研究专家。你的任务是识别具体的、可操作的目标受众群体。拒绝泛化:不接受'年轻人''汽车爱好者'这类描述,必须给出年龄段、性别比例、城市层级、具体平台和具体账号/社区名称。同时要写出该群体当前的心理状态——他们现在在想什么,核心疑虑是什么,用第一人称写出他们的内心独白。",
},
{
/** 叙事策划师 */
id: "narrative",
name: "叙事策划师",
dimension: "叙事方案",
expertise: "你是一位顶尖的叙事战略家和内容策划师。你的任务是设计具体的叙事支柱:每个支柱必须包含(1)具体内容——发什么、怎么说,给出可执行的内容方向或文案草稿;(2)为什么这么设计——预期的心理机制;(3)预期效果——目标受众会产生什么认知变化;(4)风险与局限——在什么条件下会失效或反噬。叙事框架和具体消息写在一起,不要分开。",
}
],
/** 阶段配置 */
phase_config: { diverge_rounds: 3 },
/** 模板 ID(默认未选择) */
template_id: "",
/** 模板名称 */
template_name: "",
/** 模板简短描述 */
template_brief: "",
/** 报告结构定义 - 字段名到描述的映射 */
report_schema: {
/** 背景研判:当前舆论场/市场/竞争格局的战略研判 */
situation_assessment: "背景研判:当前舆论场/市场/竞争格局的战略研判。包含现有几条并行叙事及其势头、可以被打散的认知锚点、竞争对手动作的逻辑漏洞、可操作的叙事空间。不是损失报告,是战略判断。",
/** 传播目标:2-4个本方案期望在受众中建立的具体认知或情绪 */
communication_objectives: "传播目标:2-4个本方案期望在受众中建立的具体认知或情绪,用目标受众的第一人称内心独白表达。不写商业结果,不写指标,只写认知和情绪目标。可含竞争性目标。",
/** 核心受众:2-4个具体受众群体 */
audience_map: "核心受众:2-4个具体受众群体,每个包含:(1)人口画像——年龄段、性别、城市层级、具体特征;(2)当前心理状态——用第一人称内心独白;(3)关键信息节点——具体平台和账号/社区名称,不接受泛化描述。",
/** 叙事方案:2-3个叙事支柱 */
narrative_plan: "叙事方案:2-3个叙事支柱,每个包含:(1)具体内容——可执行的内容方向或文案草稿;(2)为什么这么设计——预期心理机制;(3)预期效果——目标受众的认知变化;(4)风险与局限——失效条件。叙事框架和消息合并在一起。",
/** 分阶段行动计划 */
action_plan: "分阶段行动计划:按时间线的3-4个阶段,每个行动具体到:做什么、谁执行(官方/第三方/独立账号)、何时。",
/** 风险预案:3-5个风险场景 */
risk_playbook: "风险预案:3-5个风险场景,每个含触发条件、具体应对步骤、明确禁止的动作。"
},
/** 报告示例 */
report_examples: {
situation_assessment: `事故发生后,舆论场中存在三条并行叙事:
- 主流情绪叙事(当前占主导):"电动车关门困死人"——聚焦电动门锁这一单一缺陷,将问界塑造为主动伤人的产品
- 竞品借势叙事:理想、小米相继发声强调"物理门把手不会消失",将安全心智差异化
- 技术中性叙事(目前声量极弱):部分汽车工程师账号指出燃油车追尾同样可能起火且无法逃生,但未被放大
核心判断:目前的"关门困死人"叙事对问界的伤害远超事故本身,因为它指向的是所有电动车门锁的系统性问题,而非这次追尾的偶发性。叙事反转的关键在于:将"问界门锁缺陷"重新定框为"追尾高速起火是极端工况,不同于日常安全风险",同时打开"燃油车在同等追尾条件下结果是否更好"这一比较维度。
此外,竞品的"物理门把手"营销动作,实质上是在暗示所有纯电动车都有同等风险——问界可以借助行业数据,将这一模糊指控反向用于质疑竞品的安全宣传逻辑。`,
communication_objectives: `目标一:让现有问界车主相信此次事故属于极端偶发工况
> "这是一辆车被高速追尾、车身严重变形后才发生的,不是日常开车会遇到的情况,和我每天通勤的安全性是两件事。"
目标二:让公众了解燃油车在同等追尾工况下的起火概率并不低于电动车
> "我以为电动车特别容易起火,看了数据才知道燃油车追尾也会爆炸,而且逃生时间更短。这个问题不是问界独有的。"
目标三:让潜在购车者相信问界已做出真实的技术改进,而非只是公关操作
> "他们不只是道歉,而是真的改了——门锁逻辑改了,还加了物理把手,有第三方测了。这和之前那些只道歉不改的品牌不一样。"
目标四(竞争性):在科技圈和华为生态用户中种下"竞品安全宣传是过度营销"的质疑
> "理想说保留物理门把手就安全了?那燃油车追尾不也照样困死人,他们这是蹭热度。"`,
audience_map: `受众一:现有问界M7车主(最优先)
- 画像:25-45岁男性为主(问界买家中男性占比约75%),华为手机深度用户,在二三线城市比例高于理想/小米,家庭用车场景为主
- 当前心理状态:"在家人追问压力下动摇,既不想认错(承认买错了),又需要一个可以说服家人的说法"
- 关键触点:AITO官方App推送、华为花粉俱乐部社区(注册用户2000万+)、各省市问界车主微信群
受众二:考虑30万内家用SUV的25-38岁已婚男性(主要流失群体)
- 画像:集中在北京、上海、成都、杭州、武汉;在懂车帝/汽车之家浏览过问界M7、理想L7对比页面;家庭有6岁以下子女的比例约40%,配偶对选车有实质否决权
- 当前心理状态:"事故前已接近下定,现在进入观望,主要疑虑是'安全有保障吗',在等一个可以放心的理由"
- 关键触点:懂车帝的问界M7评测区(日均浏览约4万次)、B站@42号车库(汽车技术向,260万粉)、@老司机测评(抖音+微博,180万粉)`,
narrative_plan: `叙事支柱一:工况重构——把"电动门锁缺陷"扩展为"追尾高速起火"议题
具体内容:发布或推动媒体发布《追尾高速事故生存数据分析》,引用可验证的事故数据:中国高速追尾事故中燃油车起火比例、燃油车在高速追尾后因油箱破裂导致无法逃生的案例、断电门锁问题在行业内的普遍性(其他品牌纯电车是否存在类似设计)。
为什么这么设计:公众对"电动车起火"的恐惧,部分来自"燃油车才是安全默认值"的认知锚点。此叙事目标不是洗白问界,而是打散原有的比较基准——把"问界门锁失效"从"品牌失职"变成"高速追尾这一极端工况下的行业性挑战"。
预期效果:目标受众在搜索"问界安全"时,搜到的不是事故视频,而是工况对比分析,认知从"问界车会困死人"转向"高速追尾是极端事故,概率极低"。
风险与局限:如果数据无法支持"燃油车风险不低于电动车"的结论,此叙事会反噬——数据必须真实可查。部分受众会将此解读为"转移焦点"。`
}
}
@@ -0,0 +1,153 @@
/**
* templateStore.ts - 模板存储管理模块
*
* 本模块负责从后端加载模板配置,并支持本地覆盖功能
* 模板存储在 /public/templates 目录下,通过 fetch API 加载
*
* 主要功能:
* - 加载模板索引列表
* - 根据 ID 或元数据加载单个模板
* - 从本地存储读取/保存模板覆盖
* - 检查模板是否有本地覆盖
*/
import type { Template, TemplateMeta } from '../types'
/**
* 本地存储中覆盖模板的前缀
* 完整 key 格式:mope_template_override:{template_id}
*/
const OVERRIDE_PREFIX = 'mope_template_override:'
/**
* 深度克隆对象
* 使用 JSON 序列化/反序列化实现
* 注意:这会丢失 undefined、正则、函数等特殊类型
*
* @param value - 需要克隆的值
* @returns 克隆后的新值
*/
export function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value))
}
/**
* 加载模板索引列表
* 从 http://ch1.b.uat.4.cn/zncmweb/templates/index.json 获取所有可用模板的元数据
*
* @returns 模板元数据数组
* @throws 如果请求失败或响应非 OK,抛出错误
*/
export async function loadTemplateIndex(): Promise<TemplateMeta[]> {
const response = await fetch('https://ch1.b.uat.4.cn/zncmweb/templates/index.json')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
return (data.templates || []) as TemplateMeta[]
}
/**
* 根据元数据或 ID 加载模板
* 如果本地有覆盖,返回覆盖版本
*
* @param metaOrId - 模板元数据对象或模板 ID 字符串
* @returns 模板对象
* @throws 如果请求失败或响应非 OK,抛出错误
*/
export async function loadTemplateByMeta(metaOrId: TemplateMeta | string): Promise<Template> {
// 提取模板 ID
const templateId = typeof metaOrId === 'string'
? metaOrId
: metaOrId?.template_id
// 确定请求的 URL
const file = typeof metaOrId === 'string'
? `/templates/${templateId}.json`
: (metaOrId as TemplateMeta)?.file
const response = await fetch(file || `/templates/${templateId}.json`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const template = await response.json() as Template
// 检查是否有本地覆盖
const override = getTemplateOverride(template.template_id || templateId || '')
// 如果有覆盖,返回覆盖版本
return override ? clone(override) : template
}
/**
* 加载原始模板(忽略本地覆盖)
* 用于在用户重置模板时恢复到原始状态
*
* @param metaOrId - 模板元数据对象或模板 ID 字符串
* @returns 原始模板对象
* @throws 如果请求失败或响应非 OK,抛出错误
*/
export async function loadOriginalTemplate(metaOrId: TemplateMeta | string): Promise<Template> {
// 提取模板 ID
const templateId = typeof metaOrId === 'string'
? metaOrId
: metaOrId?.template_id
// 确定请求的 URL
const file = typeof metaOrId === 'string'
? `/templates/${templateId}.json`
: (metaOrId as TemplateMeta)?.file
const response = await fetch(file || `/templates/${templateId}.json`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json() as Promise<Template>
}
/**
* 从本地存储获取模板覆盖
*
* @param templateId - 模板 ID
* @returns 覆盖的模板对象,如果没有覆盖则返回 null
*/
export function getTemplateOverride(templateId: string): Template | null {
if (!templateId) return null
try {
const raw = localStorage.getItem(`${OVERRIDE_PREFIX}${templateId}`)
return raw ? JSON.parse(raw) : null
} catch {
return null
}
}
/**
* 检查指定模板是否有本地覆盖
*
* @param templateId - 模板 ID
* @returns 如果有覆盖返回 true,否则返回 false
*/
export function hasTemplateOverride(templateId: string): boolean {
return Boolean(getTemplateOverride(templateId))
}
/**
* 保存模板覆盖到本地存储
*
* @param templateId - 模板 ID
* @param template - 要保存的模板对象
* @throws 如果 templateId 为空,抛出错误
*/
export function saveTemplateOverride(templateId: string, template: Template): void {
if (!templateId) throw new Error('缺少 template_id')
localStorage.setItem(
`${OVERRIDE_PREFIX}${templateId}`,
JSON.stringify(template)
)
}
/**
* 清除指定模板的本地覆盖
*
* @param templateId - 模板 ID
*/
export function clearTemplateOverride(templateId: string): void {
if (!templateId) return
localStorage.removeItem(`${OVERRIDE_PREFIX}${templateId}`)
}
+756
View File
@@ -0,0 +1,756 @@
/**
* 全局类型定义文件
* 本文件定义了项目中使用的所有 TypeScript 类型和接口
* 包含组件 props、状态类型、API 响应类型等
*/
/**
* =============================================================================
* 智能体相关类型
* =============================================================================
*/
/**
* 智能体基本信息
* 描述一个智能体的标识和元数据
*/
export interface Agent {
/** 智能体唯一标识符 */
id: string;
/** 智能体显示名称 */
name: string;
/** 智能体所属的分析维度 */
dimension: string;
/** 专家角色描述或智能体提示词 */
expertise?: string;
/** 技能描述(与 expertise 类似) */
skill?: string;
/** 场景角色描述 */
scenario_role?: string;
/** 智能体描述 */
description?: string;
/** 该智能体负责的报告字段列表 */
relevant_fields?: string[];
/** 分析契约 - 智能体输出规则列表 */
analysis_contract?: string[];
/** 检索模式:always=总是检索, never=从不检索, smart=智能检索 */
retrieval_mode?: 'always' | 'never' | 'smart';
/** 允许检索的数据源 ID 列表 */
allowed_source_ids?: string[];
/** 检索补充说明 */
retrieval_instruction?: string;
}
/**
* 智能体状态
* 描述智能体在运行时的实时状态
*/
export interface AgentState {
/** 智能体唯一标识符 */
id: string;
/** 智能体显示名称 */
name: string;
/** 智能体所属的分析维度 */
dimension: string;
/** 当前状态:idle=空闲, thinking=思考中, done=已完成 */
status: 'idle' | 'thinking' | 'done';
/** 该智能体已完成的分析轮次列表 */
rounds: AgentRoundData[];
}
/**
* 智能体单轮分析数据
* 包含智能体在某一轮中的完整输出
*/
export interface AgentRoundData {
/** 轮次编号 */
round: number;
/** 所处阶段 */
phase: PhaseType;
/** 分析内容(Markdown 格式) */
content: string;
/** 思考过程(Markdown 格式) */
thinking: string;
/** 检索日志列表 */
retrievalLog: RetrievalLogItem[];
/** 检索次数 */
retrievalCount: number;
/** 引用映射表:将引用编号映射到实际数据源 ID */
referenceMap: Record<string, string>;
/** 额外元数据(可选) */
metadata?: Record<string, unknown>;
/** 维度输出 */
dimensionOutput?: Record<string, string>;
}
/**
* 检索日志条目
* 记录一次检索操作的详细信息
*/
export interface RetrievalLogItem {
/** 检索查询语句 */
query: string;
/** 数据源名称 */
source_name?: string;
/** 数据源 ID */
source_id?: string;
/** 数据源标识 */
source?: string;
/** 检索结果内容列表 */
content: (string | Record<string, unknown>)[];
/** 内容 ID 列表(用于关联 referenceMap) */
content_ids?: string[];
/** 该检索结果是否被智能体采用 */
adopted?: boolean;
}
/**
* =============================================================================
* 会话和状态相关类型
* =============================================================================
*/
/**
* 会话状态
* 描述整个应用的状态机状态
*/
export type SessionStatus =
| 'idle' // 空闲状态 - 初始状态,未开始会话
| 'running' // 运行中 - 会话正在执行
| 'waiting_human' // 等待人工确认 - 需要用户手动确认协调者提案
| 'done' // 已完成 - 会话正常结束
| 'error'; // 错误状态 - 会话执行过程中发生错误
/**
* 协调者模式
* 决定协调者如何推进会话
*/
export type CoordinatorMode =
| 'auto' // 自动模式 - 协调者自动推进,无需人工干预
| 'human_review'; // 人工确认模式 - 每轮协调者提案后需要人工确认
/**
* 阶段类型
* 描述当前所处的分析阶段
*/
export type PhaseType =
| 'starting' // 启动阶段 - 第一轮,初始化分析
| 'diverging' // 辩论阶段 - 多轮讨论,各智能体提供观点
| 'closing' // 收敛阶段 - 协调者整合观点,形成结论
| 'final_report' // 最终报告生成阶段 - 协调者正在生成最终报告
| 'done'; // 完成阶段 - 最终报告生成完毕
/**
* 时间线项目类型
* 描述时间线中不同类型的条目
*/
export type TimelineItemType =
| 'round_start' // 轮次开始标记
| 'agent_contribution' // 智能体贡献内容
| 'coordinator_streaming' // 协调者流式输出(思考中)
| 'coordinator' // 协调者完整提案
| 'final_report' // 最终报告
| 'background_retrieval'; // 背景检索结果
/**
* =============================================================================
* 配置相关类型
* =============================================================================
*/
/**
* 阶段配置
* 控制不同阶段的参数
*/
export interface PhaseConfig {
/** 发散(辩论)轮次数量 */
diverge_rounds: number;
}
/**
* 协调者配置
* 包含协调者的各项提示词和规则
*/
export interface CoordinatorConfig {
/** 系统提示前缀 - 协调者的全局规则 */
system_prompt_prefix?: string;
/** 首轮策略指导 */
round1_guidance?: string;
/** 中间轮策略指导 */
middle_round_guidance?: string;
/** 末轮策略指导 */
final_round_guidance?: string;
/** 选人规则 - 如何选择下一轮发言的智能体 */
speaker_selection_rules?: string;
/** 任务指令规则 - 如何生成给智能体的任务 */
focus_instruction_rules?: string;
/** 依赖推进规则 - 如何处理字段间的依赖关系 */
dependency_driving_rules?: string;
/** 一致性/冲突检查规则 */
consistency_check_rules?: string;
/** 人工修改解释规则 - 如何解释协调者的修改 */
revise_prompt_prefix?: string;
}
/**
* 模板元数据
* 用于模板列表展示
*/
export interface TemplateMeta {
/** 模板唯一标识符 */
template_id: string;
/** 模板显示名称 */
template_name?: string;
/** 模板简短描述 */
template_brief?: string;
/** 模板文件名 */
file?: string;
}
/**
* 完整模板结构
* 包含模板的所有配置和数据
*/
export interface Template {
/** 模板唯一标识符 */
template_id: string;
/** 模板显示名称 */
template_name?: string;
/** 模板简短描述 */
template_brief?: string;
/** 模板详细说明 */
usage_notes?: string;
/** 报告结构定义 - 字段名到描述的映射 */
report_schema: Record<string, string>;
/** 报告示例 - 各字段的示例内容 */
report_examples?: Record<string, string>;
/** 字段要求 - 各字段的详细要求 */
field_requirements?: Record<string, FieldRequirement>;
/** 参与智能体列表 */
agents: Agent[];
/** 协调者配置 */
coordinator_config?: CoordinatorConfig;
/** 智能体适应策略 */
agent_adaptation_policy?: Record<string, unknown>;
/** 阶段配置 */
phase_config?: PhaseConfig;
}
/**
* 字段要求
* 描述某个报告字段的详细要求
*/
export interface FieldRequirement {
/** 字段名称 */
name?: string;
/** 字段描述 */
description?: string;
/** 是否必需 */
required?: boolean;
/** 内容类型期望 */
content_type?: string;
/** 最大长度 */
max_length?: number;
/** 其他元数据 */
[key: string]: unknown;
}
/**
* 应用配置
* 用户在开始会话时提供的完整配置
*/
export interface AppConfig {
/** 任务背景描述 */
task_context: string;
/** 模板 ID */
template_id?: string;
/** 模板名称 */
template_name?: string;
/** 模板简短描述 */
template_brief?: string;
/** 协调者模式 */
coordinator_mode?: CoordinatorMode;
/** 协调者配置 */
coordinator_config?: CoordinatorConfig;
/** 参与智能体列表 */
agents?: Agent[];
/** 阶段配置 */
phase_config?: PhaseConfig;
/** 报告结构 */
report_schema?: Record<string, string>;
/** 报告示例 */
report_examples?: Record<string, string>;
/** 字段要求 */
field_requirements?: Record<string, FieldRequirement>;
/** 智能体适应策略 */
agent_adaptation_policy?: Record<string, unknown>;
/** 检索配置 */
retrieval?: Record<string, unknown>;
/** 检索配置(别名,用于兼容) */
retrieve?: Record<string, unknown>;
/** 审核模式(别名,用于兼容) */
review_mode?: CoordinatorMode;
}
/**
* =============================================================================
* 时间线相关类型
* =============================================================================
*/
/**
* 时间线条目基类
*/
export interface TimelineItemBase {
/** 条目类型 */
type: TimelineItemType;
/** 条目索引(用于列表渲染的 key) */
idx?: number;
}
/**
* 轮次开始条目
* 标记新一轮的开始
*/
export interface RoundStartItem extends TimelineItemBase {
type: 'round_start';
/** 轮次编号 */
round: number;
/** 所处阶段 */
phase: PhaseType;
/** 该轮参与的所有智能体 ID 列表 */
agentIds?: string[];
}
/**
* 智能体贡献条目
* 包含智能体的完整分析输出
*/
export interface AgentContributionItem extends TimelineItemBase {
type: 'agent_contribution';
/** 智能体 ID */
agentId: string;
/** 轮次编号 */
round: number;
/** 所处阶段 */
phase: PhaseType;
/** 是否处于流式输出状态 */
streaming: boolean;
/** 智能体输出数据 */
data: AgentRoundData;
}
/**
* 协调者流式输出条目
* 协调者正在生成内容时的实时输出
*/
export interface CoordinatorStreamingItem extends TimelineItemBase {
type: 'coordinator_streaming';
/** 当前阶段 */
phase: PhaseType;
/** 当前辩论轮次 */
divergeRound?: number;
/** 总辩论轮次 */
totalDivergeRounds?: number;
/** 思考内容(流式输出中) */
thinking: string;
/** 输出内容(流式输出中) */
content: string;
/** 当前正在生成的字段名 */
currentField?: string | null;
/** 最终报告的已完成字段 */
reportPartial: Record<string, string>;
}
/**
* 协调者提案数据
* 协调者完成后的完整提案
*/
export interface CoordinatorProposalData {
/** 轮次编号 */
round: number;
/** 辩论轮次编号 */
divergeRound?: number;
/** 总辩论轮次 */
totalDivergeRounds?: number;
/** 下一轮发言的智能体 ID 列表 */
nextSpeakers: string[];
/** 任务指令映射 - 智能体 ID 到指令内容的映射 */
focus: Record<string, string>;
/** 当前草稿 */
draft: Record<string, string>;
/** 草稿更新内容 */
draftUpdates: Record<string, string>;
/** 当前识别的缺口/问题 */
gaps: string[];
/** 字段评估结果 */
fieldAssessment: Record<string, FieldAssessment>;
/** 推理过程 */
reasoning: string;
/** 原始提案对象 */
proposal: Record<string, unknown>;
}
/**
* 字段评估
* 对某个报告字段的完成度评估
*/
export interface FieldAssessment {
/** 字段状态:filled=已完成, partial=部分完成, blocked=受阻, empty=未开始 */
status: 'filled' | 'partial' | 'blocked' | 'empty';
/** 阻塞原因(如果状态为 blocked) */
blocking_reason?: string;
/** 其他评估信息 */
[key: string]: unknown;
}
/**
* 协调者条目
* 协调者的完整提案
*/
export interface CoordinatorItem extends TimelineItemBase {
type: 'coordinator';
/** 协调者提案数据 */
data: CoordinatorProposalData;
/** 协调者的思考过程 */
thinking?: string;
}
/**
* 最终报告数据
*/
export interface FinalReportData {
/** 最终报告内容 - 字段名到内容的映射 */
report: Record<string, string>;
/** 各维度的详细分析 */
dimensions: Record<string, unknown>;
/** 报告元数据 */
meta: Record<string, unknown>;
}
/**
* 维度数据
* 某个分析维度的详细输出
*/
export interface DimensionData {
/** 维度标签 */
label?: string;
/** 最新分析内容 */
latest_analysis?: string;
/** 其他维度数据 */
[key: string]: unknown;
}
/**
* 最终报告条目
*/
export interface FinalReportItem extends TimelineItemBase {
type: 'final_report';
/** 最终报告数据 */
data: FinalReportData;
}
/**
* 背景检索数据
*/
export interface BackgroundRetrievalData {
/** 检索结果 - 维度到结果条目的映射 */
results: Record<string, RetrievalResultEntry[]>;
/** 涉及的维度列表 */
dimensions?: string[];
/** 总记录数 */
total_records?: number;
}
/**
* 检索结果条目
*/
export interface RetrievalResultEntry {
/** 检索查询 */
query?: string;
/** 检索结果内容 */
content: (string | Record<string, unknown>)[];
}
/**
* 背景检索条目
*/
export interface BackgroundRetrievalItem extends TimelineItemBase {
type: 'background_retrieval';
/** 检索数据 */
data: BackgroundRetrievalData;
}
/**
* 联合类型:所有可能的时间线条目
*/
export type AnyTimelineItem =
| RoundStartItem
| AgentContributionItem
| CoordinatorStreamingItem
| CoordinatorItem
| FinalReportItem
| BackgroundRetrievalItem;
/**
* =============================================================================
* API 和事件相关类型
* =============================================================================
*/
/**
* SSE 事件解析结果
* 从后端 SSE 流中解析出的事件
*/
export interface SseEvent {
/** 事件类型 */
event: string;
/** 事件数据 */
data: Record<string, unknown>;
}
/**
* 待审核提案
* 需要人工确认的协调者提案
*/
export interface PendingReview {
/** 轮次编号 */
divergeRound: number;
/** 总辩论轮次 */
totalDivergeRounds: number;
/** 下一轮发言的智能体 ID 列表 */
nextSpeakers: string[];
/** 任务指令 */
focus: Record<string, string>;
/** 当前草稿 */
draft: Record<string, string>;
/** 当前缺口 */
gaps: string[];
/** 原始提案 */
proposal: Record<string, unknown>;
}
/**
* 人工确认操作的载荷
*/
export interface HumanReviewPayload {
/** 下一轮发言的智能体 ID 列表 */
nextSpeakers: string[];
/** 任务指令 */
focus: Record<string, string>;
/** 草稿更新 */
draft: Record<string, string>;
/** 缺口列表 */
gaps: string[];
}
/**
* 会话状态类型
* 整个应用的状态对象
*/
export interface SessionState {
/** 会话状态 */
status: SessionStatus;
/** 会话 ID */
sessionId: string | null;
/** 后端服务器地址 */
backendUrl: string;
/** 协调者模式 */
coordinatorMode: CoordinatorMode;
/** 当前阶段 */
phase: PhaseType | null;
/** 当前轮次 */
currentRound: number;
/** 总轮次数 */
totalRounds: number;
/** 辩论轮次数 */
divergeRounds: number;
/** 报告结构定义 */
reportSchema: Record<string, string>;
/** 参与会话的智能体列表 */
agents: Agent[];
/** 智能体状态映射 */
agentStates: Record<string, AgentState>;
/** 协调者更新历史 */
coordinatorUpdates: CoordinatorProposalData[];
/** 最新草稿 */
latestDraft: Record<string, string>;
/** 背景检索数据 */
backgroundRetrieval: BackgroundRetrievalData | null;
/** 最终报告 */
finalReport: FinalReportData | null;
/** 最终报告已完成字段 */
finalReportPartial: Record<string, string>;
/** 当前正在生成的最终报告字段 */
currentFinalField: string | null;
/** 待人工确认的提案 */
pendingReview: PendingReview | null;
/** 是否正在提交人工确认 */
reviewSubmitting: boolean;
/** 是否正在等待协调者修改 */
reviewRevising: boolean;
/** 错误列表 */
errors: ErrorRecord[];
/** 时间线条目列表 */
timeline: AnyTimelineItem[];
}
/**
* 错误记录
*/
export interface ErrorRecord {
/** 错误消息 */
error: string;
/** 发生错误的智能体 ID */
agent_id?: string;
/** 其他错误数据 */
[key: string]: unknown;
}
/**
* =============================================================================
* 组件 Props 类型
* =============================================================================
*/
/**
* ConfigPanel 组件的 Props
*/
export interface ConfigPanelProps {
/** 开始会话回调 */
onStart: (config: AppConfig, backendUrl: string) => void;
/** 是否正在运行 */
isRunning: boolean;
/** 停止会话回调 */
onStop: () => void;
/** 打开场景配置页回调 */
onOpenScenarioConfig: () => void;
}
/**
* Sidebar 组件的 Props
*/
export interface SidebarProps {
/** 智能体列表 */
agents: Agent[];
/** 智能体状态映射 */
agentStates: Record<string, AgentState>;
/** 当前阶段 */
phase: PhaseType | null;
/** 当前轮次 */
currentRound: number;
/** 辩论轮次数 */
divergeRounds: number;
/** 总轮次数 */
totalRounds: number;
}
/**
* Timeline 组件的 Props
*/
export interface TimelineProps {
/** 时间线条目列表 */
timeline: AnyTimelineItem[];
/** 智能体状态映射 */
agentStates: Record<string, AgentState>;
/** 报告结构定义 */
reportSchema: Record<string, string>;
/** 待审核提案 */
pendingReview: PendingReview | null;
/** 审核通过回调 */
onApproveReview: (payload: HumanReviewPayload) => Promise<void>;
/** 审核修改回调 */
onReviseReview: (instruction: string) => Promise<void>;
/** 是否正在提交审核 */
reviewSubmitting: boolean;
/** 是否正在修改 */
reviewRevising: boolean;
}
/**
* AgentCard 组件的 Props
*/
export interface AgentCardProps {
/** 智能体 ID */
agentId: string;
/** 智能体名称 */
agentName: string;
/** 分析维度 */
dimension: string;
/** 当前轮次 */
round: number;
/** 当前阶段 */
phase: PhaseType;
/** 分析内容 */
content: string;
/** 思考过程 */
thinking: string;
/** 维度输出 */
dimensionOutput?: Record<string, string>;
/** 检索日志 */
retrievalLog?: RetrievalLogItem[];
/** 检索次数 */
retrievalCount?: number;
/** 引用映射表 */
referenceMap?: Record<string, string>;
/** 是否处于流式输出状态 */
streaming?: boolean;
}
/**
* ProgressSidebar 组件的 Props
*/
export interface ProgressSidebarProps {
/** 当前阶段 */
phase: PhaseType | null;
/** 会话状态 */
status: SessionStatus;
/** 当前轮次 */
currentRound: number;
/** 辩论轮次数 */
divergeRounds: number;
/** 报告结构定义 */
reportSchema: Record<string, string>;
/** 协调者更新历史 */
coordinatorUpdates: CoordinatorProposalData[];
/** 待审核提案 */
pendingReview: PendingReview | null;
/** 最新草稿 */
latestDraft: Record<string, string>;
/** 最终报告已完成字段 */
finalReportPartial: Record<string, string>;
/** 当前正在生成的字段 */
currentFinalField: string | null;
/** 智能体状态映射 */
agentStates: Record<string, AgentState>;
}
/**
* FinalReport 组件的 Props
*/
export interface FinalReportProps {
/** 最终报告输出数据 */
output: FinalReportData;
/** 报告结构定义 */
reportSchema: Record<string, string>;
}
/**
* MarkdownContent 组件的 Props
*/
export interface MarkdownContentProps {
/** Markdown 内容字符串 */
content: string;
/** 引用映射表(用于高亮引用标记) */
referenceMap?: Record<string, string>;
/** 额外的 CSS 类名 */
className?: string;
}
/**
* ScenarioConfigPage 组件的 Props
*/
export interface ScenarioConfigPageProps {
/** 返回上一页回调 */
onBack: () => void;
}