代码修改
This commit is contained in:
1 parent
8f5af46ec2
commit
8f75e12871
168 files changed
+76575
No files matched your search
@@ -0,0 +1,175 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Upload, FileText, Calendar, Users, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface CreateTaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (taskData: any) => void;
|
||||
}
|
||||
|
||||
const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onCreate }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
scene: '宣传策略生成',
|
||||
deadline: '',
|
||||
resources: '',
|
||||
constraints: '',
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.name || !formData.scene) {
|
||||
alert('请填写必填项');
|
||||
return;
|
||||
}
|
||||
onCreate(formData);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative bg-slate-800/95 backdrop-blur-xl border border-blue-800/30 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-blue-800/30">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">新建任务</h2>
|
||||
<p className="text-slate-400 text-sm mt-1">创建一个新的宣传策略分析任务</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-700/50 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-5 max-h-[60vh] overflow-y-auto">
|
||||
{/* Task Name */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">
|
||||
任务名称 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="例如:五四青年节宣传活动策略"
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Task Description */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">
|
||||
任务描述
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="详细描述任务目标和背景..."
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scene Selection */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">
|
||||
选择场景 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.scene}
|
||||
onChange={(e) => setFormData({ ...formData, scene: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
>
|
||||
<option value="宣传策略生成">宣传策略生成</option>
|
||||
<option value="宣传方案推演">宣传方案推演</option>
|
||||
<option value="宣传活动复盘">宣传活动复盘</option>
|
||||
<option value="舆情应对策略">舆情应对策略</option>
|
||||
<option value="多版本策略比较">多版本策略比较</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Deadline */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
时间要求
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.deadline}
|
||||
onChange={(e) => setFormData({ ...formData, deadline: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
资源条件
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.resources}
|
||||
onChange={(e) => setFormData({ ...formData, resources: e.target.value })}
|
||||
placeholder="例如:预算10万,运营团队3人..."
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Constraints */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
风险约束
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={formData.constraints}
|
||||
onChange={(e) => setFormData({ ...formData, constraints: e.target.value })}
|
||||
placeholder="描述任务的限制条件和风险边界..."
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
附件/参考资料
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-blue-800/30 rounded-lg p-6 text-center hover:border-blue-500/50 transition-colors cursor-pointer">
|
||||
<Upload className="w-8 h-8 text-slate-500 mx-auto mb-2" />
|
||||
<p className="text-slate-400 text-sm">点击或拖拽文件到此处上传</p>
|
||||
<p className="text-slate-500 text-xs mt-1">支持 PDF、Word、图片等格式</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-blue-800/30 bg-slate-900/30">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 bg-slate-700/50 hover:bg-slate-600/50 border border-blue-800/30 text-slate-300 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
开始分析
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTaskModal;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
const searilizeError = (error: any) => {
|
||||
if (error instanceof Error) {
|
||||
return error.message + '\n' + error.stack;
|
||||
}
|
||||
return JSON.stringify(error, null, 2);
|
||||
};
|
||||
|
||||
export class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error: any }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: any) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="p-4 border border-red-500 rounded">
|
||||
<h2 className="text-red-500">Something went wrong.</h2>
|
||||
<pre className="mt-2 text-sm">{searilizeError(this.state.error)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Bell, Search, User, ChevronDown, Moon, Sun, Palette } from 'lucide-react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
export interface userData {
|
||||
userName: string;
|
||||
typeName: string;
|
||||
}
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [userData, setUserData] = useState<userData>({
|
||||
userName: '',
|
||||
typeName: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let userInfo: any = localStorage.getItem('userInfo');
|
||||
if (userInfo) {
|
||||
userInfo = JSON.parse(userInfo);
|
||||
setUserData(userInfo);
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<header className={`h-16 flex items-center justify-between px-6 theme-transition ${
|
||||
isDark
|
||||
? 'bg-slate-900/50 backdrop-blur-xl border-b border-blue-800/30'
|
||||
: 'bg-red-600 border-b border-red-700'
|
||||
}`}>
|
||||
{/* 左侧 - 搜索框 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* <div className="relative">
|
||||
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${isDark ? 'text-slate-500' : 'text-red-200'}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索场景、智能体、任务..."
|
||||
className={`w-80 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-white/20 transition-all duration-200 theme-transition ${
|
||||
isDark
|
||||
? 'bg-slate-800/50 border border-blue-800/30 text-white placeholder-slate-500'
|
||||
: 'bg-red-700 border border-red-800 text-white placeholder-red-200'
|
||||
}`}
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* 右侧 - 用户信息和通知 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`p-2 rounded-lg transition-all duration-200 ${
|
||||
isDark
|
||||
? 'hover:bg-slate-800/50 text-slate-400 hover:text-white'
|
||||
: 'hover:bg-red-500 text-white'
|
||||
}`}
|
||||
title={isDark ? '切换到红白主题' : '切换到深色主题'}
|
||||
>
|
||||
{isDark ? (
|
||||
<Palette className="w-5 h-5" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 通知 */}
|
||||
{/* <button className={`relative p-2 rounded-lg transition-colors ${
|
||||
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
|
||||
}`}>
|
||||
<Bell className={`w-5 h-5 ${isDark ? 'text-slate-400' : 'text-white'}`} />
|
||||
<span className={`absolute top-1 right-1 w-2 h-2 rounded-full ${isDark ? 'bg-cyan-500' : 'bg-white'}`} />
|
||||
</button> */}
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className={`w-px h-8 ${isDark ? 'bg-blue-800/30' : 'bg-red-500'}`} />
|
||||
|
||||
{/* 用户信息 */}
|
||||
<button className={`flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors ${
|
||||
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
|
||||
}`}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-blue-500 to-cyan-400'
|
||||
: 'bg-white'
|
||||
}`}>
|
||||
<User className={`w-4 h-4 ${isDark ? 'text-white' : 'text-red-600'}`} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-white' : 'text-white'}`}>{userData.userName}</p>
|
||||
<p className={`text-xs ${isDark ? 'text-blue-400/60' : 'text-red-200'}`}>{userData.typeName}</p>
|
||||
</div>
|
||||
{/* <ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-white'}`} /> */}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
onCitationClick?: (index: number) => void;
|
||||
}
|
||||
|
||||
const extractTextFromNode = (node: React.ReactNode): string => {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
if (Array.isArray(node)) return node.map(extractTextFromNode).join('');
|
||||
if (React.isValidElement<{ children?: React.ReactNode }>(node)) return extractTextFromNode(node.props.children);
|
||||
return '';
|
||||
};
|
||||
|
||||
const CodeBlock: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const codeText = extractTextFromNode(children).replace(/\n$/, '');
|
||||
const language = className?.match(/language-(\w+)/)?.[1];
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(codeText);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch (error) {
|
||||
console.error('复制代码失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative my-4 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 shadow-sm dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400">
|
||||
<span>{language || 'code'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
|
||||
>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="m-0 overflow-x-auto p-4 text-sm leading-relaxed text-gray-800 dark:text-slate-100">
|
||||
<code className={className}>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '', onCitationClick }) => {
|
||||
const contentWithCitationBadge = content.replace(
|
||||
/\[\[(\d+)\]\]/g,
|
||||
'<button type="button" data-citation-index="$1" class="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-2 mx-0.5 rounded-md border text-xs font-semibold align-middle bg-red-50 text-red-600 border-red-200 dark:bg-slate-700 dark:text-slate-100 dark:border-slate-500 cursor-pointer transition-all hover:opacity-85">[$1]</button>',
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`markdown-content ${className}`}
|
||||
onClick={(event) => {
|
||||
if (!onCitationClick) return;
|
||||
const target = event.target as HTMLElement;
|
||||
const button = target.closest('[data-citation-index]') as HTMLElement | null;
|
||||
if (!button) return;
|
||||
const index = Number(button.dataset.citationIndex || '0');
|
||||
if (Number.isNaN(index) || index <= 0) return;
|
||||
onCitationClick(index);
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{
|
||||
h1: ({ node, ...props }) => <h1 className="text-2xl font-bold mb-4" {...props} />,
|
||||
h2: ({ node, ...props }) => <h2 className="text-xl font-bold mb-3" {...props} />,
|
||||
h3: ({ node, ...props }) => <h3 className="text-lg font-bold mb-2" {...props} />,
|
||||
h4: ({ node, ...props }) => <h4 className="text-base font-bold mb-2" {...props} />,
|
||||
h5: ({ node, ...props }) => <h5 className="text-sm font-bold mb-2" {...props} />,
|
||||
h6: ({ node, ...props }) => <h6 className="text-xs font-bold mb-2" {...props} />,
|
||||
p: ({ node, ...props }) => <p className="mb-4 leading-relaxed" {...props} />,
|
||||
ul: ({ node, ...props }) => <ul className="list-disc list-inside mb-4 ml-4" {...props} />,
|
||||
ol: ({ node, ...props }) => <ol className="list-decimal list-inside mb-4 ml-4" {...props} />,
|
||||
li: ({ node, ...props }) => <li className="mb-1" {...props} />,
|
||||
// code: ({ node, inline, className, children, ...props }: any) => {
|
||||
// if (inline) {
|
||||
// return (
|
||||
// <code
|
||||
// className={`rounded bg-gray-100 px-1 py-0.5 text-sm text-gray-800 dark:bg-slate-800 dark:text-slate-100 ${className || ''}`}
|
||||
// {...props}
|
||||
// >
|
||||
// {children}
|
||||
// </code>
|
||||
// );
|
||||
// }
|
||||
// return <CodeBlock className={className}>{children}</CodeBlock>;
|
||||
// },
|
||||
// pre: ({ node, children }) => <>{children}</>,
|
||||
blockquote: ({ node, ...props }) => (
|
||||
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic my-4" {...props} />
|
||||
),
|
||||
a: ({ node, ...props }) => (
|
||||
<a className="text-blue-600 dark:text-blue-400 hover:underline" {...props} />
|
||||
),
|
||||
table: ({ node, ...props }) => (
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="min-w-full border-collapse" {...props} />
|
||||
</div>
|
||||
),
|
||||
thead: ({ node, ...props }) => <thead className="bg-gray-100 dark:bg-gray-800" {...props} />,
|
||||
tbody: ({ node, ...props }) => <tbody className="divide-y divide-gray-200 dark:divide-gray-700" {...props} />,
|
||||
tr: ({ node, ...props }) => <tr className="hover:bg-gray-50 dark:hover:bg-gray-700" {...props} />,
|
||||
th: ({ node, ...props }) => <th className="px-4 py-2 text-left font-semibold" {...props} />,
|
||||
td: ({ node, ...props }) => <td className="px-4 py-2" {...props} />,
|
||||
hr: ({ node, ...props }) => <hr className="my-4 border-gray-300 dark:border-gray-600" {...props} />,
|
||||
strong: ({ node, ...props }) => <strong className="font-bold" {...props} />,
|
||||
em: ({ node, ...props }) => <em className="italic" {...props} />,
|
||||
}}
|
||||
>
|
||||
{contentWithCitationBadge}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownRenderer;
|
||||
@@ -0,0 +1,572 @@
|
||||
import React, { useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
BarChart3,
|
||||
Bot,
|
||||
Brain,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
Compass,
|
||||
Database,
|
||||
FileBox,
|
||||
FileOutput,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Grid3X3,
|
||||
HelpCircle,
|
||||
History,
|
||||
Layers,
|
||||
Layout,
|
||||
Library,
|
||||
ListTodo,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Network,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
Scale,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
|
||||
export type PageType = string;
|
||||
|
||||
type MenuItem = {
|
||||
id: PageType;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
children?: MenuItem[];
|
||||
path?: string;
|
||||
activePath?: string;
|
||||
};
|
||||
type Props = {
|
||||
currentPage: PageType;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
};
|
||||
type Level1MenuId =
|
||||
| "qa-center"
|
||||
| "light-app-center"
|
||||
| "task-management"
|
||||
| "agent-list"
|
||||
| "result-management"
|
||||
| "resource-management";
|
||||
type TaskGroupId = string;
|
||||
|
||||
const taskGroups: TaskGroupId[] = [
|
||||
"xk-task",
|
||||
"plan-task",
|
||||
"mnty-task",
|
||||
"effect-task",
|
||||
];
|
||||
const collapsibleMenus = new Set<PageType>([
|
||||
"qa-center",
|
||||
"light-app-center",
|
||||
"task-management",
|
||||
"agent-list",
|
||||
"result-management",
|
||||
"resource-management",
|
||||
]);
|
||||
const taskPages = new Set([
|
||||
"dashboard",
|
||||
"task-create",
|
||||
"task-single",
|
||||
"task-scene",
|
||||
"task-consult-create",
|
||||
"task-consult",
|
||||
"task-consult-result",
|
||||
"task-list",
|
||||
]);
|
||||
const qaPages = new Set([
|
||||
"new-qa",
|
||||
"general-qa",
|
||||
"agent-qa",
|
||||
"multi-agent-qa",
|
||||
"qa-records",
|
||||
"sixstep-chat",
|
||||
"topic-research",
|
||||
"task-chat",
|
||||
"action-chat",
|
||||
"qa-from",
|
||||
"qa-from-6bf"
|
||||
]);
|
||||
const lightAppPages = new Set([
|
||||
"light-app-square",
|
||||
"light-app-records",
|
||||
"light-app-run",
|
||||
]);
|
||||
const agentPages = new Set(["agent-trial"]);
|
||||
const resultPages = new Set([
|
||||
"result-planning",
|
||||
"result-mnty",
|
||||
"result-effect-pg",
|
||||
"result-xk",
|
||||
"result-dq",
|
||||
"result-other",
|
||||
"result-detail",
|
||||
]);
|
||||
const resourcePages = new Set([
|
||||
"resource-zz",
|
||||
"resource-network-actor",
|
||||
"resource-knowledge-base",
|
||||
"resource-evaluation-index",
|
||||
]);
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
label: "问答管理",
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "qa-from", label: "通用问答", icon: Bot }, // general-qa
|
||||
// { id: 'sixstep-chat', label: 'zz决策支持', icon: Layers },
|
||||
{ id: "qa-from-6bf", label: "zz决策支持", icon: Layers },
|
||||
{ id: "action-chat", label: "zzXD应用", icon: History },
|
||||
{ id: "topic-research", label: "课题研究", icon: CircleHelp },
|
||||
// { id: 'agent-qa', label: '智能体问答', icon: Bot },
|
||||
// { id: 'multi-agent-qa', label: '多智能体协同', icon: Layers },
|
||||
// { id: 'qa-records', label: '问答记录', icon: History },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "task-management",
|
||||
label: "任务管理",
|
||||
icon: Target,
|
||||
children: [
|
||||
// { id: "dashboard", label: "任务统计", icon: BarChart3 },
|
||||
{
|
||||
id: "xk-task",
|
||||
label: "XK中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo },
|
||||
{ id: "xk-task-dispatch", label: "任务调度", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "plan-task",
|
||||
label: "COH中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "plan-task-sentiment", label: "三情FX", icon: ListTodo },
|
||||
{ id: "plan-task-fx", label: "任务FX", icon: ListTodo },
|
||||
{ id: "plan-task-action", label: "行动CE", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "effect-task",
|
||||
label: "PG中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "effect-task-pg", label: "任务PG", icon: ListTodo },
|
||||
{ id: "effect-action-pg", label: "行动PG", icon: ListTodo },
|
||||
{ id: "effect-info-pg", label: "信息PG", icon: ListTodo },
|
||||
// { id: "effect-account-pg", label: "账号PG", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mnty-task",
|
||||
label: "TY中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "mnty-task-mechanism", label: "XDTY任务", icon: ListTodo },
|
||||
{ id: "mnty-task-plan", label: "方案TY任务", icon: ListTodo },
|
||||
{ id: "mnty-task-zsty", label: "XXTY任务", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// id: 'light-app-center', label: '工具管理', icon: Grid3X3,
|
||||
// children: [
|
||||
// { id: 'light-app-square', label: '应用广场', icon: Layout },
|
||||
// { id: 'light-app-records', label: '运行记录', icon: History },
|
||||
// { id: 'scene-list', label: '场景管理', icon: Layers },
|
||||
// ]
|
||||
// },
|
||||
{
|
||||
id: "agent-list",
|
||||
label: " 综合管理",
|
||||
icon: Bot,
|
||||
children: [
|
||||
// { id: 'agent-list', label: '业务链条管理', icon: Users },
|
||||
// { id: 'agent-list', label: '智能体管理', icon: Users },
|
||||
{ id: "agent-expert-center", label: "智能体管理", icon: Users },
|
||||
{ id: 'scene-list', label: '业务链条管理', icon: Layers },
|
||||
// { id: "skills-workshop", label: "技能列表", icon: Users },
|
||||
// { id: "sixstep-agents", label: "mcp管理", icon: Layers },
|
||||
// { id: "file-skills", label: "skills管理", icon: Layers },
|
||||
// { id: 'agent-trial', label: '单智能体试用', icon: PlayCircle }
|
||||
],
|
||||
},
|
||||
// { id: 'result-management', label: '结果管理', icon: BarChart3, children: [
|
||||
// { id: 'result-planning', label: '规划任务', icon: Target, children: [
|
||||
// { id: 'result-planning-all', label: '全部', icon: BarChart3, path: '/result-management/result-planning', activePath: '/result-management/result-planning' },
|
||||
// { id: 'result-planning-goal', label: '目标分析', icon: Target, path: '/result-management/result-planning?subCategory=目标分析' },
|
||||
// { id: 'result-planning-brand', label: '品牌策略', icon: Target, path: '/result-management/result-planning?subCategory=品牌策略' },
|
||||
// { id: 'result-planning-content', label: '内容规划', icon: Target, path: '/result-management/result-planning?subCategory=内容规划' },
|
||||
// { id: 'result-planning-channel', label: '渠道规划', icon: Target, path: '/result-management/result-planning?subCategory=渠道规划' },
|
||||
// { id: 'result-planning-narrative', label: '叙事策略', icon: Target, path: '/result-management/result-planning?subCategory=叙事策略' },
|
||||
// ] },
|
||||
// { id: 'result-mnty', label: 'MNTY任务', icon: Brain, children: [
|
||||
// { id: 'result-mnty-all', label: '全部', icon: BarChart3, path: '/result-management/result-mnty', activePath: '/result-management/result-mnty' },
|
||||
// { id: 'result-mnty-mechanism', label: '机制研究', icon: Brain, path: '/result-management/result-mnty?subCategory=机制研究' },
|
||||
// { id: 'result-mnty-plan', label: '方案设计', icon: Brain, path: '/result-management/result-mnty?subCategory=方案设计' },
|
||||
// { id: 'result-mnty-zsty', label: 'ZSTY分析', icon: Brain, path: '/result-management/result-mnty?subCategory=ZSTY分析' },
|
||||
// ] },
|
||||
// { id: 'result-effect-pg', label: '效果PG任务', icon: TrendingUp, children: [
|
||||
// { id: 'result-effect-pg-all', label: '全部', icon: BarChart3, path: '/result-management/result-effect-pg', activePath: '/result-management/result-effect-pg' },
|
||||
// { id: 'result-effect-pg-behavior', label: '行为PG', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=行为PG' },
|
||||
// { id: 'result-effect-pg-evaluation', label: '效果评估', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=效果评估' },
|
||||
// { id: 'result-effect-pg-info', label: '信息PG', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=信息PG' },
|
||||
// { id: 'result-effect-pg-account', label: '账号PG', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=账号PG' },
|
||||
// ] },
|
||||
// { id: 'result-xk', label: 'XK任务', icon: Compass, children: [
|
||||
// { id: 'result-xk-all', label: '全部', icon: BarChart3, path: '/result-management/result-xk', activePath: '/result-management/result-xk' },
|
||||
// { id: 'result-xk-jc', label: '任务JC', icon: Compass, path: '/result-management/result-xk?subCategory=任务JC' },
|
||||
// { id: 'result-xk-dispatch', label: '任务调度', icon: Compass, path: '/result-management/result-xk?subCategory=任务调度' },
|
||||
// ] },
|
||||
// { id: 'result-dq', label: 'DQ研究类任务', icon: Database, children: [
|
||||
// { id: 'result-dq-all', label: '全部', icon: BarChart3, path: '/result-management/result-dq', activePath: '/result-management/result-dq' },
|
||||
// { id: 'result-dq-audience', label: '受众分析', icon: Database, path: '/result-management/result-dq?subCategory=受众分析' },
|
||||
// { id: 'result-dq-kb', label: '知识库研究', icon: Database, path: '/result-management/result-dq?subCategory=知识库研究' },
|
||||
// { id: 'result-dq-trend', label: '趋势研究', icon: Database, path: '/result-management/result-dq?subCategory=趋势研究' },
|
||||
// ] },
|
||||
// { id: 'result-other', label: '其他类任务', icon: MoreHorizontal, children: [
|
||||
// { id: 'result-other-all', label: '全部', icon: BarChart3, path: '/result-management/result-other', activePath: '/result-management/result-other' },
|
||||
// { id: 'result-other-general', label: '通用分析', icon: MoreHorizontal, path: '/result-management/result-other?subCategory=通用分析' },
|
||||
// { id: 'result-other-temp', label: '临时任务', icon: MoreHorizontal, path: '/result-management/result-other?subCategory=临时任务' },
|
||||
// { id: 'result-other-other', label: '其他', icon: MoreHorizontal, path: '/result-management/result-other?subCategory=其他' },
|
||||
// ] },
|
||||
// ] },
|
||||
{
|
||||
id: "resource-management",
|
||||
label: "资源管理",
|
||||
icon: Database,
|
||||
children: [
|
||||
// { id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
|
||||
{
|
||||
id: "resource-network-actor",
|
||||
label: "网络行为体资源管理",
|
||||
icon: Network,
|
||||
},
|
||||
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
|
||||
{ id: "resource-evaluation-index", label: "作战资源管理", icon: Library },
|
||||
// {
|
||||
// id: "resource-evaluation-index",
|
||||
// label: "评估指标体系资源管理",
|
||||
// icon: Scale,
|
||||
// },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const subEntries = [
|
||||
// { id: 'task-create', label: '新建任务', icon: ListTodo, parent: 'task-management' },
|
||||
// { id: 'task-single', label: '单智能体任务', icon: Bot, parent: 'task-management' },
|
||||
// { id: 'task-scene', label: '场景任务', icon: Workflow, parent: 'task-management' },
|
||||
// { id: 'task-consult-create', label: '多智能体会商', icon: MessageSquare, parent: 'task-management' },
|
||||
// { id: 'final-plan', label: '方案输出', icon: FileOutput, parent: 'strategy-result' },
|
||||
];
|
||||
|
||||
const getPath = (id: PageType) => {
|
||||
if (taskGroups.includes(id as TaskGroupId)) {
|
||||
console.log('----task-management', id);
|
||||
return `/task-management/${id}`;
|
||||
}
|
||||
if (taskGroups.some((group) => id.startsWith(group))) {
|
||||
return `/task-management/${id.split("-").slice(0, 2).join("-")}/${id}`;
|
||||
}
|
||||
// 特殊处理 effect-task 下的子菜单项(effect-action-pg, effect-info-pg, effect-account-pg)
|
||||
if (id.startsWith("effect-") && !id.startsWith("effect-task")) {
|
||||
return `/task-management/effect-task/${id}`;
|
||||
}
|
||||
if (taskPages.has(id)) return `/task-management/${id}`;
|
||||
if (qaPages.has(id)) return `/qa-center/${id}`;
|
||||
if (id === "qa-center") return "/qa-center";
|
||||
if (lightAppPages.has(id)) return `/light-app-center/${id}`;
|
||||
if (agentPages.has(id)) return `/agent-list/${id}`;
|
||||
if (resultPages.has(id)) return `/result-management/${id}`;
|
||||
if (resourcePages.has(id)) return `/resource-management/${id}`;
|
||||
|
||||
return `/${id}`;
|
||||
};
|
||||
|
||||
const toggleSet = <T,>(set: Set<T>, item: T) => {
|
||||
const next = new Set(set);
|
||||
next.has(item) ? next.delete(item) : next.add(item);
|
||||
return next;
|
||||
};
|
||||
|
||||
export default function Sidebar({
|
||||
currentPage,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse,
|
||||
}: Props) {
|
||||
const { theme } = useTheme();
|
||||
const location = useLocation();
|
||||
const isDark = theme === "dark";
|
||||
const [collapsedMenus, setCollapsedMenus] = useState<Set<Level1MenuId>>(
|
||||
new Set()
|
||||
);
|
||||
const [expandedTaskGroups, setExpandedTaskGroups] = useState<Set<string>>(
|
||||
() =>
|
||||
new Set([
|
||||
...taskGroups.filter((group) => currentPage.startsWith(group)),
|
||||
...(currentPage.startsWith("result-") ? [currentPage] : []),
|
||||
])
|
||||
);
|
||||
|
||||
const panel = `ml-4 pl-4 border-l space-y-1 theme-transition ${
|
||||
isDark ? "border-blue-800/30" : "border-red-100"
|
||||
}`;
|
||||
const iconBtn = `rounded-lg transition-all duration-200 ${
|
||||
isDark
|
||||
? "text-slate-400 hover:text-white hover:bg-slate-800/50"
|
||||
: "text-gray-500 hover:text-red-600 hover:bg-red-50"
|
||||
}`;
|
||||
const itemClass = (active: boolean) =>
|
||||
`rounded-lg transition-all duration-200 theme-transition ${
|
||||
active
|
||||
? isDark
|
||||
? "bg-blue-600/20 text-blue-400 border border-blue-500/30"
|
||||
: "bg-red-50 text-red-600 border border-red-200"
|
||||
: isDark
|
||||
? "text-slate-400 hover:text-white hover:bg-slate-800/50"
|
||||
: "text-gray-600 hover:text-red-600 hover:bg-red-50"
|
||||
}`;
|
||||
|
||||
const isTopActive = (id: PageType) => {
|
||||
if (id === "qa-center") return qaPages.has(currentPage);
|
||||
if (id === "light-app-center") return currentPage.startsWith("light-app");
|
||||
if (id === "task-management")
|
||||
return (
|
||||
currentPage === "dashboard" ||
|
||||
taskPages.has(currentPage) ||
|
||||
taskGroups.some((group) => currentPage.startsWith(group))
|
||||
);
|
||||
if (id === "scene-list") return currentPage.startsWith("scene");
|
||||
if (id === "agent-list") return currentPage.startsWith("agent");
|
||||
if (id === "result-management") return currentPage.startsWith("result");
|
||||
if (id === "resource-management") return currentPage.startsWith("resource");
|
||||
return currentPage === id;
|
||||
};
|
||||
const hasActiveChild = (item: MenuItem): boolean =>
|
||||
currentPage === item.id || Boolean(item.children?.some(hasActiveChild));
|
||||
|
||||
const renderLink = (item: MenuItem, top = false) => {
|
||||
const Icon = item.icon;
|
||||
const activePath = decodeURI(`${location.pathname}${location.search}`);
|
||||
const active = top
|
||||
? isTopActive(item.id)
|
||||
: item.path
|
||||
? activePath === (item.activePath ?? item.path)
|
||||
: currentPage === item.id;
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path ?? getPath(item.id)}
|
||||
onClick={() =>
|
||||
top &&
|
||||
item.id === "task-management" &&
|
||||
expandedTaskGroups.size === 0 &&
|
||||
setExpandedTaskGroups(new Set(["xk-task"]))
|
||||
}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
className={`${top ? "flex-1" : "w-full"} flex items-center ${
|
||||
isCollapsed && top ? "justify-center gap-0 px-2" : "gap-2 px-3"
|
||||
} py-2 ${itemClass(active)}`}
|
||||
>
|
||||
<Icon
|
||||
className={`${top ? "w-5 h-5" : "w-4 h-4"} flex-shrink-0 ${
|
||||
active ? (isDark ? "text-blue-400" : "text-red-500") : ""
|
||||
}`}
|
||||
/>
|
||||
{(!isCollapsed || !top) && (
|
||||
<span
|
||||
className={`${
|
||||
top ? "text-sm" : "text-xs"
|
||||
} font-medium flex-1 text-left`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTaskGroup = (item: MenuItem) => {
|
||||
const expanded = expandedTaskGroups.has(item.id);
|
||||
const active = hasActiveChild(item);
|
||||
const Icon = expanded ? FolderOpen : Folder;
|
||||
return (
|
||||
<div key={item.id} className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedTaskGroups((prev) => toggleSet(prev, item.id))
|
||||
}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 ${itemClass(
|
||||
active
|
||||
)}`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-4 h-4 flex-shrink-0 ${
|
||||
active ? (isDark ? "text-blue-400" : "text-red-500") : ""
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs font-medium flex-1 text-left">
|
||||
{item.label}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={`w-4 h-4 transition-transform duration-200 ${
|
||||
expanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{expanded && item.children && (
|
||||
<div className={panel}>
|
||||
{item.children.map((child) => renderLink(child))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderChildren = (item: MenuItem) =>
|
||||
item.children ? (
|
||||
<div className={panel}>
|
||||
{item.children.map((child) =>
|
||||
child.children ? renderTaskGroup(child) : renderLink(child)
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
const renderSubEntries = (item: MenuItem) => {
|
||||
const entries = subEntries.filter((entry) => entry.parent === item.id);
|
||||
return entries.length && isTopActive(item.id) ? (
|
||||
<div className={panel}>{entries.map((entry) => renderLink(entry))}</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 h-screen ${
|
||||
isCollapsed ? "w-16" : "w-64"
|
||||
} z-50 theme-transition overflow-y-auto ${
|
||||
isDark
|
||||
? "bg-slate-900/80 backdrop-blur-xl border-r border-blue-800/30"
|
||||
: "bg-white border-r border-red-100 shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-16 flex items-center ${
|
||||
isCollapsed ? "justify-center px-2" : "justify-between px-6"
|
||||
} border-b theme-transition flex-shrink-0 ${
|
||||
isDark ? "border-blue-800/30" : "border-red-100"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||
isDark
|
||||
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||||
: "bg-gradient-to-br from-red-500 to-red-400"
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
<h1
|
||||
className={`font-semibold text-sm ${
|
||||
isDark ? "text-white" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
智能CM系统
|
||||
</h1>
|
||||
<p
|
||||
className={`text-xs ${
|
||||
isDark ? "text-blue-400/60" : "text-red-400/80"
|
||||
}`}
|
||||
>
|
||||
Smart CM Platform
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
className={`p-2 ${iconBtn}`}
|
||||
title="收起侧边栏"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<nav className={`${isCollapsed ? "p-2" : "p-4"} space-y-2`}>
|
||||
{isCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
className={`w-full flex items-center justify-center p-2.5 ${iconBtn}`}
|
||||
title="展开侧边栏"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{menuItems.map((item) => {
|
||||
const canCollapse = collapsibleMenus.has(item.id);
|
||||
const childrenCollapsed =
|
||||
canCollapse && collapsedMenus.has(item.id as Level1MenuId);
|
||||
return (
|
||||
<div key={item.id} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderLink(item, true)}
|
||||
{!isCollapsed && item.children && canCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCollapsedMenus((prev) =>
|
||||
toggleSet(prev, item.id as Level1MenuId)
|
||||
)
|
||||
}
|
||||
className={`p-1.5 ${iconBtn}`}
|
||||
title={childrenCollapsed ? "展开菜单" : "折叠菜单"}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-4 h-4 transition-transform duration-200 ${
|
||||
childrenCollapsed ? "" : "rotate-90"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed &&
|
||||
item.children &&
|
||||
!childrenCollapsed &&
|
||||
renderChildren(item)}
|
||||
{!isCollapsed && !childrenCollapsed && renderSubEntries(item)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{isDark ? (
|
||||
<>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-20 right-0 w-24 h-24 bg-cyan-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-red-500/5 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-20 right-0 w-24 h-24 bg-red-400/5 rounded-full blur-3xl pointer-events-none" />
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
@keyframes breathing {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathingGlow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-breathing {
|
||||
animation: breathing 1.5s ease-in-out infinite, breathingGlow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.agent-resizing {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll,
|
||||
.agent-file-preview-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar-track,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar-thumb,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.38);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar-thumb:hover,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(96, 165, 250, 0.58);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.agent-file-markdown-preview > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.agent-file-markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.agent-file-markdown-preview img {
|
||||
max-width: 100%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,55 @@
|
||||
import SkillCreatorConversationItem from './SkillCreatorConversationItem';
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export interface ConversationStep {
|
||||
text: string;
|
||||
subText?: string;
|
||||
status: 'done' | 'processing';
|
||||
}
|
||||
|
||||
export interface ConversationMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
steps?: ConversationStep[];
|
||||
thinkings?: Array<{ duration: number; content: string }>;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
interface AgentConversationListProps {
|
||||
messages: ConversationMessage[];
|
||||
isConnecting?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AgentConversationList({ messages, isConnecting = false, className = '' }: AgentConversationListProps) {
|
||||
return (
|
||||
<div className={`w-full max-w-4xl mx-auto space-y-5 pb-6 ${className}`}>
|
||||
{messages.map((message, index) => {
|
||||
// 仅在最后一条 assistant 且尚未收到任何生成内容时显示“连接中”态
|
||||
// 一旦出现 steps/thinkings/toolCalls/content,就切回正常渲染
|
||||
const hasStartedGenerating =
|
||||
Boolean(message.content) ||
|
||||
Boolean(message.steps?.length) ||
|
||||
Boolean(message.thinkings?.length) ||
|
||||
Boolean(message.toolCalls?.length);
|
||||
const shouldShowConnecting =
|
||||
isConnecting && index === messages.length - 1 && message.role === 'assistant' && !hasStartedGenerating;
|
||||
|
||||
return (
|
||||
<SkillCreatorConversationItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
isConnecting={shouldShowConnecting}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, Sparkles, ArrowRight, MessageCircle, Bot, BriefcaseBusiness, Info, Pencil, Trash2, Eye, EyeOff, MessageCircleMore, InfoIcon, Edit } from 'lucide-react';
|
||||
import { Dialog, Loading, MessagePlugin, Popconfirm, Switch } from 'tdesign-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useExpertAuthBootstrap } from '../../hooks';
|
||||
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
|
||||
|
||||
interface ExpertAgentItem {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
description: string | null;
|
||||
model_name: string;
|
||||
is_draft: boolean;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type ExpertTab = 'mine' | 'published';
|
||||
|
||||
export default function AgentExpertCenterPage() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const { llmList, currentUser, authedFetch } = useExpertAuthBootstrap();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ExpertTab>('mine');
|
||||
const [agentList, setAgentList] = useState<ExpertAgentItem[]>([]);
|
||||
const [isListLoading, setIsListLoading] = useState(false);
|
||||
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [selectedAgent, setSelectedAgent] = useState<ExpertAgentItem | null>(null);
|
||||
|
||||
const fetchExpertList = useCallback(async () => {
|
||||
setIsListLoading(true);
|
||||
try {
|
||||
const isDraft = activeTab === 'mine';
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ is_draft: isDraft }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('expert-agents/list接口调用失败:', res.status, res.statusText);
|
||||
setAgentList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: unknown = await res.json();
|
||||
const rawList = Array.isArray(data) ? (data as ExpertAgentItem[]) : [];
|
||||
|
||||
if (isDraft) {
|
||||
const mineFromList = currentUser ? rawList.filter((item) => item.user_id === currentUser.id) : [];
|
||||
|
||||
const allRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
let mineFromAll: ExpertAgentItem[] = [];
|
||||
if (allRes.ok) {
|
||||
const allData: unknown = await allRes.json();
|
||||
const allList = Array.isArray(allData) ? (allData as ExpertAgentItem[]) : [];
|
||||
mineFromAll = currentUser ? allList.filter((item) => item.user_id === currentUser.id) : [];
|
||||
} else {
|
||||
console.error('expert-agents接口调用失败:', allRes.status, allRes.statusText);
|
||||
}
|
||||
|
||||
const mergedMap = new Map<number, ExpertAgentItem>();
|
||||
[...mineFromList, ...mineFromAll].forEach((item) => mergedMap.set(item.id, item));
|
||||
setAgentList(Array.from(mergedMap.values()));
|
||||
return;
|
||||
}
|
||||
|
||||
setAgentList(rawList);
|
||||
} catch (error) {
|
||||
console.error('expert-agents/list接口异常:', error);
|
||||
setAgentList([]);
|
||||
} finally {
|
||||
setIsListLoading(false);
|
||||
}
|
||||
}, [activeTab, authedFetch, currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'mine' && !currentUser) return;
|
||||
void fetchExpertList();
|
||||
}, [activeTab, currentUser, fetchExpertList]);
|
||||
|
||||
const handleStatusToggle = async (nextPublished: boolean) => {
|
||||
if (!selectedAgent) return;
|
||||
|
||||
const nextIsDraft = !nextPublished;
|
||||
setIsStatusUpdating(true);
|
||||
try {
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${selectedAgent.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ is_draft: nextIsDraft }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('更新专家状态失败:', res.status, res.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAgent({ ...selectedAgent, is_draft: nextIsDraft });
|
||||
await fetchExpertList();
|
||||
} catch (error) {
|
||||
console.error('更新专家状态异常:', error);
|
||||
} finally {
|
||||
setIsStatusUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addAgentSearch = async () => {
|
||||
const parmy = {
|
||||
model_name: 'Qwen3.5-27B',
|
||||
name: '未命名专家',
|
||||
system_prompt: '你是一个专业的助手,能够回答用户的问题。'
|
||||
}
|
||||
const res: any = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(parmy),
|
||||
});
|
||||
console.log('创建专家', res);
|
||||
const data: any = await res.json()
|
||||
navigate(`/agent-expert-edit?id=${data.id}`)
|
||||
}
|
||||
|
||||
const delAgentSearch = async (id) => {
|
||||
const res: any = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
MessagePlugin.success('操作成功')
|
||||
await fetchExpertList();
|
||||
}
|
||||
|
||||
const cards = useMemo(
|
||||
() =>
|
||||
agentList.map((item) => ({
|
||||
id: String(item.id),
|
||||
title: item.name || '未命名专家',
|
||||
desc: item.description || '暂无描述',
|
||||
status: item.is_draft ? '草稿' : '已发布',
|
||||
avatarSvg: item.avatar_url,
|
||||
raw: item,
|
||||
})),
|
||||
[agentList],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`w-full min-h-[calc(100vh-140px)] ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
<div className="max-w-[1100px] mx-auto">
|
||||
<section className={`rounded-2xl border px-6 py-6 ${isDark ? 'bg-slate-900/60 border-slate-700' : 'bg-white border-gray-200'}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-fuchsia-500 to-violet-600 flex items-center justify-center text-white shadow">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">我的智能体</h1>
|
||||
</div>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>管理你的专属 AI 专家助手</p>
|
||||
<p className={`mt-1 text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>已加载模型:{llmList.length}</p>
|
||||
|
||||
<div className="mt-4 inline-flex items-center rounded-lg border overflow-hidden">
|
||||
<button
|
||||
onClick={() => setActiveTab('mine')}
|
||||
className={`px-3 py-1.5 text-xs ${activeTab === 'mine' ? 'bg-slate-900 text-white' : isDark ? 'bg-slate-800/60 text-slate-300' : 'bg-gray-50 text-gray-600'}`}
|
||||
>
|
||||
我的智能体
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('published')}
|
||||
className={`px-3 py-1.5 text-xs ${activeTab === 'published' ? 'bg-slate-900 text-white' : isDark ? 'bg-slate-800/60 text-slate-300' : 'bg-gray-50 text-gray-600'}`}
|
||||
>
|
||||
发布的智能体
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="inline-flex items-center gap-2 h-10 px-4 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition" onClick={addAgentSearch}>
|
||||
<Plus className="w-4 h-4" /> 创建智能体
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
{isListLoading ? (
|
||||
<div className={`min-h-[280px] rounded-2xl border flex items-center justify-center ${isDark ? 'bg-slate-900/40 border-slate-700' : 'bg-white border-gray-200'}`}>
|
||||
<Loading loading text="加载专家列表中..." />
|
||||
</div>
|
||||
) : cards.length === 0 ? (
|
||||
<div className={`min-h-[280px] rounded-2xl border flex flex-col items-center justify-center text-center px-4 ${isDark ? 'bg-slate-900/40 border-slate-700' : 'bg-white border-gray-200'}`}>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center mb-3 ${isDark ? 'bg-slate-800 text-slate-300' : 'bg-gray-100 text-gray-500'}`}>
|
||||
<BriefcaseBusiness className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className={`text-base font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
{activeTab === 'mine' ? '暂无我的专家' : '暂无发布的专家'}
|
||||
</h3>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
创建你的第一个专家助手,开始高效工作
|
||||
</p>
|
||||
<button className="mt-4 inline-flex items-center gap-2 h-10 px-4 rounded-xl bg-slate-900 text-white hover:bg-slate-800 transition">
|
||||
<Plus className="w-4 h-4" /> 创建第一个专家
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{cards.map((item, idx) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className={`rounded-2xl border p-5 transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 ${isDark ? 'bg-slate-900/60 border-slate-700 hover:border-slate-500' : 'bg-white border-gray-200 hover:border-gray-300'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{item.avatarSvg ? (
|
||||
<img
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(item.avatarSvg)}`}
|
||||
alt={item.title}
|
||||
className="w-10 h-10 rounded-xl object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center text-white shadow-sm flex-shrink-0">
|
||||
{idx % 2 === 0 ? <MessageCircle className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-[15px] leading-6 truncate">{item.title}</h3>
|
||||
<p className={`text-xs mt-1 line-clamp-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{activeTab === 'mine' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title="查看详情"
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-700/60' : 'border-gray-200 text-gray-500 hover:bg-gray-100'}`}
|
||||
onClick={() => {
|
||||
setSelectedAgent(item.raw);
|
||||
setDetailVisible(true);
|
||||
}}
|
||||
>
|
||||
<Info className="w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="编辑"
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-700/60' : 'border-gray-200 text-gray-500 hover:bg-gray-100'}`}
|
||||
onClick={() => {
|
||||
navigate(`/agent-expert-edit?id=${item.id}`);
|
||||
}}
|
||||
>
|
||||
<Edit className="w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
<Popconfirm
|
||||
content="确认删除该条数据吗?"
|
||||
onConfirm={() => delAgentSearch(item.id)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title="删除"
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-rose-900/20 hover:text-rose-300' : 'border-gray-200 text-gray-500 hover:bg-rose-50 hover:text-rose-500'}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded-full border ${isDark ? 'bg-emerald-950/50 border-emerald-800 text-emerald-300' : 'bg-emerald-50 border-emerald-200 text-emerald-700'}`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/agent-chat?id=${item.id}`)}
|
||||
className={`mt-6 inline-flex items-center gap-1 text-sm ${isDark ? 'text-slate-300 hover:text-white' : 'text-gray-700 hover:text-gray-900'}`}
|
||||
>
|
||||
进行对话
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={`mt-6 rounded-2xl border px-6 py-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4 ${isDark ? 'bg-violet-950/30 border-violet-800/50' : 'bg-violet-50 border-violet-100'}`}>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">不确定从哪个开始?</h3>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>直接开始对话,AI 会自动匹配最合适的专家助手</p>
|
||||
</div>
|
||||
|
||||
<button className="inline-flex items-center justify-center gap-2 h-11 px-6 rounded-xl bg-gradient-to-r from-fuchsia-500 to-violet-600 text-white font-medium hover:opacity-95 transition">
|
||||
开始对话
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<Dialog
|
||||
visible={detailVisible}
|
||||
onClose={() => setDetailVisible(false)}
|
||||
confirmBtn={null}
|
||||
cancelBtn={null}
|
||||
header="专家详情"
|
||||
width={520}
|
||||
>
|
||||
<div className="py-2">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{selectedAgent?.avatar_url ? (
|
||||
<img
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(selectedAgent.avatar_url)}`}
|
||||
alt={selectedAgent?.name || '专家头像'}
|
||||
className="w-20 h-20 rounded-2xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-2xl bg-gray-100" />
|
||||
)}
|
||||
|
||||
<h4 className="mt-4 text-2xl font-semibold text-gray-900">{selectedAgent?.name || '未命名专家'}</h4>
|
||||
<p className="mt-2 text-sm text-gray-400">{selectedAgent?.description || '暂无描述'}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-xl bg-gray-50 px-3 py-3 flex items-center justify-between">
|
||||
<div className="inline-flex items-center gap-2 text-sm text-gray-500">
|
||||
{selectedAgent?.is_draft ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{selectedAgent?.is_draft ? '草稿状态' : '已发布'}
|
||||
</div>
|
||||
<Switch
|
||||
value={!selectedAgent?.is_draft}
|
||||
loading={isStatusUpdating}
|
||||
disabled={isStatusUpdating}
|
||||
onChange={(value) => {
|
||||
void handleStatusToggle(value as boolean);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="mt-4 w-full h-10 rounded-lg bg-gradient-to-r from-fuchsia-500 to-violet-600 text-white inline-flex items-center justify-center gap-2">
|
||||
<MessageCircleMore className="w-4 h-4" />
|
||||
开始聊天
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,130 @@
|
||||
import type { KeyboardEvent, ReactNode } from 'react';
|
||||
import { Send, Square, Upload } from 'lucide-react';
|
||||
import { Textarea } from 'tdesign-react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
interface ChatInputComposerProps {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
onSend?: () => void;
|
||||
onStop?: () => void;
|
||||
onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
isSending?: boolean;
|
||||
isDarkMode: boolean;
|
||||
trailingActions?: ReactNode;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
showBorderTop?: boolean;
|
||||
showUpload?: boolean;
|
||||
onUploadFile?: (file: File) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function ChatInputComposer({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
onStop,
|
||||
onKeyDown,
|
||||
placeholder = '输入你的问题...',
|
||||
disabled = false,
|
||||
isSending = false,
|
||||
isDarkMode,
|
||||
trailingActions,
|
||||
readOnly = false,
|
||||
className = '',
|
||||
showBorderTop = false,
|
||||
showUpload = true,
|
||||
onUploadFile,
|
||||
}: ChatInputComposerProps) {
|
||||
const submitEnabled = Boolean(value.trim()) && !disabled && !readOnly && Boolean(onSend);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const iconButtonClass = `rounded-lg p-2 transition-colors ${
|
||||
isDarkMode ? 'text-slate-400 hover:bg-slate-600 hover:text-slate-200' : 'text-gray-500 hover:bg-gray-200 hover:text-gray-700'
|
||||
}`;
|
||||
|
||||
const handleKeyDown = (val, context) => {
|
||||
if (context?.e?.key === 'Enter') {
|
||||
// 阻止默认的换行行为(如果是 Textarea)
|
||||
context.e.preventDefault();
|
||||
|
||||
// 模拟发送请求
|
||||
onSend()
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${showBorderTop ? (isDarkMode ? 'border-t border-slate-700/50' : 'border-t border-red-100') : ''} p-4 ${className}`}>
|
||||
<div className={`flex items-end gap-3 rounded-2xl p-3 shadow-inner ${isDarkMode ? 'bg-slate-700/50' : 'bg-gray-100'}`}>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(nextValue) => onChange?.(String(nextValue))}
|
||||
onKeydown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
autosize={{ minRows: 1, maxRows: 8 }}
|
||||
disabled={disabled}
|
||||
readonly={readOnly}
|
||||
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]:!min-h-[40px] [&_.t-textarea__inner]:!resize-none [&_.t-textarea__inner]:!bg-transparent [&_.t-textarea__inner]:!border-0 [&_.t-textarea__inner]:!p-0 [&_.t-textarea__inner]:!shadow-none [&_.t-textarea__inner]:!outline-none [&_.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 ${
|
||||
isDarkMode
|
||||
? '[&_.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 && (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
void onUploadFile?.(file);
|
||||
}
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled || !onUploadFile}
|
||||
className={`${iconButtonClass} ${(disabled || !onUploadFile) ? 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-inherit' : ''}`}
|
||||
title="上传文件"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{trailingActions && <div className="flex items-center gap-2">{trailingActions}</div>}
|
||||
|
||||
<button
|
||||
onClick={isSending ? onStop : onSend}
|
||||
disabled={!isSending && !submitEnabled}
|
||||
className={`flex items-center gap-2 rounded-xl px-4 py-2 font-medium shadow-sm transition-all ${
|
||||
isSending
|
||||
? isDarkMode
|
||||
? 'bg-rose-600 text-white'
|
||||
: 'bg-red-600 text-white'
|
||||
: submitEnabled
|
||||
? isDarkMode
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-red-500 text-white hover:bg-red-600'
|
||||
: isDarkMode
|
||||
? 'cursor-not-allowed bg-slate-600 text-slate-400'
|
||||
: 'cursor-not-allowed bg-gray-300 text-gray-500'
|
||||
}`}
|
||||
type="button"
|
||||
title={isSending ? '停止' : '发送'}
|
||||
>
|
||||
{isSending ? <Square className="h-4 w-4 fill-current" /> : <Send className="h-4 w-4" />}
|
||||
{isSending ? '停止' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
FileCode2,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
interface FileSkill {
|
||||
id: string;
|
||||
folder: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kind: 'md' | 'py';
|
||||
filename: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
const API_BASE = 'https://ch1.b.uat.4.cn/zncmback/api/skills/files';
|
||||
|
||||
export default function FileSkillsWorkshop() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const [skills, setSkills] = useState<FileSkill[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FileSkill | null>(null);
|
||||
const [openCardMenuId, setOpenCardMenuId] = useState<string | null>(null);
|
||||
|
||||
const cardMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const reload = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(API_BASE);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
const data = (await res.json()) as FileSkill[];
|
||||
setSkills(data);
|
||||
} catch (err) {
|
||||
console.error('加载技能失败', err);
|
||||
setError('加载技能失败,请稍后重试');
|
||||
setSkills([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (cardMenuRef.current && !cardMenuRef.current.contains(target)) {
|
||||
setOpenCardMenuId(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const keyword = searchTerm.trim().toLowerCase();
|
||||
if (!keyword) return skills;
|
||||
return skills.filter((s) =>
|
||||
`${s.name} ${s.description} ${s.filename}`.toLowerCase().includes(keyword),
|
||||
);
|
||||
}, [searchTerm, skills]);
|
||||
|
||||
const handleDelete = async (skill: FileSkill) => {
|
||||
setOpenCardMenuId(null);
|
||||
if (!window.confirm(`确认删除技能「${skill.name}」?`)) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/${encodeURIComponent(skill.folder)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
setSkills((prev) => prev.filter((s) => s.folder !== skill.folder));
|
||||
} catch (err) {
|
||||
console.error('删除技能失败', err);
|
||||
setError('删除技能失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full min-h-[calc(100vh-140px)] ${
|
||||
isDark ? 'bg-slate-950 text-slate-100' : 'bg-[#fcfcfd] text-[#101828]'
|
||||
}`}
|
||||
>
|
||||
{createOpen && (
|
||||
<CreateSkillModal
|
||||
isDark={isDark}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => {
|
||||
setCreateOpen(false);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditSkillModal
|
||||
isDark={isDark}
|
||||
skill={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mx-auto max-w-[1360px] px-6 py-8">
|
||||
<section className="mb-7 flex items-start justify-between gap-5">
|
||||
<div>
|
||||
<h1
|
||||
className={`text-[22px] font-bold tracking-tight ${
|
||||
isDark ? 'text-slate-100' : 'text-[#111827]'
|
||||
}`}
|
||||
>
|
||||
技能管理
|
||||
</h1>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>
|
||||
上传 Markdown 提示词或 Python 脚本作为技能,供 MCP 与模型调用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="inline-flex h-11 items-center gap-2 rounded-xl bg-[#ea2d2d] px-5 text-sm font-medium text-white shadow-[0_10px_20px_-12px_rgba(234,45,45,0.7)] transition hover:bg-[#d92626]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建技能
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`mb-8 rounded-[28px] border px-6 py-5 ${
|
||||
isDark ? 'border-slate-800 bg-slate-900/65' : 'border-[#f5d4d4] bg-white'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-2xl border px-4 py-3 ${
|
||||
isDark ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'
|
||||
}`}
|
||||
>
|
||||
<Search className={`h-4 w-4 ${isDark ? 'text-slate-400' : 'text-[#ff6b6b]'}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索技能名称 / 描述..."
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
className={`min-w-0 flex-1 bg-transparent text-sm outline-none ${
|
||||
isDark
|
||||
? 'text-slate-100 placeholder:text-slate-500'
|
||||
: 'text-gray-800 placeholder:text-[#9ca3af]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{loading ? (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-[28px] border py-20 text-sm ${
|
||||
isDark
|
||||
? 'border-slate-800 bg-slate-900/60 text-slate-300'
|
||||
: 'border-[#f5d4d4] bg-white text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
正在加载技能...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${
|
||||
isDark ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'
|
||||
}`}
|
||||
>
|
||||
<h3
|
||||
className={`mb-2 text-lg font-semibold ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
加载失败
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>{error}</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${
|
||||
isDark ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mb-4 flex h-12 w-12 items-center justify-center rounded-2xl ${
|
||||
isDark ? 'bg-slate-800 text-slate-400' : 'bg-[#fff1f1] text-[#ff6b6b]'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-6 w-6" />
|
||||
</div>
|
||||
<h3
|
||||
className={`mb-2 text-lg font-semibold ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
暂无技能
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>
|
||||
点击右上角「新建技能」上传你的第一个技能。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filtered.map((skill) => {
|
||||
const isPy = skill.kind === 'py';
|
||||
const Icon = isPy ? FileCode2 : FileText;
|
||||
return (
|
||||
<article
|
||||
key={skill.folder}
|
||||
className={`group relative overflow-hidden rounded-[22px] border px-5 py-5 transition-all duration-200 ${
|
||||
isDark
|
||||
? 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:shadow-xl hover:shadow-black/20'
|
||||
: 'border-[#f5d4d4] bg-white hover:border-[#ffbdbd] hover:shadow-[0_16px_40px_-26px_rgba(239,68,68,0.35)]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-x-0 top-0 h-1 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
|
||||
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute right-4 top-4 z-20"
|
||||
ref={openCardMenuId === skill.folder ? cardMenuRef : null}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpenCardMenuId((prev) =>
|
||||
prev === skill.folder ? null : skill.folder,
|
||||
);
|
||||
}}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{openCardMenuId === skill.folder && (
|
||||
<div
|
||||
className={`absolute right-0 top-[calc(100%+6px)] z-30 w-36 rounded-2xl border py-1 shadow-xl ${
|
||||
isDark ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpenCardMenuId(null);
|
||||
setEditing(skill);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-slate-300 hover:bg-slate-800'
|
||||
: 'text-gray-700 hover:bg-[#fff6f6]'
|
||||
}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4 shrink-0" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(skill);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-rose-400 hover:bg-slate-800'
|
||||
: 'text-rose-500 hover:bg-[#fff6f6]'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 pr-8">
|
||||
<div
|
||||
className={`relative flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br shadow-[0_18px_32px_-22px_rgba(15,23,42,0.35)] ring-1 ${
|
||||
isPy
|
||||
? 'from-indigo-100 via-blue-50 to-white text-indigo-500 shadow-indigo-100/80 ring-indigo-100'
|
||||
: 'from-rose-100 via-red-50 to-white text-red-500 shadow-red-100/80 ring-red-100'
|
||||
}`}
|
||||
>
|
||||
<Icon className="relative z-10 h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className={`truncate text-[17px] font-semibold ${
|
||||
isDark ? 'text-slate-100' : 'text-[#111827]'
|
||||
}`}
|
||||
title={skill.name}
|
||||
>
|
||||
{skill.name}
|
||||
</h3>
|
||||
<p
|
||||
className={`mt-1 line-clamp-3 min-h-[3.6em] text-sm ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
{skill.description || '暂无描述'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mt-4 flex items-center justify-between text-xs ${
|
||||
isDark ? 'text-slate-500' : 'text-[#98a2b3]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium ${
|
||||
isPy
|
||||
? 'bg-indigo-50 text-indigo-600'
|
||||
: 'bg-[#fff2f2] text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{isPy ? 'Python' : 'Markdown'}
|
||||
</span>
|
||||
<span>{new Date(skill.updated_at * 1000).toLocaleString()}</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CreateModalProps {
|
||||
isDark: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
function CreateSkillModal({ isDark, onClose, onCreated }: CreateModalProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleFileChange = (next: File | null) => {
|
||||
setFile(next);
|
||||
if (next && !name.trim()) {
|
||||
const stem = next.name.replace(/\.[^.]+$/, '');
|
||||
setName(stem);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!file) {
|
||||
setErrMsg('请上传 .md 或 .py 文件');
|
||||
return;
|
||||
}
|
||||
setErrMsg('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
if (name.trim()) fd.append('name', name.trim());
|
||||
if (description.trim()) fd.append('description', description.trim());
|
||||
const res = await fetch(API_BASE, { method: 'POST', body: fd });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
console.error('新建技能失败', err);
|
||||
setErrMsg(err instanceof Error ? err.message : '新建技能失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`relative w-[480px] overflow-hidden rounded-[28px] border shadow-2xl ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-100'
|
||||
: 'border-[#f3d6d6] bg-white text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-1 w-full ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
|
||||
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between px-6 pb-3 pt-5">
|
||||
<h2 className="text-base font-semibold">新建技能</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 pb-2 pt-2">
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
技能文件(.md 或 .py)
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".md,.py"
|
||||
className="hidden"
|
||||
onChange={(event) => handleFileChange(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 hover:bg-slate-800'
|
||||
: 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow ring-1 ring-white/20">
|
||||
<Upload className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`truncate text-sm font-medium ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{file ? file.name : '选择文件'}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
支持 .md 提示词 或 .py 可执行脚本
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
技能名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="默认使用上传文件名"
|
||||
className={`w-full rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
描述(可选)
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="技能用途、输入输出说明等"
|
||||
className={`w-full resize-none rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errMsg && (
|
||||
<p className="text-xs text-rose-500">{errMsg}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-5 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`rounded-xl px-4 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
: 'text-[#667085] hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
className={`rounded-xl bg-[#ea2d2d] px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-[#d92626] ${
|
||||
submitting ? 'cursor-not-allowed opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
{submitting ? '提交中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EditModalProps {
|
||||
isDark: boolean;
|
||||
skill: FileSkill;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function EditSkillModal({ isDark, skill, onClose, onSaved }: EditModalProps) {
|
||||
const [name, setName] = useState(skill.name);
|
||||
const [description, setDescription] = useState(skill.description ?? '');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
setErrMsg('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
if (file) fd.append('file', file);
|
||||
if (name.trim() !== skill.name) fd.append('name', name.trim());
|
||||
if (description !== (skill.description ?? '')) fd.append('description', description);
|
||||
|
||||
const res = await fetch(`${API_BASE}/${encodeURIComponent(skill.folder)}`, {
|
||||
method: 'PUT',
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
console.error('保存技能失败', err);
|
||||
setErrMsg(err instanceof Error ? err.message : '保存技能失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`relative w-[480px] overflow-hidden rounded-[28px] border shadow-2xl ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-100'
|
||||
: 'border-[#f3d6d6] bg-white text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-1 w-full ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
|
||||
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between px-6 pb-3 pt-5">
|
||||
<h2 className="text-base font-semibold">编辑技能</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 pb-2 pt-2">
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
技能名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className={`w-full rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
className={`w-full resize-none rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
覆盖文件(可选,选择后将替换原有内容)
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".md,.py"
|
||||
className="hidden"
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 hover:bg-slate-800'
|
||||
: 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow ring-1 ring-white/20">
|
||||
<Upload className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`truncate text-sm font-medium ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{file ? file.name : `当前文件:${skill.filename}`}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
仅支持 .md 或 .py
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errMsg && <p className="text-xs text-rose-500">{errMsg}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-5 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`rounded-xl px-4 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
: 'text-[#667085] hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
className={`rounded-xl bg-[#ea2d2d] px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-[#d92626] ${
|
||||
submitting ? 'cursor-not-allowed opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bot, CheckCircle2, ChevronDown, Info, Sparkles, UserRound, Wrench } from 'lucide-react';
|
||||
import MarkdownRenderer from '../MarkdownRenderer';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
interface ConversationStep {
|
||||
text: string;
|
||||
subText?: string;
|
||||
status: 'done' | 'processing';
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
interface ConversationMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
steps?: ConversationStep[];
|
||||
thinkings?: Array<{ duration: number; content: string }>;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
interface SkillCreatorConversationItemProps {
|
||||
message: ConversationMessage;
|
||||
isConnecting?: boolean;
|
||||
}
|
||||
|
||||
function MessageAvatar({ role, active = false }: { role: 'user' | 'assistant'; active?: boolean }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = 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 shadow-sm ${
|
||||
role === 'assistant'
|
||||
? isDarkMode
|
||||
? 'bg-gradient-to-br from-slate-700 to-slate-900 text-sky-200 ring-1 ring-slate-600'
|
||||
: 'bg-gradient-to-br from-red-50 to-rose-100 text-red-500 ring-1 ring-red-100'
|
||||
: isDarkMode
|
||||
? 'bg-gradient-to-br from-blue-500 to-indigo-600 text-white'
|
||||
: 'bg-gradient-to-br from-red-500 to-rose-500 text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{role === 'assistant' && active && (
|
||||
<Sparkles className={`absolute -right-0.5 -top-0.5 w-3.5 h-3.5 ${isDarkMode ? 'text-sky-200' : 'text-amber-400'}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepsSummary({ steps }: { steps: ConversationStep[] }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const [open, setOpen] = useState(true);
|
||||
const doneCount = steps.filter((step) => step.status === 'done').length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mb-3 overflow-hidden rounded-2xl border shadow-sm ${
|
||||
isDarkMode ? 'border-slate-700 bg-slate-900/60 text-slate-300' : 'border-red-100 bg-white text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
|
||||
isDarkMode ? 'bg-slate-800/70 hover:bg-slate-800' : 'bg-red-50/60 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className={`w-3.5 h-3.5 ${isDarkMode ? 'text-emerald-400' : 'text-red-500'}`} />
|
||||
<span className="font-medium">执行步骤</span>
|
||||
<span className={isDarkMode ? 'text-slate-500' : 'text-gray-400'}>已完成 {doneCount} / {steps.length}</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'max-h-[640px] opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className={`border-t px-4 py-3 space-y-3 ${isDarkMode ? 'border-slate-700' : 'border-red-50'}`}>
|
||||
{steps.map((step, index) => {
|
||||
const done = step.status === 'done';
|
||||
|
||||
return (
|
||||
<div key={`${step.text}-${index}`} className="flex gap-3">
|
||||
<div className="pt-0.5">
|
||||
{done ? (
|
||||
<CheckCircle2 className={`w-4 h-4 ${isDarkMode ? 'text-emerald-400' : 'text-red-500'}`} />
|
||||
) : (
|
||||
<span className={`block w-4 h-4 rounded-full border-2 ${isDarkMode ? 'border-sky-300 border-t-transparent' : 'border-red-400 border-t-transparent'} animate-spin`} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-xs font-medium ${isDarkMode ? 'text-slate-200' : 'text-gray-800'}`}>
|
||||
第 {index + 1} 步:{step.text}
|
||||
</div>
|
||||
{step.subText && (
|
||||
<div className={`mt-1 rounded-xl px-3 py-2 text-xs leading-5 ${isDarkMode ? 'bg-slate-950/50 text-slate-400' : 'bg-gray-50 text-gray-600'}`}>
|
||||
{step.subText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingPanel({ duration, content }: { duration: number; content: string }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (duration > 0) setOpen(false);
|
||||
}, [duration]);
|
||||
|
||||
return (
|
||||
<div className={`mb-3 rounded-2xl border overflow-hidden shadow-sm ${isDarkMode ? 'border-slate-700 bg-slate-900/60' : 'border-red-100 bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
|
||||
isDarkMode ? 'text-slate-300 bg-slate-800/70 hover:bg-slate-800' : 'text-gray-600 bg-red-50/60 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<Info className={`w-3.5 h-3.5 ${isDarkMode ? 'text-sky-300' : 'text-red-500'}`} />
|
||||
<span className="font-medium">思考过程</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className={`px-4 py-3 text-xs leading-6 whitespace-pre-wrap border-t ${isDarkMode ? 'border-slate-700 text-slate-300' : 'border-red-50 text-gray-600'}`}>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallPanel({ toolCall }: { toolCall: ToolCall }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const details = {
|
||||
input: toolCall.input && Object.keys(toolCall.input).length > 0 ? JSON.stringify(toolCall.input, null, 2) : '',
|
||||
output: toolCall.output ?? '',
|
||||
};
|
||||
const hasDetails = Boolean(details.input || details.output);
|
||||
|
||||
return (
|
||||
<div className={`mb-3 rounded-2xl border overflow-hidden shadow-sm ${isDarkMode ? 'border-slate-700 bg-slate-900/60' : 'border-red-100 bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
|
||||
isDarkMode ? 'text-slate-300 bg-slate-800/70 hover:bg-slate-800' : 'text-gray-600 bg-red-50/60 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<Wrench className={`w-3.5 h-3.5 ${isDarkMode ? 'text-amber-300' : 'text-red-500'}`} />
|
||||
<span className="font-medium">{toolCall.name}</span>
|
||||
{hasDetails && <ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />}
|
||||
</button>
|
||||
{hasDetails && (
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'max-h-[520px] opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className={`border-t px-4 py-3 space-y-3 ${isDarkMode ? 'border-slate-700' : 'border-red-50'}`}>
|
||||
{details.input && (
|
||||
<div>
|
||||
<p className={`text-[11px] mb-1 ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>请求参数</p>
|
||||
<pre className={`rounded-xl px-3 py-2 text-[11px] leading-5 overflow-x-auto ${isDarkMode ? 'bg-slate-950/60 text-slate-300' : 'bg-gray-50 text-gray-700'}`}>{details.input}</pre>
|
||||
</div>
|
||||
)}
|
||||
{details.output && (
|
||||
<div>
|
||||
<p className={`text-[11px] mb-1 ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>返回结果</p>
|
||||
<pre className={`rounded-xl px-3 py-2 text-[11px] leading-5 overflow-x-auto ${isDarkMode ? 'bg-slate-950/60 text-slate-300' : 'bg-gray-50 text-gray-700'}`}>{details.output}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SkillCreatorConversationItem({ message, isConnecting = false }: SkillCreatorConversationItemProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end gap-3">
|
||||
<div className={`max-w-[72%] rounded-2xl rounded-tr-md px-4 py-3 text-sm leading-relaxed shadow-sm ${
|
||||
isDarkMode ? 'bg-blue-600/80 text-white shadow-blue-950/20' : 'bg-gradient-to-r from-red-500 to-rose-500 text-white shadow-red-200/70'
|
||||
}`}>
|
||||
{message.content}
|
||||
</div>
|
||||
<MessageAvatar role="user" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<MessageAvatar role="assistant" active={!message.content} />
|
||||
<div
|
||||
className={`max-w-[86%] flex-1 rounded-3xl border px-4 py-3.5 text-sm shadow-sm ${
|
||||
isDarkMode ? 'border-slate-700 bg-slate-800/45 text-slate-200 shadow-black/10' : 'border-red-100 bg-white text-[#2a2f3f] shadow-red-100/60'
|
||||
}`}
|
||||
>
|
||||
{message.steps && message.steps.length > 0 && <StepsSummary steps={message.steps} />}
|
||||
{message.thinkings?.map((thinking, index) => (
|
||||
<ThinkingPanel key={`${message.id}-thinking-${index}`} duration={thinking.duration} content={thinking.content} />
|
||||
))}
|
||||
{message.toolCalls?.map((toolCall) => (
|
||||
<ToolCallPanel key={toolCall.id} toolCall={toolCall} />
|
||||
))}
|
||||
{isConnecting && (
|
||||
<div className={`flex items-center gap-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<span className="skill-creator-dot" />
|
||||
<span className="skill-creator-dot" />
|
||||
<span className="skill-creator-dot" />
|
||||
<span
|
||||
data-text="正在连接技能服务..."
|
||||
className={`ml-1 skill-creator-shimmer-text ${isDarkMode ? 'skill-creator-shimmer-dark' : 'skill-creator-shimmer-light'}`}
|
||||
>
|
||||
正在连接技能服务...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{message.content && (
|
||||
<MarkdownRenderer
|
||||
content={message.content}
|
||||
className={isDarkMode ? '[&_p]:text-slate-200 [&_li]:text-slate-200' : '[&_p]:text-[#2a2f3f] [&_li]:text-[#2a2f3f]'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,337 @@
|
||||
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
|
||||
import { Archive, Loader2, MessageSquareText, Plus, RefreshCw, Trash2, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export interface SkillCreatorThread {
|
||||
id: number;
|
||||
title: string;
|
||||
archived: boolean;
|
||||
expert_agent_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ThreadListResponse {
|
||||
threads?: SkillCreatorThread[];
|
||||
archived_threads?: SkillCreatorThread[];
|
||||
}
|
||||
|
||||
interface SkillCreatorThreadSidebarProps {
|
||||
authedFetch: (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => Promise<Response>;
|
||||
currentThreadId: string;
|
||||
isDarkMode: boolean;
|
||||
isOpen: boolean;
|
||||
isBusy?: boolean;
|
||||
createThreadPayload?: Record<string, unknown>;
|
||||
createButtonText?: string;
|
||||
emptyText?: string;
|
||||
subtitle?: string;
|
||||
threadFilter?: (thread: SkillCreatorThread) => boolean;
|
||||
onClose: () => void;
|
||||
onThreadSelect: (threadId: string) => void;
|
||||
}
|
||||
|
||||
const THREADS_API_URL = `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads`;
|
||||
|
||||
const sortThreads = (threads: SkillCreatorThread[]) =>
|
||||
[...threads].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
||||
|
||||
export default function SkillCreatorThreadSidebar({
|
||||
authedFetch,
|
||||
currentThreadId,
|
||||
isDarkMode,
|
||||
isOpen,
|
||||
isBusy = false,
|
||||
createThreadPayload,
|
||||
createButtonText = '新建聊天',
|
||||
emptyText = '暂无记录',
|
||||
subtitle = '切换或管理当前会话',
|
||||
threadFilter,
|
||||
onClose,
|
||||
onThreadSelect,
|
||||
}: SkillCreatorThreadSidebarProps) {
|
||||
const [threads, setThreads] = useState<SkillCreatorThread[]>([]);
|
||||
const [archivedThreads, setArchivedThreads] = useState<SkillCreatorThread[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [deletingThreadId, setDeletingThreadId] = useState<number | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const applyFilter = useCallback(
|
||||
(items: SkillCreatorThread[]) => (threadFilter ? items.filter(threadFilter) : items),
|
||||
[threadFilter],
|
||||
);
|
||||
|
||||
const refreshThreads = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await authedFetch(THREADS_API_URL, { method: 'GET' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取会话列表失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ThreadListResponse;
|
||||
const nextThreads = Array.isArray(data.threads) ? data.threads : [];
|
||||
const nextArchivedThreads = Array.isArray(data.archived_threads) ? data.archived_threads : [];
|
||||
|
||||
setThreads(sortThreads(applyFilter(nextThreads)));
|
||||
setArchivedThreads(sortThreads(applyFilter(nextArchivedThreads)));
|
||||
} catch (fetchError) {
|
||||
console.error('获取会话列表失败', fetchError);
|
||||
setError('会话列表加载失败,请稍后重试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [applyFilter, authedFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
void refreshThreads();
|
||||
}, [isOpen, refreshThreads]);
|
||||
|
||||
const allThreads = useMemo(() => [...threads, ...archivedThreads], [archivedThreads, threads]);
|
||||
|
||||
const handleCreateThread = async () => {
|
||||
if (isBusy || isCreating) return;
|
||||
|
||||
setIsCreating(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await authedFetch(THREADS_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: '新建技能',
|
||||
archived: false,
|
||||
is_real_chat: true,
|
||||
...createThreadPayload,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`创建会话失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const newThread = (await response.json()) as SkillCreatorThread;
|
||||
if (!threadFilter || threadFilter(newThread)) {
|
||||
setThreads((prev) => sortThreads([newThread, ...prev]));
|
||||
}
|
||||
onThreadSelect(String(newThread.id));
|
||||
} catch (createError) {
|
||||
console.error('创建会话失败', createError);
|
||||
setError('新建会话失败,请稍后重试');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteThread = async (thread: SkillCreatorThread) => {
|
||||
if (isBusy || deletingThreadId === thread.id) return;
|
||||
|
||||
const confirmed = window.confirm(`确认删除会话“${thread.title || `会话 ${thread.id}`}”吗?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
setDeletingThreadId(thread.id);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await authedFetch(`${THREADS_API_URL}/${thread.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`删除会话失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const nextThreads = threads.filter((item) => item.id !== thread.id);
|
||||
const nextArchivedThreads = archivedThreads.filter((item) => item.id !== thread.id);
|
||||
setThreads(nextThreads);
|
||||
setArchivedThreads(nextArchivedThreads);
|
||||
|
||||
if (currentThreadId === String(thread.id)) {
|
||||
const fallbackThread = [...nextThreads, ...nextArchivedThreads][0];
|
||||
onThreadSelect(fallbackThread ? String(fallbackThread.id) : '');
|
||||
}
|
||||
} catch (deleteError) {
|
||||
console.error('删除会话失败', deleteError);
|
||||
setError('删除会话失败,请稍后重试');
|
||||
} finally {
|
||||
setDeletingThreadId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const renderThreadSection = (title: string, items: SkillCreatorThread[], archived = false) => (
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{archived ? <Archive className="h-3.5 w-3.5" /> : <MessageSquareText className="h-3.5 w-3.5" />}
|
||||
<span className="text-[11px] font-semibold tracking-[0.14em]">{title}</span>
|
||||
</div>
|
||||
<span className={`text-[11px] ${isDarkMode ? 'text-slate-500' : 'text-gray-400'}`}>{items.length}</span>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
className={`rounded-xl border border-dashed px-3 py-4 text-center text-xs ${
|
||||
isDarkMode ? 'border-slate-700 text-slate-500' : 'border-gray-200 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{items.map((thread) => {
|
||||
const isActive = currentThreadId === String(thread.id);
|
||||
const isDeleting = deletingThreadId === thread.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={thread.id}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => onThreadSelect(String(thread.id))}
|
||||
className={`group flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 ${
|
||||
isActive
|
||||
? isDarkMode
|
||||
? 'border-slate-600 bg-slate-800/90 text-slate-50 shadow-sm'
|
||||
: 'border-red-200 bg-red-50/80 text-gray-900 shadow-sm'
|
||||
: isDarkMode
|
||||
? 'border-transparent text-slate-300 hover:border-slate-700 hover:bg-slate-800/60 hover:text-slate-100'
|
||||
: 'border-transparent text-gray-700 hover:border-gray-200 hover:bg-gray-50 hover:text-gray-900'
|
||||
} ${isBusy ? 'cursor-not-allowed opacity-70' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
|
||||
isActive
|
||||
? isDarkMode
|
||||
? 'bg-slate-700 text-slate-200'
|
||||
: 'bg-white text-red-500 ring-1 ring-red-100'
|
||||
: isDarkMode
|
||||
? 'bg-slate-800 text-slate-400'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{archived ? <Archive className="h-4 w-4" /> : <MessageSquareText className="h-4 w-4" />}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{thread.title || `会话 ${thread.id}`}</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDeleteThread(thread);
|
||||
}}
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
|
||||
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
|
||||
} ${
|
||||
isDarkMode
|
||||
? 'text-slate-400 hover:bg-rose-500/15 hover:text-rose-300'
|
||||
: 'text-gray-400 hover:bg-rose-50 hover:text-rose-500'
|
||||
}`}
|
||||
role="button"
|
||||
aria-label={`删除会话 ${thread.title || thread.id}`}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleDeleteThread(thread);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`flex h-full shrink-0 flex-col overflow-hidden border-r transition-[width,opacity,transform,border-color] duration-300 ease-out ${
|
||||
isOpen ? 'w-[286px] translate-x-0 opacity-100' : 'w-0 -translate-x-3 opacity-0 pointer-events-none'
|
||||
} ${
|
||||
isDarkMode ? 'border-slate-700 bg-slate-950/90 text-slate-100' : 'border-gray-200 bg-[#fafafa] text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div className={`border-b px-4 py-4 ${isDarkMode ? 'border-slate-800 bg-slate-900/70' : 'border-gray-200 bg-white'}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">聊天记录</h3>
|
||||
<p className={`mt-1 text-xs ${isDarkMode ? 'text-slate-500' : 'text-gray-500'}`}>{subtitle}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
|
||||
isDarkMode ? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
title="关闭聊天记录"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || isCreating}
|
||||
onClick={() => void handleCreateThread()}
|
||||
className={`inline-flex h-9 flex-1 items-center justify-center gap-2 rounded-lg border px-3 text-sm font-medium transition ${
|
||||
isDarkMode
|
||||
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-red-200 hover:bg-red-50 hover:text-red-600'
|
||||
} disabled:cursor-not-allowed disabled:opacity-70`}
|
||||
>
|
||||
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||
{createButtonText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={() => void refreshThreads()}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-lg border transition ${
|
||||
isDarkMode
|
||||
? 'border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'
|
||||
: 'border-gray-200 bg-white text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
} ${isLoading ? 'cursor-not-allowed opacity-70' : ''}`}
|
||||
title="刷新会话列表"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={`mt-3 rounded-lg px-3 py-2 text-xs ${isDarkMode ? 'bg-rose-500/10 text-rose-300' : 'bg-rose-50 text-rose-500'}`}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
|
||||
{isLoading && allThreads.length === 0 ? (
|
||||
<div className={`flex h-full items-center justify-center gap-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在加载聊天记录...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{renderThreadSection('进行中', threads)}
|
||||
{renderThreadSection('已归档', archivedThreads, true)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
Boxes,
|
||||
Braces,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Compass,
|
||||
Crown,
|
||||
Edit,
|
||||
FileCode2,
|
||||
Globe,
|
||||
LineChart,
|
||||
MoreVertical,
|
||||
PenTool,
|
||||
Pencil,
|
||||
PencilIcon,
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Target,
|
||||
Trash2,
|
||||
Upload,
|
||||
Wand2,
|
||||
Workflow,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useExpertAuthBootstrap } from '../../hooks';
|
||||
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
|
||||
import { MessagePlugin } from 'tdesign-react';
|
||||
|
||||
interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
highlight: string;
|
||||
relatedCount: number;
|
||||
version: string;
|
||||
inputCount: number;
|
||||
outputCount: number;
|
||||
isNew?: boolean;
|
||||
isMine: boolean;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface ApiSkill {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface SkillsResponse {
|
||||
items: ApiSkill[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
interface CreateSkillThreadResponse {
|
||||
id: number;
|
||||
title: string;
|
||||
archived: boolean;
|
||||
expert_agent_id: number | null;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
type CategoryKey = 'all' | 'writing' | 'analysis' | 'decision' | 'validation' | 'workflow';
|
||||
|
||||
const CATEGORY_LABELS: Record<CategoryKey, string> = {
|
||||
all: '全部类型',
|
||||
writing: '编辑类',
|
||||
analysis: '分析类',
|
||||
decision: '决策类',
|
||||
validation: '校验类',
|
||||
workflow: '流程类',
|
||||
};
|
||||
|
||||
const categoryOrder: CategoryKey[] = ['all', 'writing', 'analysis', 'decision', 'validation', 'workflow'];
|
||||
|
||||
const AVATAR_STYLES = [
|
||||
{
|
||||
Icon: Bot,
|
||||
wrapper: 'from-rose-100 via-red-50 to-white text-red-500',
|
||||
glow: 'shadow-red-100/80',
|
||||
ring: 'ring-red-100',
|
||||
orb: 'bg-red-500/10',
|
||||
},
|
||||
{
|
||||
Icon: Wand2,
|
||||
wrapper: 'from-violet-100 via-fuchsia-50 to-white text-violet-500',
|
||||
glow: 'shadow-violet-100/80',
|
||||
ring: 'ring-violet-100',
|
||||
orb: 'bg-violet-500/10',
|
||||
},
|
||||
{
|
||||
Icon: LineChart,
|
||||
wrapper: 'from-cyan-100 via-sky-50 to-white text-sky-500',
|
||||
glow: 'shadow-sky-100/80',
|
||||
ring: 'ring-sky-100',
|
||||
orb: 'bg-sky-500/10',
|
||||
},
|
||||
{
|
||||
Icon: ShieldCheck,
|
||||
wrapper: 'from-emerald-100 via-green-50 to-white text-emerald-500',
|
||||
glow: 'shadow-emerald-100/80',
|
||||
ring: 'ring-emerald-100',
|
||||
orb: 'bg-emerald-500/10',
|
||||
},
|
||||
{
|
||||
Icon: Workflow,
|
||||
wrapper: 'from-amber-100 via-yellow-50 to-white text-amber-500',
|
||||
glow: 'shadow-amber-100/80',
|
||||
ring: 'ring-amber-100',
|
||||
orb: 'bg-amber-500/10',
|
||||
},
|
||||
{
|
||||
Icon: FileCode2,
|
||||
wrapper: 'from-indigo-100 via-blue-50 to-white text-indigo-500',
|
||||
glow: 'shadow-indigo-100/80',
|
||||
ring: 'ring-indigo-100',
|
||||
orb: 'bg-indigo-500/10',
|
||||
},
|
||||
{
|
||||
Icon: Target,
|
||||
wrapper: 'from-pink-100 via-rose-50 to-white text-pink-500',
|
||||
glow: 'shadow-pink-100/80',
|
||||
ring: 'ring-pink-100',
|
||||
orb: 'bg-pink-500/10',
|
||||
},
|
||||
{
|
||||
Icon: Compass,
|
||||
wrapper: 'from-orange-100 via-amber-50 to-white text-orange-500',
|
||||
glow: 'shadow-orange-100/80',
|
||||
ring: 'ring-orange-100',
|
||||
orb: 'bg-orange-500/10',
|
||||
},
|
||||
];
|
||||
|
||||
const hashString = (value: string) =>
|
||||
value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
|
||||
const inferCategory = (skill: ApiSkill): CategoryKey => {
|
||||
const text = `${skill.name} ${skill.description}`.toLowerCase();
|
||||
|
||||
if (/(summary|readme|writer|copy|frontend|narrative|edit|document)/.test(text)) return 'writing';
|
||||
if (/(analysis|audience|risk|search|insight|research|data)/.test(text)) return 'analysis';
|
||||
if (/(plan|decision|goal|strategy|logic|outreach)/.test(text)) return 'decision';
|
||||
if (/(check|validate|review|guard|test|lint)/.test(text)) return 'validation';
|
||||
return 'workflow';
|
||||
};
|
||||
|
||||
const buildVersion = (id: number) => `v${1 + (id % 3)}.${(id % 8) + 1}`;
|
||||
const buildCount = (id: number, offset: number) => 2 + ((id + offset) % 4);
|
||||
const buildRelatedCount = (id: number) => 3 + (id % 4);
|
||||
|
||||
const mapApiSkillToUiSkill = (skill: ApiSkill, type: 'official' | 'mine'): Skill => ({
|
||||
id: String(skill.id),
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
category: inferCategory(skill),
|
||||
highlight: type === 'mine' ? '已接入' : '可添加',
|
||||
relatedCount: buildRelatedCount(skill.id),
|
||||
version: buildVersion(skill.id),
|
||||
inputCount: buildCount(skill.id, 1),
|
||||
outputCount: buildCount(skill.id, 2),
|
||||
isNew: skill.id % 3 === 0,
|
||||
isMine: type === 'mine',
|
||||
});
|
||||
|
||||
const getAvatarStyle = (skillId: string) => AVATAR_STYLES[hashString(skillId) % AVATAR_STYLES.length];
|
||||
|
||||
export default function SkillsWorkshop() {
|
||||
const navigate = useNavigate();
|
||||
const { authedFetch } = useExpertAuthBootstrap();
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedFilter, setSelectedFilter] = useState<'all' | 'mine'>('all');
|
||||
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all');
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
|
||||
const [isCreatingSkillThread, setIsCreatingSkillThread] = useState(false);
|
||||
const [openCardMenuId, setOpenCardMenuId] = useState<string | null>(null);
|
||||
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
|
||||
const [addingSkillId, setAddingSkillId] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isCreatingByUpload, setIsCreatingByUpload] = useState(false);
|
||||
const [isAiEditing, setIsAiEditing] = useState(false);
|
||||
|
||||
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const cardMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const createUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
const fetchByType = async (type: 'official' | 'mine') => {
|
||||
const params = new URLSearchParams({
|
||||
type,
|
||||
keyword: searchTerm.trim(),
|
||||
page: '1',
|
||||
page_size: '100',
|
||||
});
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`加载技能失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const data = (await res.json()) as SkillsResponse;
|
||||
return data.items.map((item) => mapApiSkillToUiSkill(item, type));
|
||||
};
|
||||
|
||||
const loadSkills = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const list =
|
||||
selectedFilter === 'all'
|
||||
? await Promise.all([fetchByType('official'), fetchByType('mine')])
|
||||
: [await fetchByType('mine')];
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
const merged = selectedFilter === 'all' ? [...list[0], ...list[1]] : list[0];
|
||||
const deduped = Array.from(new Map(merged.map((item) => [item.id, item])).values());
|
||||
setSkills(deduped);
|
||||
} catch {
|
||||
if (canceled) return;
|
||||
setError('加载技能失败,请稍后重试');
|
||||
setSkills([]);
|
||||
} finally {
|
||||
if (!canceled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadSkills();
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [searchTerm, selectedFilter, authedFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (createMenuRef.current && !createMenuRef.current.contains(target)) {
|
||||
setIsCreateMenuOpen(false);
|
||||
}
|
||||
if (cardMenuRef.current && !cardMenuRef.current.contains(target)) {
|
||||
setOpenCardMenuId(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filteredSkills = useMemo(
|
||||
() => skills.filter((skill) => selectedCategory === 'all' || skill.category === selectedCategory),
|
||||
[selectedCategory, skills],
|
||||
);
|
||||
|
||||
const categoryCounts = useMemo(
|
||||
() =>
|
||||
categoryOrder.reduce<Record<CategoryKey, number>>((acc, category) => {
|
||||
acc[category] = category === 'all' ? skills.length : skills.filter((skill) => skill.category === category).length;
|
||||
return acc;
|
||||
}, {} as Record<CategoryKey, number>),
|
||||
[skills],
|
||||
);
|
||||
|
||||
const mySkillCount = skills.filter((skill) => skill.isMine).length;
|
||||
const officialSkillCount = skills.filter((skill) => !skill.isMine).length;
|
||||
|
||||
const handlePublishSkill = async (skillId: string) => {
|
||||
setOpenCardMenuId(null);
|
||||
try {
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/skills/publish`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
skill_id: parseInt(skillId, 10),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`发布失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('发布技能失败', err);
|
||||
setError('发布技能失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditSkill = (skillId: string) => {
|
||||
setOpenCardMenuId(null);
|
||||
const skill = skills.find((item) => item.id === skillId) ?? null;
|
||||
setEditingSkill(skill);
|
||||
};
|
||||
|
||||
const handleDeleteSkill = async (skillId: string) => {
|
||||
setOpenCardMenuId(null);
|
||||
try {
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/mine/${skillId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`删除失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
setSkills((prev) => prev.filter((item) => item.id !== skillId));
|
||||
MessagePlugin.success('操作成功');
|
||||
} catch (err) {
|
||||
console.error('删除技能失败', err);
|
||||
setError('删除技能失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadSkillFile = async (file: File, skillId: string) => {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/${skillId}/upload`, {
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`上传失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
setEditingSkill(null);
|
||||
} catch (err) {
|
||||
console.error('上传技能失败', err);
|
||||
setError('上传技能失败,请稍后重试');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (uploadInputRef.current) uploadInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddOfficialSkill = async (skillId: string) => {
|
||||
if (addingSkillId) return;
|
||||
setAddingSkillId(skillId);
|
||||
try {
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/mine/add`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ official_skill_id: parseInt(skillId, 10) }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`添加失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
setSkills((prev) => prev.map((item) => (item.id === skillId ? { ...item, isMine: true, highlight: '已接入' } : item)));
|
||||
MessagePlugin.success('添加成功');
|
||||
} catch (err) {
|
||||
console.error('添加技能失败', err);
|
||||
setError('添加技能失败,请稍后重试');
|
||||
} finally {
|
||||
setAddingSkillId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAiEditSkill = async (skill: Skill) => {
|
||||
if (isAiEditing) return;
|
||||
setIsAiEditing(true);
|
||||
try {
|
||||
const threadRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: `编辑技能:${skill.name}`,
|
||||
archived: true,
|
||||
is_real_chat: true,
|
||||
}),
|
||||
});
|
||||
if (!threadRes.ok) {
|
||||
throw new Error(`创建会话失败: ${threadRes.status} ${threadRes.statusText}`);
|
||||
}
|
||||
const threadData = (await threadRes.json()) as CreateSkillThreadResponse;
|
||||
const threadId = threadData?.id;
|
||||
if (!threadId) throw new Error('创建会话成功,但未返回会话 id');
|
||||
|
||||
const copyRes = await authedFetch(
|
||||
`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${threadId}/copy-skill/${skill.id}`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!copyRes.ok) {
|
||||
throw new Error(`复制技能失败: ${copyRes.status} ${copyRes.statusText}`);
|
||||
}
|
||||
|
||||
setEditingSkill(null);
|
||||
localStorage.setItem('skill_creator_thread_id', String(threadId));
|
||||
navigate(`/skill-creator?id=${threadId}`);
|
||||
} catch (err) {
|
||||
console.error('AI 辅助编辑失败', err);
|
||||
setError('AI 辅助编辑失败,请稍后重试');
|
||||
} finally {
|
||||
setIsAiEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSkillByUpload = async (file: File) => {
|
||||
setIsCreatingByUpload(true);
|
||||
setIsCreateMenuOpen(false);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`上传失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('上传技能失败', err);
|
||||
setError('上传技能失败,请稍后重试');
|
||||
} finally {
|
||||
setIsCreatingByUpload(false);
|
||||
if (createUploadInputRef.current) createUploadInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSkillByAgent = async () => {
|
||||
if (isCreatingSkillThread) return;
|
||||
setIsCreatingSkillThread(true);
|
||||
setIsCreateMenuOpen(false);
|
||||
try {
|
||||
const threadRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: '新建技能',
|
||||
archived: true,
|
||||
is_real_chat: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!threadRes.ok) {
|
||||
throw new Error(`创建技能会话失败: ${threadRes.status} ${threadRes.statusText}`);
|
||||
}
|
||||
|
||||
const threadData = (await threadRes.json()) as CreateSkillThreadResponse;
|
||||
const threadId = threadData?.id;
|
||||
if (!threadId) {
|
||||
throw new Error('创建技能会话成功,但未返回会话 id');
|
||||
}
|
||||
|
||||
localStorage.setItem('skill_creator_thread_id', String(threadId));
|
||||
navigate(`/qa-center/skill-creator?id=${threadId}`);
|
||||
} catch (createError) {
|
||||
console.error('创建技能会话失败', createError);
|
||||
setError('创建技能会话失败,请稍后重试');
|
||||
} finally {
|
||||
setIsCreatingSkillThread(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-full min-h-[calc(100vh-140px)] ${isDarkMode ? 'bg-slate-950 text-slate-100' : 'bg-[#fcfcfd] text-[#101828]'}`}>
|
||||
{editingSkill && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) setEditingSkill(null);
|
||||
}}
|
||||
>
|
||||
<div className={`relative w-[440px] overflow-hidden rounded-[28px] border shadow-2xl ${isDarkMode ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-[#f3d6d6] bg-white text-gray-900'}`}>
|
||||
<div className="flex items-center justify-between px-6 pb-4 pt-5">
|
||||
<h2 className="text-base font-semibold">编辑技能</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingSkill(null)}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${isDarkMode ? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100' : 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className={`px-6 pb-4 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
选择编辑方式:<span className={`font-medium ${isDarkMode ? 'text-slate-200' : 'text-gray-800'}`}>{editingSkill.name}</span>
|
||||
</p>
|
||||
|
||||
<div className={`mx-6 border-t ${isDarkMode ? 'border-slate-700' : 'border-[#f4e4e4]'}`} />
|
||||
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isAiEditing}
|
||||
onClick={() => void handleAiEditSkill(editingSkill)}
|
||||
className={`group/opt flex w-full items-center gap-4 rounded-[22px] border px-4 py-4 text-left transition ${isDarkMode ? 'border-slate-700 bg-slate-800/60 hover:bg-slate-800' : 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'} ${isAiEditing ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-indigo-600 text-white shadow-lg shadow-violet-500/30 ring-1 ring-white/20">
|
||||
<Bot className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
{isAiEditing ? '处理中...' : 'AI 辅助编辑'}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>通过对话开启 AI 帮助您优化技能内容</p>
|
||||
</div>
|
||||
<ChevronRight className={`h-4 w-4 shrink-0 transition ${isDarkMode ? 'text-slate-500 group-hover/opt:text-slate-300' : 'text-gray-300 group-hover/opt:text-gray-500'}`} />
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept=".md,.zip"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && editingSkill) {
|
||||
void handleUploadSkillFile(file, editingSkill.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isUploading}
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
className={`group/opt flex w-full items-center gap-4 rounded-[22px] border px-4 py-4 text-left transition ${isDarkMode ? 'border-slate-700 bg-slate-800/60 hover:bg-slate-800' : 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'} ${isUploading ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-lg shadow-orange-400/30 ring-1 ring-white/20">
|
||||
<Upload className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
{isUploading ? '上传中...' : '上传新版覆盖'}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>上传 `.md` 或 `.zip` 文件替换当前内容</p>
|
||||
</div>
|
||||
<ChevronRight className={`h-4 w-4 shrink-0 transition ${isDarkMode ? 'text-slate-500 group-hover/opt:text-slate-300' : 'text-gray-300 group-hover/opt:text-gray-500'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center justify-end gap-3 px-6 pb-5 pt-1 ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingSkill(null)}
|
||||
className={`rounded-xl px-4 py-2 text-sm transition ${isDarkMode ? 'hover:bg-slate-800 hover:text-slate-200' : 'hover:bg-[#fff3f3] hover:text-[#ef4444]'}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto max-w-[1360px] px-6 py-8">
|
||||
<section className={`mb-7 ${isDarkMode ? '' : ''}`}>
|
||||
<div className="flex items-start justify-between gap-5">
|
||||
<div>
|
||||
<h1 className={`text-[22px] font-bold tracking-tight ${isDarkMode ? 'text-slate-100' : 'text-[#111827]'}`}>技能工坊</h1>
|
||||
<p className={`mt-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>统一管理系统中的技能资产,支持发现、接入、发布与编辑。</p>
|
||||
</div>
|
||||
|
||||
<div className="relative" ref={createMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateMenuOpen((prev) => !prev)}
|
||||
className="inline-flex h-11 items-center gap-2 rounded-xl bg-[#ea2d2d] px-5 text-sm font-medium text-white shadow-[0_10px_20px_-12px_rgba(234,45,45,0.7)] transition hover:bg-[#d92626]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建技能体
|
||||
</button>
|
||||
|
||||
{isCreateMenuOpen && (
|
||||
<div className={`absolute right-0 top-[calc(100%+10px)] z-20 w-60 rounded-2xl border py-2 shadow-xl ${isDarkMode ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isCreatingSkillThread}
|
||||
onClick={() => void handleCreateSkillByAgent()}
|
||||
className={`w-full px-4 py-3 text-left transition ${isDarkMode ? 'hover:bg-slate-800' : 'hover:bg-[#fff6f6]'} ${isCreatingSkillThread ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>{isCreatingSkillThread ? '创建中...' : '通过 Agent 构建'}</p>
|
||||
<p className={`mt-1 text-xs ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>通过对话构建和训练 AI 技能</p>
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={createUploadInputRef}
|
||||
type="file"
|
||||
accept=".md,.zip"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void handleCreateSkillByUpload(file);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isCreatingByUpload}
|
||||
onClick={() => createUploadInputRef.current?.click()}
|
||||
className={`w-full px-4 py-3 text-left transition ${isDarkMode ? 'hover:bg-slate-800' : 'hover:bg-[#fff6f6]'} ${isCreatingByUpload ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>{isCreatingByUpload ? '上传中...' : '上传技能文件'}</p>
|
||||
<p className={`mt-1 text-xs ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>导入 `.md` 或 `.zip` 文件</p>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`mb-8 rounded-[28px] border px-6 py-6 ${isDarkMode ? 'border-slate-800 bg-slate-900/65' : 'border-[#f5d4d4] bg-white'}`}>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className={`flex min-w-0 flex-1 items-center gap-3 rounded-2xl border px-4 py-3 ${isDarkMode ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'}`}>
|
||||
<Search className={`h-4 w-4 ${isDarkMode ? 'text-slate-400' : 'text-[#ff6b6b]'}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索技能名称..."
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
className={`min-w-0 flex-1 bg-transparent text-sm outline-none ${isDarkMode ? 'text-slate-100 placeholder:text-slate-500' : 'text-gray-800 placeholder:text-[#9ca3af]'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`inline-flex rounded-2xl border p-1 ${isDarkMode ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedFilter('all')}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${selectedFilter === 'all' ? 'bg-[#fff1f1] text-[#ef4444]' : isDarkMode ? 'text-slate-300 hover:bg-slate-800' : 'text-[#475467] hover:bg-[#f9fafb]'}`}
|
||||
>
|
||||
全部类型
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedFilter('mine')}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${selectedFilter === 'mine' ? 'bg-[#fff1f1] text-[#ef4444]' : isDarkMode ? 'text-slate-300 hover:bg-slate-800' : 'text-[#475467] hover:bg-[#f9fafb]'}`}
|
||||
>
|
||||
我的技能
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="flex flex-wrap items-center gap-3">
|
||||
<span className={`text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#475467]'}`}>按类型筛选:</span>
|
||||
{categoryOrder.slice(1).map((category) => {
|
||||
const active = selectedCategory === category;
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategory(active ? 'all' : category)}
|
||||
className={`rounded-full border px-4 py-2 text-sm transition ${
|
||||
active
|
||||
? 'border-[#ffb5b5] bg-[#fff5f5] text-[#ef4444]'
|
||||
: isDarkMode
|
||||
? 'border-slate-700 bg-slate-900/40 text-slate-300 hover:border-slate-600'
|
||||
: 'border-[#f3d0d0] bg-white text-[#667085] hover:border-[#ffcccc] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_LABELS[category]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div> */}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{loading ? (
|
||||
<div className={`flex items-center justify-center rounded-[28px] border py-20 text-sm ${isDarkMode ? 'border-slate-800 bg-slate-900/60 text-slate-300' : 'border-[#f5d4d4] bg-white text-[#667085]'}`}>
|
||||
正在加载技能...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${isDarkMode ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'}`}>
|
||||
<h3 className={`mb-2 text-lg font-semibold ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>加载失败</h3>
|
||||
<p className={`text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>{error}</p>
|
||||
</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${isDarkMode ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'}`}>
|
||||
<div className={`mb-4 flex h-12 w-12 items-center justify-center rounded-2xl ${isDarkMode ? 'bg-slate-800 text-slate-400' : 'bg-[#fff1f1] text-[#ff6b6b]'}`}>
|
||||
<Search className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className={`mb-2 text-lg font-semibold ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>未找到匹配的技能</h3>
|
||||
<p className={`text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>尝试调整搜索词或切换筛选条件。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{filteredSkills.map((skill) => {
|
||||
const avatar = getAvatarStyle(skill.id);
|
||||
const AvatarIcon = avatar.Icon;
|
||||
const categoryLabel = CATEGORY_LABELS[skill.category as CategoryKey] ?? '流程类';
|
||||
|
||||
return (
|
||||
<article
|
||||
key={skill.id}
|
||||
className={`group relative rounded-[22px] border px-5 py-5 transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:shadow-xl hover:shadow-black/20'
|
||||
: 'border-[#f5d4d4] bg-white hover:border-[#ffbdbd] hover:shadow-[0_16px_40px_-26px_rgba(239,68,68,0.35)]'
|
||||
}`}
|
||||
>
|
||||
|
||||
{!skill.isMine && selectedFilter === 'all' && (
|
||||
<div className="absolute right-4 top-4 z-20 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
disabled={addingSkillId === skill.id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleAddOfficialSkill(skill.id);
|
||||
}}
|
||||
className={`flex items-center justify-center rounded-lg bg-emerald-500 text-white shadow-sm transition hover:bg-emerald-600 ${addingSkillId === skill.id ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{skill.isMine && (
|
||||
<div className="absolute right-4 top-4 z-20" ref={openCardMenuId === skill.id ? cardMenuRef : null}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpenCardMenuId((prev) => (prev === skill.id ? null : skill.id));
|
||||
}}
|
||||
className={`flex items-center justify-center rounded-lg transition ${isDarkMode ? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'}`}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{openCardMenuId === skill.id && (
|
||||
<div className={`absolute right-0 top-[calc(100%+6px)] z-30 w-40 rounded-2xl border py-1 shadow-xl ${isDarkMode ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handlePublishSkill(skill.id);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${isDarkMode ? 'text-emerald-400 hover:bg-slate-800' : 'text-emerald-600 hover:bg-[#fff6f6]'}`}
|
||||
>
|
||||
<Globe className="h-4 w-4 shrink-0" />
|
||||
发布到全部
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleEditSkill(skill.id);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${isDarkMode ? 'text-slate-300 hover:bg-slate-800' : 'text-amber-600 hover:bg-[#fff6f6]'}`}
|
||||
>
|
||||
<Edit className="h-4 w-4 shrink-0" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDeleteSkill(skill.id);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${isDarkMode ? 'text-rose-400 hover:bg-slate-800' : 'text-rose-500 hover:bg-[#fff6f6]'}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className={`relative flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br ${avatar.wrapper} shadow-[0_18px_32px_-22px_rgba(15,23,42,0.35)] ${avatar.glow} ring-1 ${avatar.ring}`}>
|
||||
<div className={`absolute inset-0 rounded-2xl ${avatar.orb}`} />
|
||||
<AvatarIcon className="relative z-10 h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className={`text-[17px] font-semibold ${skill.isMine ? 'text-[#111827]' : isDarkMode ? 'text-slate-100' : 'text-[#111827]'}`}>{skill.name}</h3>
|
||||
<span className="inline-flex items-center rounded-full bg-[#fff2f2] px-2.5 py-1 text-xs font-medium text-[#ef4444]">
|
||||
{ skill.isMine ? '我的' : '官方' }
|
||||
</span>
|
||||
</div>
|
||||
<p className={`mt-1 line-clamp-3 text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>{skill.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
export interface AgentStepItem {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AgentParsed {
|
||||
steps: AgentStepItem[];
|
||||
thinkingMarkdown: string;
|
||||
answerMarkdown: string;
|
||||
thinkingComplete: boolean;
|
||||
}
|
||||
|
||||
const STEP_RE = /<step\s+title="([^"]+)">([\s\S]*?)<\/step>/g;
|
||||
const ANSWER_RE = /<answer>([\s\S]*?)<\/answer>/;
|
||||
const THINKING_COMMENT_RE = /<!--\s*thinking:\s*([\s\S]*?)\s*-->/i;
|
||||
const WAITING_PREFIX = '仍在等待模型首个 token...';
|
||||
|
||||
const stripCodeFence = (raw: string): string => {
|
||||
const trimmed = raw.trim();
|
||||
const match = /```(?:json)?\s*([\s\S]*?)```/i.exec(trimmed);
|
||||
if (match) return match[1].trim();
|
||||
return trimmed.replace(/^```(?:json)?\s*/i, '').trim();
|
||||
};
|
||||
|
||||
const stripThinkTags = (
|
||||
text: string,
|
||||
): { remainder: string; thinkBlock: string; closed: boolean } => {
|
||||
const start = text.indexOf('<think>');
|
||||
if (start === -1) return { remainder: text, thinkBlock: '', closed: false };
|
||||
const after = text.slice(start + '<think>'.length);
|
||||
const endIdx = after.indexOf('</think>');
|
||||
if (endIdx === -1) {
|
||||
return {
|
||||
remainder: text.slice(0, start),
|
||||
thinkBlock: after,
|
||||
closed: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
remainder: text.slice(0, start) + after.slice(endIdx + '</think>'.length),
|
||||
thinkBlock: after.slice(0, endIdx),
|
||||
closed: true,
|
||||
};
|
||||
};
|
||||
|
||||
const mergeStep = (steps: AgentStepItem[], nextStep: AgentStepItem) => {
|
||||
const existingIndex = steps.findIndex((step) => step.title === nextStep.title);
|
||||
if (existingIndex === -1) {
|
||||
steps.push(nextStep);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = steps[existingIndex];
|
||||
const nextIsWaiting = nextStep.content.startsWith(WAITING_PREFIX);
|
||||
const existingLines = existing.content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const stableLines = existingLines.filter((line) => !line.startsWith(WAITING_PREFIX));
|
||||
|
||||
if (nextIsWaiting) {
|
||||
const mergedLines = stableLines.length > 0 ? [...stableLines, nextStep.content] : [nextStep.content];
|
||||
steps[existingIndex] = {
|
||||
...existing,
|
||||
content: mergedLines.join('\n'),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedLines = stableLines.includes(nextStep.content)
|
||||
? stableLines
|
||||
: [...stableLines, nextStep.content];
|
||||
steps[existingIndex] = {
|
||||
...existing,
|
||||
content: normalizedLines.join('\n'),
|
||||
};
|
||||
};
|
||||
|
||||
const extractSteps = (text: string): { steps: AgentStepItem[]; remainder: string } => {
|
||||
const steps: AgentStepItem[] = [];
|
||||
const remainder = text.replace(STEP_RE, (_, title: string, content: string) => {
|
||||
mergeStep(steps, { title: title.trim(), content: content.trim() });
|
||||
return '';
|
||||
});
|
||||
return { steps, remainder };
|
||||
};
|
||||
|
||||
const extractTaggedSection = (
|
||||
text: string,
|
||||
re: RegExp,
|
||||
): { value: string; remainder: string } => {
|
||||
const match = re.exec(text);
|
||||
if (!match) return { value: '', remainder: text };
|
||||
return {
|
||||
value: match[1].trim(),
|
||||
remainder: text.replace(match[0], '').trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const extractJsonStringField = (
|
||||
text: string,
|
||||
field: string,
|
||||
): { value: string; complete: boolean; matched: boolean } => {
|
||||
const re = new RegExp(`"${field}"\\s*:\\s*"`);
|
||||
const match = re.exec(text);
|
||||
if (!match) return { value: '', complete: false, matched: false };
|
||||
let i = match.index + match[0].length;
|
||||
let value = '';
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
if (ch === '\\') {
|
||||
if (i + 1 >= text.length) return { value, complete: false, matched: true };
|
||||
const next = text[i + 1];
|
||||
switch (next) {
|
||||
case 'n':
|
||||
value += '\n';
|
||||
break;
|
||||
case 't':
|
||||
value += '\t';
|
||||
break;
|
||||
case 'r':
|
||||
value += '\r';
|
||||
break;
|
||||
case '"':
|
||||
value += '"';
|
||||
break;
|
||||
case '\\':
|
||||
value += '\\';
|
||||
break;
|
||||
case '/':
|
||||
value += '/';
|
||||
break;
|
||||
case 'b':
|
||||
value += '\b';
|
||||
break;
|
||||
case 'f':
|
||||
value += '\f';
|
||||
break;
|
||||
case 'u':
|
||||
if (i + 5 < text.length) {
|
||||
const hex = text.slice(i + 2, i + 6);
|
||||
value += String.fromCharCode(parseInt(hex, 16));
|
||||
i += 6;
|
||||
continue;
|
||||
}
|
||||
return { value, complete: false, matched: true };
|
||||
default:
|
||||
value += next;
|
||||
}
|
||||
i += 2;
|
||||
} else if (ch === '"') {
|
||||
return { value, complete: true, matched: true };
|
||||
} else {
|
||||
value += ch;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return { value, complete: false, matched: true };
|
||||
};
|
||||
|
||||
const extractItems = (text: string): unknown[] => {
|
||||
const re = /"items"\s*:\s*\[/;
|
||||
const match = re.exec(text);
|
||||
if (!match) return [];
|
||||
let i = match.index + match[0].length;
|
||||
const out: unknown[] = [];
|
||||
while (i < text.length) {
|
||||
while (i < text.length && /[\s,]/.test(text[i])) i++;
|
||||
if (i >= text.length) break;
|
||||
if (text[i] === ']') break;
|
||||
if (text[i] !== '{') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let depth = 0;
|
||||
let inStr = false;
|
||||
let escape = false;
|
||||
let j = i;
|
||||
for (; j < text.length; j++) {
|
||||
const c = text[j];
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (inStr) {
|
||||
if (c === '\\') escape = true;
|
||||
else if (c === '"') inStr = false;
|
||||
} else if (c === '"') inStr = true;
|
||||
else if (c === '{') depth++;
|
||||
else if (c === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
j++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (depth !== 0) break;
|
||||
try {
|
||||
out.push(JSON.parse(text.slice(i, j)));
|
||||
} catch {
|
||||
// ignore partial item
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const itemToMarkdown = (item: unknown, idx: number): string => {
|
||||
if (item == null) return `${idx + 1}.`;
|
||||
if (typeof item !== 'object') return `${idx + 1}. ${String(item)}`;
|
||||
const o = item as Record<string, unknown>;
|
||||
const title =
|
||||
(typeof o.title === 'string' && o.title) ||
|
||||
(typeof o.expected_behavior === 'string' && o.expected_behavior) ||
|
||||
(typeof o.name === 'string' && o.name) ||
|
||||
`条目 ${idx + 1}`;
|
||||
const body =
|
||||
(typeof o.content === 'string' && o.content) ||
|
||||
(typeof o.reason === 'string' && o.reason) ||
|
||||
'';
|
||||
|
||||
const lines: string[] = [`**${idx + 1}. ${title}**`];
|
||||
if (body) lines.push(body);
|
||||
|
||||
if (typeof o.expected_behavior === 'string' && typeof o.driving_factor === 'string') {
|
||||
if (!o.title) {
|
||||
lines.length = 0;
|
||||
lines.push(`**${idx + 1}. ${o.expected_behavior}**`);
|
||||
}
|
||||
lines.push(`_驱动因素_:${o.driving_factor}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(o.references) && o.references.length > 0) {
|
||||
const refs = (o.references as unknown[])
|
||||
.filter((x) => typeof x === 'string' || typeof x === 'number')
|
||||
.map((x) => `\`${String(x)}\``)
|
||||
.join(' ');
|
||||
if (refs) lines.push(`引用:${refs}`);
|
||||
}
|
||||
|
||||
return lines.join('\n\n');
|
||||
};
|
||||
|
||||
const itemsToMarkdown = (items: unknown[]): string =>
|
||||
items.map((item, idx) => itemToMarkdown(item, idx)).join('\n\n---\n\n');
|
||||
|
||||
export const parseAgentResponse = (raw: string): AgentParsed => {
|
||||
if (!raw) {
|
||||
return {
|
||||
steps: [],
|
||||
thinkingMarkdown: '',
|
||||
answerMarkdown: '',
|
||||
thinkingComplete: false,
|
||||
};
|
||||
}
|
||||
|
||||
const stripped = stripThinkTags(raw);
|
||||
const stepsResult = extractSteps(stripped.remainder);
|
||||
let body = stripCodeFence(stepsResult.remainder);
|
||||
const extraThinking = stripped.thinkBlock;
|
||||
let thinkingComplete = stripped.closed;
|
||||
|
||||
const answerSection = extractTaggedSection(body, ANSWER_RE);
|
||||
body = answerSection.remainder;
|
||||
const thinkingCommentSection = extractTaggedSection(body, THINKING_COMMENT_RE);
|
||||
body = thinkingCommentSection.remainder;
|
||||
|
||||
if (body) {
|
||||
const start = body.indexOf('{');
|
||||
if (start !== -1) body = body.slice(start);
|
||||
}
|
||||
|
||||
if (body) {
|
||||
try {
|
||||
const obj = JSON.parse(body) as Record<string, unknown>;
|
||||
if (obj && typeof obj === 'object') {
|
||||
const thinking = typeof obj.thinking === 'string' ? obj.thinking : '';
|
||||
const items = Array.isArray(obj.items) ? (obj.items as unknown[]) : [];
|
||||
const answerMarkdown = items.length > 0 ? itemsToMarkdown(items) : answerSection.value;
|
||||
return {
|
||||
steps: stepsResult.steps,
|
||||
thinkingMarkdown: [extraThinking, thinking, thinkingCommentSection.value]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
answerMarkdown,
|
||||
thinkingComplete: true,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const thinkingField = extractJsonStringField(body, 'thinking');
|
||||
const items = extractItems(body);
|
||||
|
||||
if (thinkingField.complete) thinkingComplete = true;
|
||||
|
||||
let fallbackAnswer = answerSection.value || body.trim();
|
||||
if (items.length > 0) {
|
||||
fallbackAnswer = itemsToMarkdown(items);
|
||||
}
|
||||
|
||||
if (!thinkingField.matched && items.length === 0 && !extraThinking) {
|
||||
return {
|
||||
steps: stepsResult.steps,
|
||||
thinkingMarkdown: '',
|
||||
answerMarkdown: fallbackAnswer,
|
||||
thinkingComplete: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
steps: stepsResult.steps,
|
||||
thinkingMarkdown: [extraThinking, thinkingField.value, thinkingCommentSection.value]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
answerMarkdown: fallbackAnswer,
|
||||
thinkingComplete,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,736 @@
|
||||
# Agent 组件开发问答(Q&A)
|
||||
|
||||
> 目录范围:`src/components/agent`
|
||||
>
|
||||
> 涵盖文件:
|
||||
> - `AgentExpertCenterPage.tsx`
|
||||
> - `AgentExpertEditPage.tsx`
|
||||
> - `AgentChatPage.tsx`
|
||||
> - `SkillCreatorPage.tsx`
|
||||
> - `SkillsWorkshop.tsx`
|
||||
> - `SkillCreatorThreadSidebar.tsx`
|
||||
> - `AgentConversationList.tsx`
|
||||
> - `SkillCreatorConversationItem.tsx`
|
||||
> - `ChatInputComposer.tsx`
|
||||
> - `AgentChatPage.css`
|
||||
|
||||
---
|
||||
|
||||
## Q1:这个目录整体是干什么的?
|
||||
|
||||
**A:这是“专家 Agent + 技能工坊 + 技能创建对话”的前端 UI 组件集合。**
|
||||
|
||||
- `AgentExpertCenterPage`:专家列表入口(我的专家/发布专家)。
|
||||
- `AgentExpertEditPage`:专家配置编辑页(左侧对话预览 + 右侧配置)。
|
||||
- `AgentChatPage`:某个专家的真实聊天页(含线程、文件上传、流式回答)。
|
||||
- `SkillsWorkshop`:技能资产中心(搜索/筛选/接入/发布/删除/编辑)。
|
||||
- `SkillCreatorPage`:技能创建/改写专用对话页(支持文件树预览和保存弹窗)。
|
||||
- `SkillCreatorThreadSidebar`:复用的会话侧边栏(获取线程、新建、删除、切换)。
|
||||
- `AgentConversationList` + `SkillCreatorConversationItem`:统一消息列表与消息卡片渲染。
|
||||
- `ChatInputComposer`:统一输入框(发送/停止/上传)。
|
||||
- `AgentChatPage.css`:`AgentChatPage` 专用动画和滚动条样式。
|
||||
|
||||
---
|
||||
|
||||
## Q2:核心共享数据模型有哪些?
|
||||
|
||||
**A:对话层主要围绕 `ConversationMessage`。**
|
||||
|
||||
- 定义位置:`AgentConversationList.tsx`
|
||||
- 字段:
|
||||
- `id`: 消息唯一标识。
|
||||
- `role`: `'user' | 'assistant'`
|
||||
- `content`: 文本内容(markdown 渲染)。
|
||||
- `steps`: 思考步骤列表(数据来自 `data-thinking-step`)。
|
||||
- `thinkings`: 推理内容列表(来自 `reasoning-*` 事件)。
|
||||
- `toolCalls`: 工具调用(输入输出)列表。
|
||||
|
||||
**A:文件树使用 `FileNode`。**
|
||||
|
||||
- 在 `AgentChatPage`、`SkillCreatorPage` 内部定义并使用。
|
||||
- 字段:`name/path/type/size/extension/children`。
|
||||
|
||||
**A:线程侧边栏使用 `SkillCreatorThread`。**
|
||||
|
||||
- 定义位置:`SkillCreatorThreadSidebar.tsx`
|
||||
- 字段:`id/title/archived/expert_agent_id/created_at/updated_at`。
|
||||
|
||||
---
|
||||
|
||||
## Q3:整个目录的“数据流主干”怎么走?
|
||||
|
||||
**A:可以看成 3 条主链路。**
|
||||
|
||||
### 1) 专家链路(Center -> Edit -> Chat)
|
||||
|
||||
1. `AgentExpertCenterPage` 拉专家列表(我的/发布)。
|
||||
2. 点击“编辑”跳 `AgentExpertEditPage`,加载专家详情 + 工具 + 技能绑定状态。
|
||||
3. 点击“进行对话”跳 `AgentChatPage`,加载该专家详情和对应会话消息。
|
||||
|
||||
### 2) 技能链路(Workshop -> SkillCreator)
|
||||
|
||||
1. `SkillsWorkshop` 拉技能列表(official/mine),本地映射成 UI `Skill`。
|
||||
2. 新建技能:创建会话后跳 `SkillCreatorPage?id=threadId`。
|
||||
3. AI 辅助编辑已有技能:先创建线程,再 copy skill 到线程,再跳转 `SkillCreatorPage`。
|
||||
|
||||
### 3) 对话链路(输入 -> SSE -> UI -> 持久化)
|
||||
|
||||
1. `ChatInputComposer` 触发 `onSend`。
|
||||
2. 页面组件(`AgentChatPage` 或 `SkillCreatorPage`)执行 `handleSendMessage`。
|
||||
3. 先写入用户消息 + assistant 占位消息。
|
||||
4. 调流式接口读取 SSE:
|
||||
- `data-thinking-step` -> 更新 `steps`
|
||||
- `reasoning-start/delta/end` -> 更新 `thinkings`
|
||||
- `tool-input-available/tool-output-available` -> 更新 `toolCalls`
|
||||
- `text-delta` -> 追加 `content`
|
||||
5. 完成后将 assistant 结果再次 POST 到消息接口持久化。
|
||||
|
||||
---
|
||||
|
||||
## Q4:`AgentExpertCenterPage` 组件是干什么的?
|
||||
|
||||
**A:专家资产“总览入口”,负责展示列表和基础操作入口。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 展示“我的专家”和“发布专家”两种视图。
|
||||
- 打开专家详情弹窗(支持发布状态切换)。
|
||||
- 提供“编辑”和“进行对话”跳转入口。
|
||||
|
||||
### 数据来源与接口
|
||||
|
||||
- 列表查询:
|
||||
- `POST /api/v1/expert-agents/list`(按 `is_draft`)
|
||||
- 我的专家场景还会额外 `GET /api/v1/expert-agents` 做合并去重。
|
||||
- 状态切换:
|
||||
- `PATCH /api/v1/expert-agents/{id}`(更新 `is_draft`)
|
||||
|
||||
### 数据流
|
||||
|
||||
- `activeTab` 改变 -> `fetchExpertList` 触发 -> 更新 `agentList`。
|
||||
- `agentList` -> `cards(useMemo)` -> 渲染卡片。
|
||||
- 卡片按钮:
|
||||
- 编辑 -> `navigate('/qa-center/agent-expert-edit?id=...')`
|
||||
- 对话 -> `navigate('/qa-center/agent-chat?id=...')`
|
||||
|
||||
### 维护注意
|
||||
|
||||
- “创建专家”按钮目前是 UI 壳子,未接创建接口。
|
||||
- “删除”按钮当前仅 `console.log`,未接真实删除逻辑。
|
||||
|
||||
---
|
||||
|
||||
## Q5:`AgentExpertEditPage` 组件是干什么的?
|
||||
|
||||
**A:专家配置编辑工作台,同时内置一个“测试对话区”。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 左侧:和该专家进行流式对话(非真实聊天线程,使用 no_real 线程)。
|
||||
- 右侧:编辑专家基础配置(名称、模型、描述、系统提示词)。
|
||||
- 管理工具绑定、技能绑定(弹窗操作)。
|
||||
|
||||
### 关键状态
|
||||
|
||||
- 对话相关:`messages/inputValue/threadId/isStreaming`
|
||||
- 配置相关:`formValues`
|
||||
- 工具相关:`toolOptions/selectedTools/pendingTools/boundToolIdMap`
|
||||
- 技能相关:`skillOptions/selectedSkills/boundSkillMap/skillKeyword`
|
||||
|
||||
### 主要接口
|
||||
|
||||
- 专家详情:`GET /api/v1/expert-agents/{agentId}`
|
||||
- 保存专家:`PUT /api/v1/expert-agents/{agentId}`
|
||||
- 绑定工具:
|
||||
- 查询:`GET /api/v1/expert-agents/{agentId}/tools`
|
||||
- 添加:`POST /api/v1/expert-agents/{agentId}/tools`
|
||||
- 删除:`DELETE /api/v1/expert-agents/{agentId}/tools/{toolId}`
|
||||
- 绑定技能:
|
||||
- 查询:`GET /api/v1/expert-agents/{agentId}/skills`
|
||||
- 添加:`POST /api/v1/expert-agents/{agentId}/skills`
|
||||
- 删除:`DELETE /api/v1/expert-agents/{agentId}/skills/{skillId}`
|
||||
- 测试对话:
|
||||
- 线程:`GET /api/v1/expert-agent-chat/threads/no_real/{agentId}`
|
||||
- 用户消息:`POST /api/v1/expert-agent-chat/messages`
|
||||
- SSE:`POST /api/v1/expert-agent-chat/run/stream/help_expert`
|
||||
|
||||
### 数据流(对话)
|
||||
|
||||
- 用户发送 -> append user msg -> 保证 threadId -> 保存用户消息 -> append assistant 占位 -> SSE 增量更新。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- 右侧 `preview` 区底部 `ChatInputComposer` 是只读展示,不参与真实请求。
|
||||
- 工具与技能弹窗是“前端状态 + 后端同步”双层状态,调试时优先检查 refresh 查询是否成功。
|
||||
|
||||
---
|
||||
|
||||
## Q6:`AgentChatPage` 组件是干什么的?
|
||||
|
||||
**A:专家“真实聊天页”,并带线程管理、文件上传和文件预览。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 根据 URL `id` 读取专家详情。
|
||||
- 管理当前线程(localStorage 持久化)。
|
||||
- 获取历史消息并转换为统一消息结构。
|
||||
- 流式发送消息并渲染思考过程。
|
||||
- 管理会话文件(上传、树形结构、预览、侧栏缩放)。
|
||||
|
||||
### 关键状态
|
||||
|
||||
- 会话:`currentThreadId/messages/inputValue/isStreaming/isLoading`
|
||||
- UI:`isThreadSidebarOpen/autoScrollEnabled/showScrollButton`
|
||||
- 文件:`fileList/fileListThreadId/isFileSidebarOpen/expandedPaths/selectedFile/filePreview*`
|
||||
|
||||
### 主要接口
|
||||
|
||||
- 专家详情:`GET /api/v1/expert-agents/{agentId}`
|
||||
- 会话消息:
|
||||
- 历史:`GET /api/v1/expert-agent-chat/threads/{id}/messages`
|
||||
- 保存用户:`POST /api/v1/expert-agent-chat/messages`
|
||||
- SSE:`POST /api/v1/expert-agent-chat/run/stream`
|
||||
- 保存助手:`POST /api/v1/expert-agent-chat/messages`
|
||||
- 文件:
|
||||
- 列表:`GET /api/v1/expert-agent-chat/threads/{id}/files/all`
|
||||
- 上传:`POST /api/v1/expert-agent-chat/threads/{id}/files/upload`
|
||||
- 预览:`GET /api/v1/expert-agent-chat/threads/{id}/files/preview?path=...`
|
||||
|
||||
### 关键行为细节
|
||||
|
||||
- **发送即出现 assistant 占位**:点击发送后立即插入 assistant 空消息,保证用户立刻看到“连接中”态。
|
||||
- **连接中显示规则**:由 `AgentConversationList` 判断“最后一条 assistant 且还没有任何生成内容”才显示连接状态。
|
||||
- **失败回滚**:发送或流式失败时移除该 assistant 占位,避免残留空卡片。
|
||||
- **停止流式**:`AbortController` 中断请求,并清理 loading 态字段。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- `threadStorageKey` 与 `agentId` 强绑定,切换专家会切换独立 thread 记忆。
|
||||
- `normalizeHistoryContent` 是历史消息兼容核心,后端 content 结构变更时先改这里。
|
||||
|
||||
---
|
||||
|
||||
## Q7:`SkillCreatorPage` 组件是干什么的?
|
||||
|
||||
**A:技能创建/重构专用对话页,结构上是 `AgentChatPage` 的“技能场景定制版”。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 从 query/localStorage 同步当前线程 ID。
|
||||
- 加载技能会话历史与会话文件。
|
||||
- 走技能专属流式接口输出思考步骤/推理/tool call/文本。
|
||||
- 提供“保存技能”弹窗(当前主要是 UI + 本地状态)。
|
||||
|
||||
### 与 `AgentChatPage` 的主要差异
|
||||
|
||||
- 线程 ID 同步到 URL(`useSearchParams`)。
|
||||
- 有独立 `isConnecting` 状态,用于连接中阶段展示。
|
||||
- 流式接口是:`POST /api/v1/expert-agent-chat/skills-agent`
|
||||
- 页面内注入了 dot/shimmer 动画样式(供消息连接中态使用)。
|
||||
|
||||
### 主要接口
|
||||
|
||||
- 会话消息:`GET /threads/{id}/messages`
|
||||
- 流式:`POST /skills-agent`
|
||||
- 保存 assistant:`POST /messages`
|
||||
- 文件:`GET all` / `POST upload` / `GET preview`
|
||||
|
||||
### 数据流
|
||||
|
||||
- 发消息 -> append 用户 + assistant 占位 -> `isConnecting=true` -> 收到首个流块后 `isConnecting=false` -> 增量刷新 assistant -> 持久化。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- 保存技能按钮目前仅 `console.log` 输出技能名和描述,未接正式后端保存接口。
|
||||
|
||||
---
|
||||
|
||||
## Q8:`SkillsWorkshop` 组件是干什么的?
|
||||
|
||||
**A:技能资产管理页(发现、筛选、接入、发布、删除、进入编辑)。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 拉取 `official` 与 `mine` 技能,映射成统一 UI 结构。
|
||||
- 支持搜索、类型筛选、分类筛选。
|
||||
- 支持官方技能接入到“我的技能”。
|
||||
- 支持发布、删除、编辑(编辑弹窗支持 AI 编辑和上传覆盖)。
|
||||
- 支持“新建技能体”:Agent 对话创建 / 上传文件创建。
|
||||
|
||||
### 主要接口
|
||||
|
||||
- 列表:`GET /api/v1/skills?type=official|mine`
|
||||
- 发布:`POST /api/v1/expert-agents/skills/publish`
|
||||
- 删除我的技能:`DELETE /api/v1/skills/mine/{skillId}`
|
||||
- 上传覆盖:`PUT /api/v1/skills/{skillId}/upload`
|
||||
- 接入官方技能:`POST /api/v1/skills/mine/add`
|
||||
- 上传创建:`POST /api/v1/skills/upload`
|
||||
- Agent 创建线程:`POST /api/v1/expert-agent-chat/threads`
|
||||
- AI 编辑复制技能:`POST /threads/{threadId}/copy-skill/{skillId}`
|
||||
|
||||
### 数据流
|
||||
|
||||
- filter/search 改变 -> `useEffect` 重新拉取技能 -> 本地去重并缓存到 `skills`。
|
||||
- 卡片编辑 -> `editingSkill` 打开弹窗 -> 选择 AI 编辑时创建线程后跳 `SkillCreatorPage`。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- `inferCategory` 是关键词规则映射,新增技能类型时可优先扩展该规则。
|
||||
- “进入”按钮目前使用 `skill.id` 跳 `skill-creator`,若后端语义改成 threadId 需调整。
|
||||
|
||||
---
|
||||
|
||||
## Q9:`SkillCreatorThreadSidebar` 组件是干什么的?
|
||||
|
||||
**A:会话线程管理的复用侧边栏。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 打开时加载会话列表。
|
||||
- 新建会话并自动选中。
|
||||
- 删除会话并在当前会话被删时自动回退到其它会话。
|
||||
- 支持线程过滤(如仅展示当前专家线程)。
|
||||
|
||||
### 输入输出(Props 视角)
|
||||
|
||||
- 输入:`authedFetch/currentThreadId/isOpen/isBusy/threadFilter/createThreadPayload...`
|
||||
- 输出:
|
||||
- `onThreadSelect(threadId)`
|
||||
- `onClose()`
|
||||
|
||||
### 数据流
|
||||
|
||||
- `isOpen` 从 false -> true:触发 `refreshThreads`。
|
||||
- 新建成功:更新本地列表并触发 `onThreadSelect`。
|
||||
- 删除成功:本地移除;若删的是当前线程,自动选择 fallback。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- `createThreadPayload` 是高复用扩展点,场景页可注入 `expert_agent_id/title` 等字段。
|
||||
|
||||
---
|
||||
|
||||
## Q10:`AgentConversationList` 组件是干什么的?
|
||||
|
||||
**A:统一遍历消息并决定“连接中状态”是否显示。**
|
||||
|
||||
### 核心逻辑
|
||||
|
||||
- 逐条渲染 `SkillCreatorConversationItem`。
|
||||
- 只在以下条件满足时给最后一条 assistant 传 `isConnecting=true`:
|
||||
- 页面处于连接/流式阶段;
|
||||
- 当前消息是最后一条 assistant;
|
||||
- 且还没有任何实际生成内容(`content/steps/thinkings/toolCalls` 都为空)。
|
||||
|
||||
### 价值
|
||||
|
||||
- 将“连接中占位显示时机”从页面组件抽离,统一控制,减少重复判断代码。
|
||||
|
||||
---
|
||||
|
||||
## Q11:`SkillCreatorConversationItem` 组件是干什么的?
|
||||
|
||||
**A:单条消息卡片渲染器(用户消息 + 助手多面板消息)。**
|
||||
|
||||
### 渲染结构
|
||||
|
||||
- 用户消息:右侧气泡 + user avatar。
|
||||
- 助手消息:左侧 avatar + 多段内容:
|
||||
- `StepsSummary`
|
||||
- `ThinkingPanel[]`
|
||||
- `ToolCallPanel[]`
|
||||
- 连接中动画文案(`正在连接技能服务...`)
|
||||
- Markdown 正文
|
||||
|
||||
### 数据来源
|
||||
|
||||
- 完全由父级传入 `message` 和 `isConnecting` 控制,不直接请求后端。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- 此组件是所有对话页的统一呈现层;若改动 message schema,要同步更新这里的各面板。
|
||||
|
||||
---
|
||||
|
||||
## Q12:`ChatInputComposer` 组件是干什么的?
|
||||
|
||||
**A:通用输入组件,封装输入、发送/停止、上传文件。**
|
||||
|
||||
### 输入输出(Props)
|
||||
|
||||
- 输入:`value/disabled/readOnly/isSending/isDarkMode/showUpload/placeholder...`
|
||||
- 回调:
|
||||
- `onChange(value)`
|
||||
- `onSend()`
|
||||
- `onStop()`
|
||||
- `onUploadFile(file)`
|
||||
|
||||
### 关键交互
|
||||
|
||||
- Enter(非 Shift)触发发送(前提:`submitEnabled` 且非 `isSending`)。
|
||||
- 发送按钮在发送中切换为停止按钮(`onClick={isSending ? onStop : onSend}`)。
|
||||
- 文件上传通过隐藏 `input[type=file]` + 按钮触发。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- 当前实现里文本框只受 `disabled` 控制,不再因为 `isSending` 禁止输入。
|
||||
- 上传按钮仍受 `disabled || !onUploadFile` 控制。
|
||||
|
||||
---
|
||||
|
||||
## Q13:`AgentChatPage.css` 主要做了什么?
|
||||
|
||||
**A:提供 `AgentChatPage` 的辅助样式。**
|
||||
|
||||
- `.agent-resizing`:拖拽文件侧栏时全局 cursor/user-select 控制。
|
||||
- `.agent-file-tree-scroll` / `.agent-file-preview-scroll`:滚动条样式统一。
|
||||
- `.agent-file-markdown-preview`:markdown 首尾边距和图片样式收敛。
|
||||
- `.animate-breathing` 等动画类:预留呼吸动效。
|
||||
|
||||
---
|
||||
|
||||
## Q14:典型业务流程:发送一条消息时,数据如何传输?
|
||||
|
||||
**A:以 `AgentChatPage` 为例。**
|
||||
|
||||
1. 用户在 `ChatInputComposer` 输入后点击发送。
|
||||
2. `handleSendMessage` 校验 `text/threadId/isStreaming`。
|
||||
3. 立即写入:
|
||||
- user message
|
||||
- assistant 占位 message(空内容)
|
||||
4. 调 `POST /messages` 落用户消息。
|
||||
5. 调 `POST /run/stream` 开始 SSE。
|
||||
6. 每个 SSE event 增量更新 assistant message:
|
||||
- step -> `steps`
|
||||
- reasoning -> `thinkings`
|
||||
- tool -> `toolCalls`
|
||||
- text -> `content`
|
||||
7. 流结束后将聚合结果组装成 `persistedContent`,再 `POST /messages` 落库。
|
||||
8. 刷新文件列表(对可能新增输出文件做同步)。
|
||||
|
||||
---
|
||||
|
||||
## Q15:典型业务流程:从技能工坊进入技能创建页,数据如何传输?
|
||||
|
||||
**A:两种入口。**
|
||||
|
||||
### 入口 A:新建技能(Agent 构建)
|
||||
|
||||
1. `SkillsWorkshop.handleCreateSkillByAgent` 创建线程。
|
||||
2. 将 `threadId` 写入 localStorage。
|
||||
3. 跳转 `navigate('/qa-center/skill-creator?id=threadId')`。
|
||||
4. `SkillCreatorPage` 用 query/localStorage 同步 `currentThreadId`,然后拉消息和文件。
|
||||
|
||||
### 入口 B:AI 辅助编辑已有技能
|
||||
|
||||
1. 创建线程。
|
||||
2. `POST /threads/{threadId}/copy-skill/{skillId}` 把技能内容复制到线程工作区。
|
||||
3. 跳转 SkillCreator 页面,后续在线程中对话编辑。
|
||||
|
||||
---
|
||||
|
||||
## Q16:如果要新增一个“对话页”,建议复用哪些组件?
|
||||
|
||||
**A:建议最小复用组合如下。**
|
||||
|
||||
- 消息列表:`AgentConversationList`
|
||||
- 单消息卡片:`SkillCreatorConversationItem`(自动包含 steps/thinking/tool 等面板)
|
||||
- 输入框:`ChatInputComposer`
|
||||
- 线程侧边栏:`SkillCreatorThreadSidebar`
|
||||
|
||||
**页面层只负责:**
|
||||
|
||||
- 状态管理(`messages/input/isStreaming/currentThreadId`)
|
||||
- 接口请求(历史 + SSE + 持久化)
|
||||
- 事件映射(把后端事件转为 `ConversationMessage` 结构)
|
||||
|
||||
---
|
||||
|
||||
## Q17:维护时最容易踩坑的点有哪些?
|
||||
|
||||
**A:建议优先关注这 8 个点。**
|
||||
|
||||
1. **SSE 事件兼容性**:后端 event type 或字段名变化会直接导致 UI 不更新。
|
||||
2. **历史内容兼容**:`normalizeHistoryContent` 没跟上 content schema 会出现历史消息空白。
|
||||
3. **线程切换中断**:切换线程时要记得 abort 流并清状态,避免串流到新线程。
|
||||
4. **占位消息回收**:发送失败/流失败需移除 assistant 占位,避免残留空卡片。
|
||||
5. **threadId 语义一致性**:某些页面参数传 skillId/agentId,需避免误当 threadId 使用。
|
||||
6. **工具/技能绑定状态双源**:本地状态和后端状态不同步时,优先以 refresh 查询结果为准。
|
||||
7. **上传入口 accept 限制**:上传文件类型修改时要同步后端校验和 UI 提示。
|
||||
8. **深色主题样式**:该目录深色/浅色分支很多,改样式需双主题回归。
|
||||
|
||||
---
|
||||
|
||||
## Q18:后续文档扩展建议
|
||||
|
||||
**A:当前文档适合开发入门,下一步可补充“接口契约版”。**
|
||||
|
||||
- 补完整 SSE event JSON 示例(每种事件一例)。
|
||||
- 增加错误码到 UI 提示映射表。
|
||||
- 补一份“页面状态机图”(Idle / Connecting / Streaming / Stopped / Error)。
|
||||
- 给每个页面补“可测点清单”(单测/联调/回归)。
|
||||
|
||||
---
|
||||
|
||||
## Q19:能否给一份可联调的“接口契约附录”?
|
||||
|
||||
**A:可以,以下按模块整理。**
|
||||
|
||||
### 1) 专家模块(Expert)
|
||||
|
||||
- 查询专家列表
|
||||
- `POST /api/v1/expert-agents/list`
|
||||
- 请求体:
|
||||
- `is_draft: boolean`
|
||||
- 响应(数组项):
|
||||
- `id/name/avatar_url/description/model_name/is_draft/user_id/created_at`
|
||||
|
||||
- 查询专家详情
|
||||
- `GET /api/v1/expert-agents/{agentId}`
|
||||
- 响应关键字段:
|
||||
- `id/name/description/system_prompt/model_name`
|
||||
|
||||
- 更新专家配置
|
||||
- `PUT /api/v1/expert-agents/{agentId}`
|
||||
- 请求体关键字段:
|
||||
- `name`
|
||||
- `description`
|
||||
- `system_prompt`
|
||||
- `model_name`
|
||||
- `avatar_url`(当前传 `null`)
|
||||
|
||||
- 切换草稿/发布状态
|
||||
- `PATCH /api/v1/expert-agents/{agentId}`
|
||||
- 请求体:
|
||||
- `is_draft: boolean`
|
||||
|
||||
### 2) 线程与消息模块(Chat Thread + Message)
|
||||
|
||||
- 查询线程列表
|
||||
- `GET /api/v1/expert-agent-chat/threads`
|
||||
- 响应:
|
||||
- `threads: SkillCreatorThread[]`
|
||||
- `archived_threads: SkillCreatorThread[]`
|
||||
|
||||
- 新建线程
|
||||
- `POST /api/v1/expert-agent-chat/threads`
|
||||
- 请求体常见字段:
|
||||
- `title`
|
||||
- `archived`
|
||||
- `is_real_chat`
|
||||
- `expert_agent_id`(专家聊天场景)
|
||||
|
||||
- 删除线程
|
||||
- `DELETE /api/v1/expert-agent-chat/threads/{threadId}`
|
||||
|
||||
- 查询线程消息历史
|
||||
- `GET /api/v1/expert-agent-chat/threads/{threadId}/messages`
|
||||
- 响应消息项关键字段:
|
||||
- `id/thread_id/role/content/created_at`
|
||||
|
||||
- 保存消息(用户或助手)
|
||||
- `POST /api/v1/expert-agent-chat/messages`
|
||||
- 请求体(用户消息):
|
||||
- `thread_id`
|
||||
- `role: "user"`
|
||||
- `content: { type: "text", text: string }`
|
||||
- 请求体(助手消息):
|
||||
- `thread_id`
|
||||
- `role: "assistant"`
|
||||
- `content: persistedContent[]`
|
||||
|
||||
### 3) 流式回复模块(SSE)
|
||||
|
||||
- 专家聊天流式
|
||||
- `POST /api/v1/expert-agent-chat/run/stream`
|
||||
- 请求体关键字段:
|
||||
- `chat_id`
|
||||
- `expert_agent_id`
|
||||
- `user_query`
|
||||
- `messages`
|
||||
|
||||
- 专家编辑页测试流式
|
||||
- `POST /api/v1/expert-agent-chat/run/stream/help_expert`
|
||||
|
||||
- 技能创建流式
|
||||
- `POST /api/v1/expert-agent-chat/skills-agent`
|
||||
- 请求体关键字段:
|
||||
- `chat_id`
|
||||
- `user_query`
|
||||
|
||||
### 4) 文件模块(线程文件)
|
||||
|
||||
- 查询线程文件树
|
||||
- `GET /api/v1/expert-agent-chat/threads/{threadId}/files/all`
|
||||
|
||||
- 上传文件
|
||||
- `POST /api/v1/expert-agent-chat/threads/{threadId}/files/upload`
|
||||
- form-data:
|
||||
- `file`
|
||||
|
||||
- 文件预览
|
||||
- `GET /api/v1/expert-agent-chat/threads/{threadId}/files/preview?path=...`
|
||||
- 兼容响应:
|
||||
- 文本
|
||||
- JSON 字符串
|
||||
- JSON 对象中的 `content`
|
||||
|
||||
### 5) 技能模块(Skills)
|
||||
|
||||
- 查询技能列表
|
||||
- `GET /api/v1/skills?type=official|mine|all&keyword=&page=1&page_size=100`
|
||||
|
||||
- 发布技能
|
||||
- `POST /api/v1/expert-agents/skills/publish`
|
||||
- 请求体:
|
||||
- `skill_id`
|
||||
|
||||
- 添加官方技能到我的技能
|
||||
- `POST /api/v1/skills/mine/add`
|
||||
- 请求体:
|
||||
- `official_skill_id`
|
||||
|
||||
- 删除我的技能
|
||||
- `DELETE /api/v1/skills/mine/{skillId}`
|
||||
|
||||
- 上传技能文件创建
|
||||
- `POST /api/v1/skills/upload`
|
||||
|
||||
- 上传覆盖已有技能
|
||||
- `PUT /api/v1/skills/{skillId}/upload`
|
||||
|
||||
### 6) 专家绑定工具/技能
|
||||
|
||||
- 工具
|
||||
- 查询:`GET /api/v1/expert-agents/{agentId}/tools`
|
||||
- 绑定:`POST /api/v1/expert-agents/{agentId}/tools`(`tool_name`)
|
||||
- 解绑:`DELETE /api/v1/expert-agents/{agentId}/tools/{toolId}`
|
||||
|
||||
- 技能
|
||||
- 查询:`GET /api/v1/expert-agents/{agentId}/skills`
|
||||
- 绑定:`POST /api/v1/expert-agents/{agentId}/skills`(`skill_id`)
|
||||
- 解绑:`DELETE /api/v1/expert-agents/{agentId}/skills/{skillId}`
|
||||
|
||||
---
|
||||
|
||||
## Q20:SSE 事件规范可以补全吗?
|
||||
|
||||
**A:可以,以下是前端当前实现依赖的事件集合。**
|
||||
|
||||
### 1) 事件封包格式
|
||||
|
||||
- 每行以 `data: ` 开头
|
||||
- 正文为 JSON 字符串
|
||||
- 结束标记:`data: [DONE]`
|
||||
|
||||
### 2) 前端识别的事件类型
|
||||
|
||||
- `data-thinking-step`
|
||||
- 用途:更新执行步骤面板
|
||||
- 关键字段:
|
||||
- `data.id`
|
||||
- `data.title`
|
||||
- `data.status`(`completed` 或其它)
|
||||
- `data.items[]`
|
||||
|
||||
- `reasoning-start`
|
||||
- 用途:开始一段思考
|
||||
- 行为:创建新的 thinking 段,记录开始时间
|
||||
|
||||
- `reasoning-delta`
|
||||
- 用途:思考内容增量
|
||||
- 关键字段:
|
||||
- `delta`
|
||||
|
||||
- `reasoning-end`
|
||||
- 用途:结束当前思考段
|
||||
- 行为:计算该段耗时并写入 `duration`
|
||||
|
||||
- `tool-input-available`
|
||||
- 用途:记录工具调用入参
|
||||
- 关键字段:
|
||||
- `toolCallId`
|
||||
- `toolName`
|
||||
- `input`
|
||||
|
||||
- `tool-output-available`
|
||||
- 用途:记录工具调用输出
|
||||
- 关键字段:
|
||||
- `toolCallId`
|
||||
- `output`
|
||||
|
||||
- `text-delta`
|
||||
- 用途:助手正文 token 增量
|
||||
- 关键字段:
|
||||
- `delta`
|
||||
|
||||
### 3) 推荐后端保证
|
||||
|
||||
- 同一次回复里的 `toolCallId` 全局唯一。
|
||||
- `reasoning-start` 与 `reasoning-end` 成对(允许无 `delta`)。
|
||||
- `data-thinking-step` 同一个 `id` 可重复更新状态。
|
||||
- `text-delta` 在结束前允许为空片段,但尽量减少空片段。
|
||||
|
||||
### 4) 前端容错策略(已实现)
|
||||
|
||||
- JSON parse 失败:跳过该行,不中断整流。
|
||||
- 未识别 event.type:忽略。
|
||||
- `output` 非字符串:尝试 `result` 字段,否则 `JSON.stringify`。
|
||||
- `AbortError`:按“用户主动停止”处理,不弹致命错误。
|
||||
|
||||
---
|
||||
|
||||
## Q21:可以给一份页面级测试清单吗?
|
||||
|
||||
**A:可以,以下清单按页面拆分,适合联调和回归。**
|
||||
|
||||
### A. `AgentChatPage` 测试清单
|
||||
|
||||
- 打开页面后,能加载专家信息(名称、描述)。
|
||||
- 未选线程时,空态正确显示;选中线程后历史消息正确渲染。
|
||||
- 点击发送后立即出现 assistant 占位卡片,显示连接中状态。
|
||||
- 首个有效流式事件到达后,连接中占位消失,转为正常内容增量渲染。
|
||||
- 发送失败/流失败时,assistant 占位会被回收,不留空白卡片。
|
||||
- 点击停止后,流式中断,按钮状态恢复,后续不再追加内容。
|
||||
- 上传文件后文件树刷新;文件预览支持 text/json 两类返回。
|
||||
- 切换线程时会中断当前流,且不会把旧线程流内容写到新线程。
|
||||
|
||||
### B. `SkillCreatorPage` 测试清单
|
||||
|
||||
- URL `id` 与 localStorage 线程 ID 同步正确。
|
||||
- 新线程空态和快捷提示按钮显示正常。
|
||||
- 发送后 `isConnecting=true`,收到首包后 `isConnecting=false`。
|
||||
- steps/thinking/tool/text 四类流式内容都能正确出现。
|
||||
- 线程切换、停止流、文件上传/预览路径行为与 `AgentChatPage` 一致。
|
||||
- 保存技能弹窗开关正常(当前为 UI 行为)。
|
||||
|
||||
### C. `AgentExpertEditPage` 测试清单
|
||||
|
||||
- 进入页面后基础信息(name/model/description/system_prompt)能回填。
|
||||
- 保存专家配置后接口成功,按钮 loading 状态恢复。
|
||||
- 工具弹窗:勾选新增 -> 确认添加 -> 列表刷新一致。
|
||||
- 技能弹窗:绑定/解绑/一键绑定后状态与后端一致。
|
||||
- 左侧测试对话能正常流式返回,滚动与发送状态正确。
|
||||
|
||||
### D. `SkillsWorkshop` 测试清单
|
||||
|
||||
- `all`/`mine` 切换结果正确,搜索词可生效。
|
||||
- 分类筛选(writing/analysis/decision/validation/workflow)正确过滤。
|
||||
- 官方技能“+”接入后卡片状态转“已接入”。
|
||||
- 我的技能菜单中的发布/删除操作正确执行。
|
||||
- 编辑弹窗中 AI 编辑路径可成功创建线程并跳转 SkillCreator。
|
||||
- 上传创建、上传覆盖两条路径成功后状态正确恢复(含 input 清空)。
|
||||
|
||||
### E. `AgentExpertCenterPage` 测试清单
|
||||
|
||||
- mine/published 标签切换后列表正确变化。
|
||||
- mine 模式会按当前用户过滤,且与全量接口合并去重后正确展示。
|
||||
- 详情弹窗中的发布开关可切换并刷新列表。
|
||||
- 编辑、对话跳转路径参数正确。
|
||||
|
||||
### F. 主题与可用性回归
|
||||
|
||||
- 深色/浅色主题下对话气泡、按钮、边框、hover 都可读。
|
||||
- 长会话场景滚动性能可接受,自动滚动按钮行为符合预期。
|
||||
- 键盘发送(Enter)与换行(Shift+Enter)行为正确。
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Bot, Edit, Eye, Plus, Search, Sparkles, Trash2, X } from 'lucide-react';
|
||||
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
interface AgentItem {
|
||||
key: string;
|
||||
type: 'builtin' | 'custom' | string;
|
||||
deletable: boolean;
|
||||
name: string;
|
||||
description: string;
|
||||
default_prompt: string;
|
||||
schema_hint: string;
|
||||
bound_skills: string[];
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface SkillItem {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface AgentCardListProps {
|
||||
onEdit?: (key: string) => void;
|
||||
}
|
||||
|
||||
type TypeFilter = 'all' | 'builtin' | 'custom';
|
||||
|
||||
interface CreateAgentForm {
|
||||
name: string;
|
||||
description: string;
|
||||
bound_skills: string[];
|
||||
}
|
||||
|
||||
const INITIAL_FORM: CreateAgentForm = {
|
||||
name: '',
|
||||
description: '',
|
||||
bound_skills: [],
|
||||
};
|
||||
|
||||
const backUrl = 'https://ch1.b.uat.4.cn/zncmback';
|
||||
export default function AgentCardList({ onEdit }: AgentCardListProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const [agents, setAgents] = useState<AgentItem[]>([]);
|
||||
const [skills, setSkills] = useState<SkillItem[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createForm, setCreateForm] = useState<CreateAgentForm>(INITIAL_FORM);
|
||||
const [skillKeyword, setSkillKeyword] = useState('');
|
||||
|
||||
const showNotice = (message: string) => {
|
||||
setNotice(message);
|
||||
window.setTimeout(() => {
|
||||
setNotice((current) => (current === message ? null : current));
|
||||
}, 2400);
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [agentRes, skillRes] = await Promise.all([fetch(`${backUrl}/api/agents`), fetch(`${backUrl}/api/skills`)]);
|
||||
if (!agentRes.ok) {
|
||||
throw new Error(`加载 MCP 失败:${agentRes.status}`);
|
||||
}
|
||||
|
||||
const agentData = (await agentRes.json()) as AgentItem[];
|
||||
const skillData = skillRes.ok ? ((await skillRes.json()) as SkillItem[]) : [];
|
||||
|
||||
setAgents(agentData.sort((a, b) => a.order - b.order));
|
||||
setSkills(skillData);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, []);
|
||||
|
||||
const filteredAgents = useMemo(() => {
|
||||
const kw = keyword.trim().toLowerCase();
|
||||
return agents.filter((agent) => {
|
||||
const matchesKeyword =
|
||||
!kw ||
|
||||
agent.name.toLowerCase().includes(kw) ||
|
||||
agent.key.toLowerCase().includes(kw) ||
|
||||
agent.description.toLowerCase().includes(kw);
|
||||
|
||||
const matchesType = typeFilter === 'all' || agent.type === typeFilter;
|
||||
return matchesKeyword && matchesType;
|
||||
});
|
||||
}, [agents, keyword, typeFilter]);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
const kw = skillKeyword.trim().toLowerCase();
|
||||
if (!kw) {
|
||||
return skills;
|
||||
}
|
||||
return skills.filter((skill) =>
|
||||
`${skill.name} ${skill.key} ${skill.description || ''}`.toLowerCase().includes(kw),
|
||||
);
|
||||
}, [skillKeyword, skills]);
|
||||
|
||||
const resetCreateForm = () => {
|
||||
setCreateForm(INITIAL_FORM);
|
||||
setSkillKeyword('');
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
resetCreateForm();
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
setCreateOpen(false);
|
||||
resetCreateForm();
|
||||
};
|
||||
|
||||
const toggleSkill = (skillKey: string) => {
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
bound_skills: prev.bound_skills.includes(skillKey)
|
||||
? prev.bound_skills.filter((item) => item !== skillKey)
|
||||
: [...prev.bound_skills, skillKey],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = createForm.name.trim();
|
||||
const description = createForm.description.trim();
|
||||
if (!name) {
|
||||
setError('请先输入 MCP 名称');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${backUrl}/api/agents`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
bound_skills: createForm.bound_skills,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = `新增失败:${res.status}`;
|
||||
try {
|
||||
const body = (await res.json()) as { detail?: string };
|
||||
if (body.detail) {
|
||||
detail = body.detail;
|
||||
}
|
||||
} catch {
|
||||
// ignore json parse errors
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
await res.json();
|
||||
await loadData();
|
||||
closeCreateModal();
|
||||
showNotice('MCP 已新增');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '新增失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (agent: AgentItem) => {
|
||||
if (!agent.deletable) return;
|
||||
const ok = window.confirm(`确认删除 MCP “${agent.name}”吗?`);
|
||||
if (!ok) return;
|
||||
|
||||
const res = await fetch(`${backUrl}/api/agents/${agent.key}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
setError(`删除失败:${res.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await loadData();
|
||||
showNotice('MCP 已删除');
|
||||
};
|
||||
|
||||
const selectedSkillCount = createForm.bound_skills.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className={`mb-2 text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
MCP 智能体配置
|
||||
</h1>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
管理内置与用户新增的 MCP,并支持绑定技能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateModal}
|
||||
className={`inline-flex h-10 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
|
||||
isDark ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-red-600 text-white hover:bg-red-500'
|
||||
}`}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新增 MCP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="relative max-w-xl flex-1">
|
||||
<Search
|
||||
className={`absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 ${
|
||||
isDark ? 'text-slate-500' : 'text-red-400'
|
||||
}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索 MCP 名称、Key 或描述"
|
||||
className={`w-full rounded-xl border py-2.5 pl-10 pr-4 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-red-500/20 ${
|
||||
isDark
|
||||
? 'border-blue-800/30 bg-slate-800/50 text-white placeholder-slate-500'
|
||||
: 'border-red-200 bg-white text-gray-900 placeholder-red-300'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'builtin', label: '内置 MCP' },
|
||||
{ key: 'custom', label: '用户新增' },
|
||||
].map((item) => {
|
||||
const active = typeFilter === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setTypeFilter(item.key as TypeFilter)}
|
||||
className={`rounded-full px-3 py-1.5 text-sm transition ${
|
||||
active
|
||||
? isDark
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-red-600 text-white'
|
||||
: isDark
|
||||
? 'bg-slate-800 text-slate-300 hover:bg-slate-700'
|
||||
: 'bg-white text-gray-600 ring-1 ring-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <div className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>加载中...</div> : null}
|
||||
{error ? <div className="text-sm text-red-500">{error}</div> : null}
|
||||
{notice ? <div className="text-sm text-emerald-500">{notice}</div> : null}
|
||||
|
||||
{!loading && !error ? (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{filteredAgents.map((agent) => (
|
||||
<div
|
||||
key={agent.key}
|
||||
className={`group rounded-xl border p-5 transition-all duration-300 ${
|
||||
isDark
|
||||
? 'border-blue-800/30 bg-slate-800/30 backdrop-blur-xl hover:border-blue-500/50'
|
||||
: 'border-red-100 bg-white hover:border-red-300 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-xl ${
|
||||
isDark ? 'bg-blue-500/20' : 'bg-red-100'
|
||||
}`}
|
||||
>
|
||||
<Bot className={`h-6 w-6 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{agent.name}
|
||||
</h3>
|
||||
<p className={`text-xs font-mono ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
{agent.key}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||
agent.type === 'builtin'
|
||||
? isDark
|
||||
? 'bg-cyan-500/15 text-cyan-200'
|
||||
: 'bg-cyan-50 text-cyan-700'
|
||||
: isDark
|
||||
? 'bg-amber-500/15 text-amber-200'
|
||||
: 'bg-amber-50 text-amber-700'
|
||||
}`}
|
||||
>
|
||||
{agent.type === 'builtin' ? '内置' : '自定义'}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||
isDark ? 'bg-slate-700/60 text-slate-300' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
#{agent.order + 1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={`mb-4 min-h-[40px] text-sm ${isDark ? 'text-slate-400' : 'text-gray-600'}`}>
|
||||
{agent.description || '暂无描述'}
|
||||
</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className={`mb-2 flex items-center gap-1 text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
已绑定技能
|
||||
</div>
|
||||
<div className="flex min-h-[28px] flex-wrap gap-1.5">
|
||||
{agent.bound_skills.length === 0 ? (
|
||||
<span className={`text-xs ${isDark ? 'text-slate-600' : 'text-gray-400'}`}>未绑定</span>
|
||||
) : (
|
||||
agent.bound_skills.map((skillKey) => {
|
||||
const skill = skills.find((item) => item.key === skillKey);
|
||||
return (
|
||||
<span
|
||||
key={skillKey}
|
||||
className={`rounded-full border px-2 py-0.5 text-xs ${
|
||||
isDark
|
||||
? 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300'
|
||||
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
}`}
|
||||
>
|
||||
{skill?.name || skillKey}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex items-center justify-end gap-2 border-t pt-3 ${
|
||||
isDark ? 'border-blue-800/30' : 'border-red-100'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit?.(agent.key)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors ${
|
||||
isDark
|
||||
? 'bg-slate-700/60 text-slate-200 hover:bg-slate-700'
|
||||
: 'border border-red-200 bg-white text-red-600 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
详情
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit?.(agent.key)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors ${
|
||||
isDark ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-red-600 text-white hover:bg-red-500'
|
||||
}`}
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
编辑
|
||||
</button>
|
||||
{agent.deletable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDelete(agent)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors ${
|
||||
isDark
|
||||
? 'bg-rose-500/15 text-rose-200 hover:bg-rose-500/25'
|
||||
: 'bg-rose-50 text-rose-600 hover:bg-rose-100'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
删除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !error && filteredAgents.length === 0 ? (
|
||||
<div className={`py-12 text-center text-sm ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
没有匹配的 MCP
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{createOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeCreateModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`max-h-[85vh] w-full max-w-2xl overflow-hidden rounded-3xl border shadow-2xl ${
|
||||
isDark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-red-100 bg-white text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b px-6 py-5 dark:border-slate-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">新增 MCP</h2>
|
||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
输入 MCP 名称、详情并选择要绑定的技能
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeCreateModal}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl ${
|
||||
isDark ? 'hover:bg-slate-800' : 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 overflow-y-auto px-6 py-5">
|
||||
<div>
|
||||
<label className={`mb-2 block text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
|
||||
MCP 名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="例如:游戏分析助手"
|
||||
className={`w-full rounded-xl border px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-red-500/20 ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950 text-white placeholder:text-slate-500'
|
||||
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={`mb-2 block text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
|
||||
详情
|
||||
</label>
|
||||
<textarea
|
||||
value={createForm.description}
|
||||
onChange={(e) => setCreateForm((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="一句话说明这个 MCP 的职责"
|
||||
rows={4}
|
||||
className={`w-full rounded-xl border px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-red-500/20 ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950 text-white placeholder:text-slate-500'
|
||||
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<label className={`block text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
|
||||
绑定技能
|
||||
</label>
|
||||
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
已选 {selectedSkillCount} 项
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={skillKeyword}
|
||||
onChange={(e) => setSkillKeyword(e.target.value)}
|
||||
placeholder="搜索技能名称或描述"
|
||||
className={`w-full rounded-xl border px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-red-500/20 ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950 text-white placeholder:text-slate-500'
|
||||
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`max-h-72 space-y-2 overflow-y-auto rounded-2xl border p-3 ${
|
||||
isDark ? 'border-slate-700 bg-slate-950/60' : 'border-gray-200 bg-gray-50/70'
|
||||
}`}
|
||||
>
|
||||
{filteredSkills.length === 0 ? (
|
||||
<div className={`py-8 text-center text-sm ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
没有匹配的技能
|
||||
</div>
|
||||
) : (
|
||||
filteredSkills.map((skill) => {
|
||||
const checked = createForm.bound_skills.includes(skill.key);
|
||||
return (
|
||||
<button
|
||||
key={skill.key}
|
||||
type="button"
|
||||
onClick={() => toggleSkill(skill.key)}
|
||||
className={`flex w-full items-start gap-3 rounded-xl border px-3 py-3 text-left transition ${
|
||||
checked
|
||||
? isDark
|
||||
? 'border-emerald-500/50 bg-emerald-500/10'
|
||||
: 'border-emerald-200 bg-emerald-50'
|
||||
: isDark
|
||||
? 'border-slate-700 bg-slate-900/50 hover:bg-slate-800/70'
|
||||
: 'border-white bg-white hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mt-1 h-4 w-4 shrink-0 rounded border ${
|
||||
checked
|
||||
? 'border-emerald-500 bg-emerald-500'
|
||||
: isDark
|
||||
? 'border-slate-600'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
{skill.name}
|
||||
<span className={`ml-2 font-mono text-[11px] ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
{skill.key}
|
||||
</span>
|
||||
</div>
|
||||
<p className={`mt-1 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{skill.description || '暂无描述'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex items-center justify-end gap-3 border-t px-6 py-4 ${
|
||||
isDark ? 'border-slate-700' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeCreateModal}
|
||||
className={`rounded-xl px-4 py-2 text-sm ${
|
||||
isDark ? 'text-slate-300 hover:bg-slate-800' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={creating}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-medium text-white ${
|
||||
isDark ? 'bg-blue-600 hover:bg-blue-500' : 'bg-red-600 hover:bg-red-500'
|
||||
} disabled:cursor-not-allowed disabled:opacity-60`}
|
||||
>
|
||||
{creating ? '创建中...' : '确认新增'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Bot, ChevronDown, CheckCircle2, Loader2, Sparkles, UserRound } from 'lucide-react';
|
||||
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import MarkdownRenderer from '../MarkdownRenderer';
|
||||
import { parseAgentResponse } from '../agent/agentResponseParser';
|
||||
|
||||
export interface AgentChatMsg {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface AvatarProps {
|
||||
role: 'user' | 'assistant';
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
const AgentAvatar: React.FC<AvatarProps> = ({ role, isActive = false }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const Icon = role === 'assistant' ? Bot : UserRound;
|
||||
|
||||
const gradientClass =
|
||||
role === 'assistant'
|
||||
? 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'
|
||||
: 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';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-2xl shadow-sm ${gradientClass} ${isActive ? 'qa-avatar-active' : ''}`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/20 opacity-0 qa-avatar-shine" />
|
||||
<Icon className={`relative z-10 h-5 w-5 ${isActive ? 'qa-avatar-icon-active' : ''}`} />
|
||||
{role === 'assistant' && isActive && (
|
||||
<Sparkles className="qa-avatar-spark absolute -right-0.5 -top-0.5 h-3.5 w-3.5 text-yellow-200" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CollapsibleProps {
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Collapsible: React.FC<CollapsibleProps> = ({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden rounded-2xl border ${isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'} ${className}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={`flex w-full 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'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">{title}</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 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 AssistantAnswerProps {
|
||||
steps: { title: string; content: string }[];
|
||||
thinkingMarkdown: string;
|
||||
answerMarkdown: string;
|
||||
thinkingComplete: boolean;
|
||||
streaming: boolean;
|
||||
}
|
||||
|
||||
const AssistantAnswer: React.FC<AssistantAnswerProps> = ({
|
||||
steps,
|
||||
thinkingMarkdown,
|
||||
answerMarkdown,
|
||||
thinkingComplete,
|
||||
streaming,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const showThinkingSection = Boolean(thinkingMarkdown) || (!thinkingComplete && streaming);
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0 max-w-5xl space-y-3 overflow-hidden">
|
||||
{steps.length > 0 && (
|
||||
<Collapsible
|
||||
defaultOpen
|
||||
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={`flex h-9 w-9 items-center justify-center rounded-2xl ${
|
||||
isDark ? 'bg-emerald-500/15 text-emerald-300' : 'bg-emerald-50 text-emerald-600'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
步骤
|
||||
</div>
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
展示技能执行、上下文准备和模型调用进度
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={`${step.title}-${index}`}
|
||||
className={`rounded-xl border px-3 py-3 ${
|
||||
isDark ? 'border-slate-700 bg-slate-900/40' : 'border-gray-100 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-sm font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
{step.title}
|
||||
</div>
|
||||
<div className={`mt-1 whitespace-pre-wrap text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
|
||||
{step.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{showThinkingSection && (
|
||||
<Collapsible
|
||||
defaultOpen
|
||||
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={`flex h-9 w-9 items-center justify-center rounded-2xl ${
|
||||
isDark ? 'bg-violet-500/15 text-violet-300' : 'bg-orange-50 text-orange-500'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
思考过程
|
||||
</div>
|
||||
{thinkingComplete ? (
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
思考已完成,可展开查看
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`qa-generating-marquee text-xs ${
|
||||
isDark ? 'qa-generating-marquee-dark text-slate-300' : 'qa-generating-marquee-light text-gray-600'
|
||||
}`}
|
||||
>
|
||||
正在生成思考内容...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{thinkingMarkdown ? (
|
||||
<MarkdownRenderer
|
||||
content={thinkingMarkdown}
|
||||
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
|
||||
/>
|
||||
) : (
|
||||
<div className={`flex items-center gap-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
等待模型输出思考内容...
|
||||
</div>
|
||||
)}
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{answerMarkdown ? (
|
||||
<div
|
||||
className={`rounded-2xl border p-4 ${
|
||||
isDark ? 'border-slate-600/40 bg-slate-700/60' : 'border-gray-100 bg-white'
|
||||
}`}
|
||||
>
|
||||
<MarkdownRenderer
|
||||
content={answerMarkdown}
|
||||
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface MessageRowProps {
|
||||
msg: AgentChatMsg;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
const MessageRow: React.FC<MessageRowProps> = ({ msg, streaming = false }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const isUser = msg.role === 'user';
|
||||
|
||||
const parsed = useMemo(() => {
|
||||
if (isUser) return null;
|
||||
return parseAgentResponse(msg.content);
|
||||
}, [msg.content, isUser]);
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex items-end justify-end gap-3">
|
||||
<div
|
||||
className={`max-w-[72%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm ${
|
||||
isDark
|
||||
? 'bg-blue-600/70 text-white'
|
||||
: 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap leading-relaxed">{msg.content}</p>
|
||||
</div>
|
||||
<AgentAvatar role="user" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<AgentAvatar role="assistant" isActive={streaming} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{parsed ? (
|
||||
<AssistantAnswer
|
||||
steps={parsed.steps}
|
||||
thinkingMarkdown={parsed.thinkingMarkdown}
|
||||
answerMarkdown={parsed.answerMarkdown}
|
||||
thinkingComplete={parsed.thinkingComplete && !streaming}
|
||||
streaming={streaming}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`rounded-2xl border p-4 text-sm ${
|
||||
isDark
|
||||
? 'border-slate-600/40 bg-slate-700/60 text-slate-200'
|
||||
: 'border-gray-100 bg-white text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AgentChatMessageListProps {
|
||||
messages: AgentChatMsg[];
|
||||
streamingMsgId?: string | null;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
const AgentChatMessageList: React.FC<AgentChatMessageListProps> = ({
|
||||
messages,
|
||||
streamingMsgId,
|
||||
agentName,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full flex-col items-center justify-center text-center ${
|
||||
isDark ? 'text-slate-500' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<Bot className="mb-3 h-10 w-10" />
|
||||
<p className="text-sm">直接与 {agentName || 'MCP 智能体'} 对话以测试当前配置</p>
|
||||
<p className="mt-1 text-xs">已绑定技能会在步骤区显示实际执行过程</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{messages.map((msg, idx) => {
|
||||
const isLast = idx === messages.length - 1;
|
||||
const isStreaming = isLast && msg.role === 'assistant' && msg.id === streamingMsgId;
|
||||
return <MessageRow key={msg.id} msg={msg} streaming={isStreaming} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentChatMessageList;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,248 @@
|
||||
import React from 'react';
|
||||
import { Check, Plus, RefreshCcw, Sparkles, UserSearch } from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { InlineCollapsible, StageHeader, ThinkingBlock } from './SixStepCards';
|
||||
import type { ExpertCard, ExpertMatchData } from './types';
|
||||
|
||||
interface ExpertCardItemProps {
|
||||
card: ExpertCard;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
const renderAvatar = (avatar: string | undefined, fallback: React.ReactNode, isDark: boolean) => {
|
||||
if (avatar && avatar.trim().startsWith('<svg')) {
|
||||
return (
|
||||
<span
|
||||
className={`flex h-10 w-10 items-center justify-center overflow-hidden rounded-2xl ${
|
||||
isDark ? 'bg-slate-700/40' : 'bg-white shadow-sm'
|
||||
}`}
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: avatar }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${
|
||||
isDark ? 'bg-cyan-500/20 text-cyan-200' : 'bg-red-50 text-red-500'
|
||||
}`}
|
||||
>
|
||||
{fallback}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const ExpertCardItem: React.FC<ExpertCardItemProps> = ({ card, index, selected, disabled, onSelect }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const baseBorder = selected
|
||||
? isDark
|
||||
? 'border-cyan-300/60 ring-1 ring-cyan-300/40 shadow-lg shadow-cyan-500/10 bg-gradient-to-br from-slate-800/70 to-cyan-900/30'
|
||||
: 'border-red-300 ring-1 ring-red-200 shadow-md shadow-red-100 bg-gradient-to-br from-white to-red-50/60'
|
||||
: isDark
|
||||
? 'border-slate-700/60 bg-gradient-to-br from-slate-800/40 to-slate-900/20 hover:border-cyan-300/40'
|
||||
: 'border-gray-100 bg-white hover:border-red-200';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(card.id)}
|
||||
className={`w-full text-left rounded-2xl border p-3 transition-all sixstep-fade-up disabled:cursor-not-allowed disabled:opacity-60 ${baseBorder}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={`w-7 h-7 rounded-xl border flex items-center justify-center text-xs font-semibold shrink-0 ${
|
||||
isDark
|
||||
? 'bg-cyan-500/20 text-cyan-200 border-cyan-400/30'
|
||||
: 'bg-red-50 text-red-600 border-red-200'
|
||||
}`}
|
||||
>
|
||||
{index.toString().padStart(2, '0')}
|
||||
</span>
|
||||
{renderAvatar(card.avatarUrl, <UserSearch className="w-5 h-5" />, isDark)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`text-sm font-semibold leading-snug truncate ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{card.name}
|
||||
</div>
|
||||
{card.isCustom && (
|
||||
<span
|
||||
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-violet-500/20 text-violet-200' : 'bg-violet-50 text-violet-600'
|
||||
}`}
|
||||
>
|
||||
新建
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-xs mt-1 leading-relaxed line-clamp-3 ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
|
||||
{card.detail || '暂无描述'}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 flex h-6 w-6 items-center justify-center rounded-full border transition ${
|
||||
selected
|
||||
? isDark
|
||||
? 'border-cyan-300 bg-cyan-400/30 text-cyan-100'
|
||||
: 'border-red-400 bg-red-500 text-white'
|
||||
: isDark
|
||||
? 'border-slate-600 text-transparent'
|
||||
: 'border-gray-300 text-transparent'
|
||||
}`}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface ExpertMatchCardsProps {
|
||||
data: ExpertMatchData;
|
||||
onSelectExpert: (id: string) => void;
|
||||
onRefetch: () => void;
|
||||
onCreateExpert: () => void;
|
||||
onConfirm: () => void;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
const ExpertMatchCards: React.FC<ExpertMatchCardsProps> = ({
|
||||
data,
|
||||
onSelectExpert,
|
||||
onRefetch,
|
||||
onCreateExpert,
|
||||
onConfirm,
|
||||
locked,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const isSearching = data.status === 'searching';
|
||||
const isCreating = data.status === 'creating';
|
||||
const isReady = data.status === 'ready' || data.status === 'confirmed';
|
||||
const cards = data.experts ?? [];
|
||||
|
||||
return (
|
||||
<InlineCollapsible
|
||||
defaultOpen
|
||||
title={
|
||||
<StageHeader
|
||||
title="专家匹配"
|
||||
desc={
|
||||
isSearching
|
||||
? cards.length > 0
|
||||
? '专家已匹配完成,可选择一位进入下一步'
|
||||
: '正在调用查找专家 MCP...'
|
||||
: isCreating
|
||||
? '正在调用创建专家 MCP...'
|
||||
: data.status === 'error'
|
||||
? data.errorMessage || '专家匹配失败'
|
||||
: '请选择一位专家进入下一步'
|
||||
}
|
||||
count={cards.length}
|
||||
loading={(isSearching && cards.length === 0) || isCreating}
|
||||
done={data.status === 'confirmed'}
|
||||
icon={UserSearch}
|
||||
accent="actor"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ThinkingBlock text={data.thinking} loading={isSearching && cards.length === 0 && !data.thinking} />
|
||||
|
||||
<InlineCollapsible
|
||||
variant="subtle"
|
||||
defaultOpen
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">推荐专家列表</span>
|
||||
<span className="text-[10px] opacity-70">{cards.length} 位</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{cards.length === 0 ? (
|
||||
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
{isSearching && cards.length === 0 ? '正在匹配专家...' : '暂无匹配的专家'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{cards.map((card, idx) => (
|
||||
<ExpertCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
index={idx + 1}
|
||||
selected={data.selectedExpertId === card.id}
|
||||
disabled={locked}
|
||||
onSelect={onSelectExpert}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</InlineCollapsible>
|
||||
|
||||
{data.status === 'error' && (
|
||||
<div className={`rounded-xl border px-3 py-2 text-xs ${
|
||||
isDark ? 'border-rose-500/30 bg-rose-500/10 text-rose-200' : 'border-rose-200 bg-rose-50 text-rose-700'
|
||||
}`}>
|
||||
{data.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefetch}
|
||||
disabled={locked || isSearching || isCreating}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
isDark
|
||||
? 'border-slate-600 bg-slate-800/60 text-slate-200 hover:bg-slate-700'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<RefreshCcw className={`w-3.5 h-3.5 ${isSearching ? 'animate-spin' : ''}`} />
|
||||
重新查找
|
||||
</button>
|
||||
|
||||
<div className={`flex-1 text-right text-[11px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
没有想要的专家?
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateExpert}
|
||||
disabled={locked || isCreating || isSearching}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
isDark
|
||||
? 'border-violet-400/40 bg-violet-500/15 text-violet-100 hover:bg-violet-500/25'
|
||||
: 'border-violet-200 bg-violet-50 text-violet-700 hover:bg-violet-100'
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
创建专家
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={locked || !data.selectedExpertId || !isReady}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs font-semibold text-white transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400'
|
||||
: 'bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-400 hover:to-rose-400'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
选择该专家
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</InlineCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpertMatchCards;
|
||||
@@ -0,0 +1,846 @@
|
||||
# 前端意图识别与交互集成 — 实现指南
|
||||
|
||||
> 对应后端系统:`backend/app/intent/` + `backend/app/orchestrator.py`
|
||||
> 前端主文件:`SixStepChatPage.tsx`, `api.ts`, `types.ts`, `SixStepConversation.tsx`
|
||||
> 完成度:**全部 6 步 + follow_up ✅**
|
||||
|
||||
---
|
||||
|
||||
## 一、核心概念回顾
|
||||
|
||||
### 意图体系(Intent System)
|
||||
|
||||
前端负责**传递上下文** + **处理 SSE 事件流** + **更新 UI 状态**,后端负责**识别意图** + **分支路由** + **执行业务**。
|
||||
|
||||
```
|
||||
前端按需收集当前状态信息
|
||||
↓
|
||||
打包到 IntentOrchestrateRequest
|
||||
↓
|
||||
POST /orchestrate-stream (SSE)
|
||||
↓
|
||||
后端 detect_intent() 识别意图
|
||||
↓
|
||||
根据意图分支,emit SSE 事件
|
||||
↓
|
||||
前端处理事件,同步更新 message 和 phase
|
||||
```
|
||||
|
||||
### 七种意图与前端对应
|
||||
|
||||
| 意图 | 触发词 | 后端处理 | 前端转换 | 最终状态 |
|
||||
|------|-------|---------|---------|---------|
|
||||
| **find_expert** | "找专家" | find_expert MCP | phase: idle → expert-match | 显示专家卡片 + MCP 选择弹窗 |
|
||||
| **reselect_expert** | "换专家" | find_expert MCP + 过滤 | phase: expert-confirm → expert-match | 显示新的专家卡片 |
|
||||
| **start_analysis** | "分析" "开始" | 全新 6bf | phase: expert-confirm → generate | 显示 6bf 阶段卡片和流程图 |
|
||||
| **restart_analysis** | "重新分析" | 清空+重新执行 | phase: completed → generate | 同上 |
|
||||
| **continue_analysis** | "继续" (在 paused 时) | 从中断点恢复 | phase: paused → generate | 显示 resume 提示 + 继续的卡片 |
|
||||
| **follow_up** | "为什么" "解释" (在 completed 时) | LLM 回答 | phase: completed → follow-up | Markdown 渲染追问回答 |
|
||||
| **unknown** | 含糊/歧义 | 请求澄清 | phase: * → clarifying | 显示澄清问题 |
|
||||
|
||||
---
|
||||
|
||||
## 二、数据模型层(types.ts)
|
||||
|
||||
### 2.1 SixStepPhase 枚举
|
||||
|
||||
```typescript
|
||||
export type SixStepPhase =
|
||||
| 'idle' // 【初始】未开始任何操作
|
||||
| 'intent-detecting' // 【过渡】意图正在识别中(通常很短,用户看不到)
|
||||
| 'expert-match' // 【等待】展示专家卡片,用户可浏览、搜索、重选
|
||||
| 'expert-confirm' // 【交互】MCP 多选弹窗打开,用户确认选择
|
||||
| 'generate' // 【处理】6bf 编排执行中,显示进度和卡片
|
||||
| 'paused' // 【中断】用户手动停止;可继续或重新分析
|
||||
| 'completed' // 【完成】编排全部完成;可追问或重新分析
|
||||
| 'clarifying' // 【澄清】后端无法理解,请用户补充
|
||||
| 'follow-up'; // 【追问】正在基于已有分析回答用户问题
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- `expert-match` vs `expert-confirm` 的区别:前者是**显示结果**,后者是**交互确认**(MCP 弹窗)
|
||||
- `paused` **必须**在 `handleStop()` 手动设置,后端的 continue_analysis 分支不会主动发送 paused
|
||||
- `follow-up` 仅在 `current_phase === 'completed'` 且用户输入匹配追问模式时触发
|
||||
|
||||
### 2.2 SixStepMessage 新增字段
|
||||
|
||||
```typescript
|
||||
export interface SixStepMessage {
|
||||
// ... 原有字段 (id, role, content, timestamp, stages, finalAnswer, ...)
|
||||
|
||||
// ========== 意图识别相关 ==========
|
||||
intent?: {
|
||||
name: string; // 识别的意图名(find_expert / follow_up / unknown 等)
|
||||
confidence?: number; // 置信度 [0.0, 1.0]
|
||||
reason?: string; // 识别理由(用于 debug/日志)
|
||||
};
|
||||
|
||||
// ========== 澄清问题 ==========
|
||||
clarifyQuestion?: string; // 后端提出的澄清问题(当 intent.name === "unknown")
|
||||
|
||||
// ========== 继续分析上下文 ==========
|
||||
/**
|
||||
* 原始步骤数据(来自后端 step_end 事件)。
|
||||
* 结构:BackendStepResult[] = { key, name, status, thinking, structured_outputs, markdown, references }
|
||||
* 用途:
|
||||
* - 当用户暂停(handleStop)时,保存这些数据
|
||||
* - 当用户"继续分析"时,传回后端作为 last_steps,后端可恢复上下文
|
||||
* - 当用户"追问"时,后端用这些数据构造 context,生成更准确的回答
|
||||
*/
|
||||
rawSteps?: BackendStepResult[];
|
||||
|
||||
/**
|
||||
* 本轮是从哪个步骤继续的(来自后端 resume 事件)。
|
||||
* 值:step key(如 "behavior_factor")或 step name(如 "预期行为与驱动因素")
|
||||
* UI 用途:在 AssistantMessage 上方显示"⏸ 从 [步骤名] 继续"提示
|
||||
*/
|
||||
resumedFrom?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 IntentOrchestrateRequest(api.ts)
|
||||
|
||||
```typescript
|
||||
export interface IntentOrchestrateRequest {
|
||||
// 意图识别相关
|
||||
intent_mode?: 'auto' | 'force_orchestrate' | 'force_find_expert';
|
||||
|
||||
// 会话状态(前端告知后端)
|
||||
current_phase: string;
|
||||
selected_expert?: ExpertCard | null;
|
||||
selected_mcps?: SelectableMcpKey[];
|
||||
|
||||
// 继续分析相关
|
||||
last_steps?: BackendStepResult[];
|
||||
resume_from_step?: string;
|
||||
expert_candidates?: ExpertCard[]; // 用于 reselect_expert 时过滤
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、流处理层(api.ts)
|
||||
|
||||
### 3.1 runIntentOrchestrateStream() — 统一 SSE 处理器
|
||||
|
||||
```typescript
|
||||
export async function runIntentOrchestrateStream(
|
||||
message: string,
|
||||
options: {
|
||||
onUpdate?: (msg: SixStepMessage) => void;
|
||||
onComplete?: () => void;
|
||||
abortSignal?: AbortSignal;
|
||||
},
|
||||
requestBody?: IntentOrchestrateRequest
|
||||
): Promise<SixStepStreamController>
|
||||
```
|
||||
|
||||
**内部逻辑概述**:
|
||||
|
||||
```
|
||||
1. 发起 POST /orchestrate-stream(带 requestBody 和 AbortSignal)
|
||||
2. 遍历 SSE 事件流,解析 JSON
|
||||
3. 根据事件名分发到不同处理函数
|
||||
4. 每次修改 message 后调用 onUpdate(message),触发前端 re-render
|
||||
5. 接收 done 或 error 事件时停止
|
||||
```
|
||||
|
||||
### 3.2 关键事件处理函数
|
||||
|
||||
#### **intent 事件**
|
||||
|
||||
```typescript
|
||||
// 来自后端:{ intent: "follow_up", confidence: 0.88, reason: "..." }
|
||||
|
||||
case 'intent':
|
||||
const intentName = data.intent;
|
||||
message.intent = { name: intentName, ...data };
|
||||
|
||||
// 👇 根据意图确定流程分支
|
||||
if (intentName === 'find_expert' || intentName === 'reselect_expert') {
|
||||
flow = 'expert'; // 接下来期待 expert_start/expert_end
|
||||
} else if (intentName === 'follow_up') {
|
||||
flow = 'follow_up'; // 接下来期待 delta(文本块)
|
||||
message.phase = 'follow-up';
|
||||
message.outputFormat = 'markdown';
|
||||
} else if (intentName === 'unknown') {
|
||||
flow = 'clarify'; // 接下来期待 clarify 事件
|
||||
} else {
|
||||
// start_analysis / restart_analysis / continue_analysis
|
||||
flow = 'generate'; // 接下来期待 start / step_start / step_end
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
**前端的 flow 变量**(内部状态,不持久化):用于判断预期的**下一个事件类型**和**数据处理方式**。
|
||||
|
||||
#### **expert_start / expert_end 事件**
|
||||
|
||||
```typescript
|
||||
case 'expert_start':
|
||||
message.phase = 'expert-match';
|
||||
message.expertMatch = {
|
||||
status: 'searching',
|
||||
thinking: '',
|
||||
experts: [],
|
||||
selectedExpertId: undefined,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'expert_end':
|
||||
// data = { experts: [...], thinking: str, is_reselect: bool, ... }
|
||||
message.expertMatch!.status = 'ready';
|
||||
message.expertMatch!.thinking = data.thinking;
|
||||
|
||||
// 逐张卡片追加,配合 pop 动画
|
||||
for (const expert of data.experts) {
|
||||
message.expertMatch!.experts.push({
|
||||
id: expert.id,
|
||||
name: expert.name,
|
||||
detail: expert.detail,
|
||||
...
|
||||
});
|
||||
// 每张卡片之间延迟 100ms,触发 pop 动画
|
||||
}
|
||||
|
||||
// 卡片全部加载后,更新 phase 并打开 MCP 选择弹窗
|
||||
message.phase = 'expert-confirm';
|
||||
// → SixStepChatPage 监听 expertMatch.status === 'ready',打开 mcpModalOpen
|
||||
break;
|
||||
```
|
||||
|
||||
#### **clarify 事件**
|
||||
|
||||
```typescript
|
||||
case 'clarify':
|
||||
message.phase = 'clarifying';
|
||||
message.clarifyQuestion = data.message; // "我没有完全理解你的意图,请补充说明..."
|
||||
break;
|
||||
```
|
||||
|
||||
#### **resume 事件**(continue_analysis 特有)
|
||||
|
||||
```typescript
|
||||
case 'resume':
|
||||
// data = { task_id, resume_from, mcp_list: [...] }
|
||||
message.resumedFrom = data.resume_from; // 后端告诉前端从哪个步骤继续
|
||||
|
||||
// UI 用途:在 AssistantMessage 顶部显示 "⏸ 从 [步骤名] 继续"
|
||||
// 该变量会保留在消息中,整个 continue 过程结束后仍显示
|
||||
break;
|
||||
```
|
||||
|
||||
#### **start 事件**(开始 6bf 编排)
|
||||
|
||||
```typescript
|
||||
case 'start':
|
||||
message.phase = 'generate';
|
||||
// data = { task_id, mcp_list: [...], ts: number }
|
||||
|
||||
// 👇 防止 restart_analysis 时保留旧卡片
|
||||
if (!isResume) { // isResume 由 resume 事件设置,continue_analysis 时为 true
|
||||
message.stages = {}; // 清空
|
||||
message.subStepStatus = {};
|
||||
message.mainStepStatus = { info: 'done', sixstep: 'pending', final: 'pending' };
|
||||
}
|
||||
// 若 isResume === true(continue_analysis),则保留已有的 stages,只追加新步骤
|
||||
|
||||
message.currentMainStep = 'sixstep';
|
||||
break;
|
||||
```
|
||||
|
||||
**关键**:`!isResume` 的条件确保 **restart_analysis 时清空旧卡片**,而 **continue_analysis 时保留已有卡片**。
|
||||
|
||||
#### **step_start / step_end 事件**
|
||||
|
||||
```typescript
|
||||
case 'step_start':
|
||||
// data = { name: "目的分析", key: "purpose" }
|
||||
const stepKey = data.key;
|
||||
if (stepKey === 'purpose') {
|
||||
message.subStepStatus['purpose'] = 'running';
|
||||
message.currentSubStep = 'purpose';
|
||||
message.stages!.purpose = { thinking: '', cards: [], complete: false };
|
||||
}
|
||||
// ... 类似处理 actor / behavior / narrative
|
||||
break;
|
||||
|
||||
case 'step_end':
|
||||
// data = { key, status, thinking, structured_outputs, markdown, references, ... }
|
||||
const stepKey = data.key;
|
||||
|
||||
// 👇 原始数据存档,用于后续 continue_analysis / follow_up
|
||||
if (stepKey !== 'info_prepare') {
|
||||
message.rawSteps = message.rawSteps || [];
|
||||
message.rawSteps.push(data); // 保存完整的 BackendStepResult
|
||||
}
|
||||
|
||||
// 更新当前消息的对应阶段
|
||||
if (stepKey === 'purpose') {
|
||||
message.stages!.purpose!.thinking = data.thinking;
|
||||
message.stages!.purpose!.cards = buildPurposeCards(data.structured_outputs);
|
||||
message.stages!.purpose!.complete = true;
|
||||
message.subStepStatus['purpose'] = 'done';
|
||||
}
|
||||
// ... 类似处理 actor / behavior / narrative
|
||||
|
||||
break;
|
||||
```
|
||||
|
||||
#### **delta 事件**(follow_up 专有)
|
||||
|
||||
```typescript
|
||||
case 'delta':
|
||||
// data = { text: "..." }(文本块)
|
||||
// 仅当 flow === 'follow_up' 时处理
|
||||
|
||||
if (flow === 'follow_up') {
|
||||
message.markdownContent = (message.markdownContent || '') + data.text;
|
||||
onUpdate(message); // 逐块更新前端,流式显示
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
#### **done 事件**
|
||||
|
||||
```typescript
|
||||
case 'done':
|
||||
// 各分支的终止信号
|
||||
|
||||
if (flow === 'follow_up') {
|
||||
message.phase = 'completed'; // ✅ follow_up 回答完成
|
||||
} else if (flow === 'expert') {
|
||||
// 专家查询完成,等待前端用户在弹窗中确认选择
|
||||
// phase 保持 'expert-confirm'
|
||||
} else if (flow === 'generate') {
|
||||
message.phase = 'completed'; // ✅ 6bf 推演完成
|
||||
} else if (flow === 'clarify') {
|
||||
// 保持 'clarifying',等待用户重新输入
|
||||
}
|
||||
|
||||
onComplete?.(); // 回调:请求完成
|
||||
break;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、主页面逻辑(SixStepChatPage.tsx)
|
||||
|
||||
### 4.1 新增状态变量
|
||||
|
||||
```typescript
|
||||
// 当前阶段(驱动 UI 和后端意图识别)
|
||||
const [currentPhase, setCurrentPhase] = useState<SixStepPhase>('idle');
|
||||
|
||||
// 已选专家和 MCP(用于请求体和 UI 展示)
|
||||
const [selectedExpert, setSelectedExpert] = useState<ExpertCard | null>(null);
|
||||
const [selectedMcps, setSelectedMcps] = useState<SelectableMcpKey[]>([]);
|
||||
|
||||
// 已推荐过的专家列表(reselect_expert 时用于过滤)
|
||||
const [expertCandidates, setExpertCandidates] = useState<ExpertCard[]>([]);
|
||||
|
||||
// 临时保存原始用户输入(确认专家后需要)
|
||||
const [lastUserInput, setLastUserInput] = useState<string>('');
|
||||
```
|
||||
|
||||
### 4.2 handleSend() — 请求体构造
|
||||
|
||||
```typescript
|
||||
async function handleSend(inputText: string) {
|
||||
setLastUserInput(inputText); // 保存原始输入
|
||||
|
||||
// 【1】获取上次的助手消息(用于获取 rawSteps)
|
||||
const lastAssistant = messages.findLast(m => m.role === 'assistant');
|
||||
|
||||
// 【2】构造请求体(关键!传递上下文给后端)
|
||||
const requestBody: IntentOrchestrateRequest = {
|
||||
intent_mode: 'auto', // 自动意图识别
|
||||
current_phase: currentPhase,
|
||||
selected_expert: selectedExpert,
|
||||
selected_mcps: selectedMcps,
|
||||
last_steps: lastAssistant?.rawSteps || [], // 👈 用于 continue / follow_up
|
||||
resume_from_step: undefined, // 让后端自动推断
|
||||
expert_candidates: expertCandidates, // 👈 用于 reselect_expert 过滤
|
||||
};
|
||||
|
||||
// 【3】调用流处理
|
||||
const controller = await runIntentOrchestrateStream(
|
||||
inputText,
|
||||
{
|
||||
onUpdate: (msg) => {
|
||||
// 替换消息列表中的末尾消息(正在生成的消息)
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev];
|
||||
newMessages[newMessages.length - 1] = msg;
|
||||
return newMessages;
|
||||
});
|
||||
|
||||
// 同步更新当前阶段
|
||||
setCurrentPhase(msg.phase || 'idle');
|
||||
|
||||
// 【关键】监听专家卡片加载完成,打开 MCP 选择弹窗
|
||||
if (msg.expertMatch?.status === 'ready') {
|
||||
setMcpModalOpen(true);
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsGenerating(false);
|
||||
},
|
||||
},
|
||||
requestBody
|
||||
);
|
||||
|
||||
controllerRef.current = controller;
|
||||
setIsGenerating(true);
|
||||
}
|
||||
```
|
||||
|
||||
**核心三步**:
|
||||
1. **构造请求体**:携带所有必要的上下文(current_phase, last_steps, expert_candidates)
|
||||
2. **调用流处理**:传入 onUpdate 回调,每个 SSE 事件都更新前端
|
||||
3. **监听事件**:特别是 expertMatch.status === 'ready',自动打开 MCP 弹窗
|
||||
|
||||
### 4.3 handleMcpModalConfirm() — 确认专家后立即启动分析
|
||||
|
||||
```typescript
|
||||
function handleMcpModalConfirm(selectedExpert: ExpertCard, selectedMcpList: SelectableMcpKey[]) {
|
||||
// 【1】保存用户选择
|
||||
setSelectedExpert(selectedExpert);
|
||||
setSelectedMcps(selectedMcpList);
|
||||
setExpertCandidates(prev => [...prev, selectedExpert]); // 添加到候选列表
|
||||
|
||||
// 【2】立即用 force_orchestrate 启动分析(不需要等用户再次输入)
|
||||
const requestBody: IntentOrchestrateRequest = {
|
||||
intent_mode: 'force_orchestrate', // 👈 强制执行编排,不识别意图
|
||||
current_phase: 'expert-confirm',
|
||||
selected_expert: selectedExpert,
|
||||
selected_mcps: selectedMcpList,
|
||||
last_steps: [],
|
||||
};
|
||||
|
||||
// 【3】使用保存的原始输入(用户之前在输入框里输入的文本)
|
||||
runIntentOrchestrateStream(
|
||||
lastUserInput, // 👈 原始输入
|
||||
{
|
||||
onUpdate: (msg) => {
|
||||
setMessages(prev => [...prev.slice(0, -1), msg]);
|
||||
setCurrentPhase(msg.phase || 'idle');
|
||||
},
|
||||
onComplete: () => setIsGenerating(false),
|
||||
},
|
||||
requestBody
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- `intent_mode: 'force_orchestrate'` — 跳过意图识别,直接执行 6bf
|
||||
- 使用 `lastUserInput`(保存的原始输入)而非动态输入
|
||||
- 添加 selectedExpert 到 expertCandidates,以便后续 reselect 时过滤
|
||||
|
||||
### 4.4 handleRefetchExpert() — 重选专家
|
||||
|
||||
```typescript
|
||||
function handleRefetchExpert() {
|
||||
const requestBody: IntentOrchestrateRequest = {
|
||||
intent_mode: 'force_find_expert', // 👈 强制查询专家
|
||||
current_phase: 'expert-confirm',
|
||||
expert_candidates: expertCandidates, // 👈 告诉后端已有候选,避免重复推荐
|
||||
last_steps: [],
|
||||
};
|
||||
|
||||
runIntentOrchestrateStream(
|
||||
lastUserInput,
|
||||
{
|
||||
onUpdate: (msg) => {
|
||||
setMessages(prev => [...prev.slice(0, -1), msg]);
|
||||
setCurrentPhase(msg.phase || 'idle');
|
||||
// MCP 弹窗保持打开,等待用户重新选择
|
||||
},
|
||||
},
|
||||
requestBody
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 handleStop() — 停止生成
|
||||
|
||||
```typescript
|
||||
function handleStop() {
|
||||
controllerRef.current?.stop();
|
||||
|
||||
// 👇 关键:标记当前消息为 paused,供后续 continue_analysis 识别
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev];
|
||||
const lastMsg = newMessages[newMessages.length - 1];
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
lastMsg.phase = 'paused';
|
||||
}
|
||||
return newMessages;
|
||||
});
|
||||
|
||||
setCurrentPhase('paused');
|
||||
setIsGenerating(false);
|
||||
}
|
||||
```
|
||||
|
||||
**为什么需要标记 paused?**
|
||||
- 后端的 intent 识别器会检查 `current_phase === 'paused'`,优先返回 `continue_analysis` 意图
|
||||
- 前端显示 "⏸ 从 [步骤] 继续" 提示,让用户清楚地知道可以继续
|
||||
|
||||
---
|
||||
|
||||
## 五、对话区渲染(SixStepConversation.tsx)
|
||||
|
||||
### 5.1 AssistantMessage 的新渲染逻辑
|
||||
|
||||
```typescript
|
||||
const AssistantMessage = ({ message, active }: Props) => {
|
||||
const isExpertPhase = message.phase === 'expert-match' || message.phase === 'expert-confirm';
|
||||
const isFollowUp = message.phase === 'follow-up';
|
||||
const isClarifying = message.phase === 'clarifying';
|
||||
const isMarkdown = message.outputFormat === 'markdown';
|
||||
|
||||
// 👇 【防护条件】防止旧分析内容在专家/澄清/追问阶段出现
|
||||
const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 【分支 1】专家匹配阶段 */}
|
||||
{isExpertPhase && message.expertMatch && (
|
||||
<ExpertMatchCards
|
||||
data={message.expertMatch}
|
||||
onRefetch={onRefetch}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 【分支 2】6bf 分析阶段(防护条件) */}
|
||||
{showAnalysisContent && (
|
||||
<>
|
||||
{/* 继续分析提示 */}
|
||||
{message.resumedFrom && (
|
||||
<div className="resume-hint">
|
||||
⏸ 从「{message.resumedFrom}」继续
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 各阶段卡片 */}
|
||||
{message.stages?.info && <InfoStage ... />}
|
||||
{message.stages?.purpose && <PurposeStage ... />}
|
||||
{message.stages?.actor && <ActorStage ... />}
|
||||
{message.stages?.behavior && <BehaviorStage ... />}
|
||||
{message.stages?.narrative && <NarrativeStage ... />}
|
||||
{message.finalAnswer && <FinalAnswer ... />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 【分支 3】追问回答(Markdown) */}
|
||||
{isFollowUp && message.markdownContent && (
|
||||
<MarkdownRenderer content={message.markdownContent} />
|
||||
)}
|
||||
|
||||
{/* 【分支 4】Markdown 输出格式 */}
|
||||
{isMarkdown && !isFollowUp && message.markdownContent && (
|
||||
<MarkdownRenderer content={message.markdownContent} />
|
||||
)}
|
||||
|
||||
{/* 【分支 5】澄清问题 */}
|
||||
{isClarifying && message.clarifyQuestion && (
|
||||
<div className="clarify-block">
|
||||
<p className="text-sm text-gray-600">我需要你进一步说明:</p>
|
||||
<p className="font-medium">{message.clarifyQuestion}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">请重新输入以补充信息</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**关键防护条件**:
|
||||
|
||||
```typescript
|
||||
const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;
|
||||
```
|
||||
|
||||
这一行确保了:
|
||||
- ✅ `expert-match` 阶段只显示专家卡片,不显示旧分析
|
||||
- ✅ `clarifying` 阶段只显示澄清问题,不显示其他内容
|
||||
- ✅ `follow-up` 阶段只显示 Markdown 回答,不显示旧分析
|
||||
|
||||
### 5.2 阶段加载动画
|
||||
|
||||
```typescript
|
||||
// 当前阶段正在生成时,显示 shimmer(流光)动画
|
||||
const purposeGenerating = message.phase === 'generate' &&
|
||||
message.currentSubStep === 'purpose' &&
|
||||
active;
|
||||
|
||||
<StageHeader
|
||||
loading={purposeGenerating}
|
||||
icon={<Target ... />}
|
||||
title="目的分析"
|
||||
count={message.stages?.purpose?.cards.length || 0}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、生命周期示例
|
||||
|
||||
### 6.1 用户"找专家"的流程
|
||||
|
||||
```
|
||||
【用户输入】"帮我找个专家"
|
||||
↓
|
||||
【前端】handleSend("帮我找个专家")
|
||||
- requestBody: { intent_mode: 'auto', current_phase: 'idle', last_steps: [] }
|
||||
- 调用 runIntentOrchestrateStream
|
||||
↓
|
||||
【后端】detect_intent("帮我找个专家", current_phase='idle', ...)
|
||||
- 规则识别:匹配 _FIND_PATTERNS → confidence=0.88
|
||||
- 返回:IntentResult(intent='find_expert', ...)
|
||||
↓
|
||||
【后端】根据 intent='find_expert',调用 _stream_find_expert()
|
||||
- emit: expert_start
|
||||
- 查询 find_expert MCP agent
|
||||
- emit: expert_end { experts: [...] }
|
||||
- emit: done
|
||||
↓
|
||||
【前端】处理 SSE 事件
|
||||
- intent: { name: 'find_expert', ... }
|
||||
→ flow = 'expert'
|
||||
- expert_start
|
||||
→ message.phase = 'expert-match'
|
||||
→ message.expertMatch.status = 'searching'
|
||||
- expert_end
|
||||
→ message.expertMatch.status = 'ready'
|
||||
→ message.expertMatch.experts = [...]
|
||||
→ message.phase = 'expert-confirm'
|
||||
→ 检测到 expertMatch.status === 'ready'
|
||||
→ setMcpModalOpen(true) 打开 MCP 选择弹窗
|
||||
- done
|
||||
→ onComplete()
|
||||
↓
|
||||
【用户】在 MCP 选择弹窗中选择专家和 MCP,点击"确认"
|
||||
↓
|
||||
【前端】handleMcpModalConfirm(selectedExpert, selectedMcps)
|
||||
- setSelectedExpert(selectedExpert)
|
||||
- setSelectedMcps(selectedMcps)
|
||||
- requestBody: { intent_mode: 'force_orchestrate', ... }
|
||||
- 调用 runIntentOrchestrateStream(lastUserInput, ...)
|
||||
↓
|
||||
【后端】orchestrate_stream() with intent_mode='force_orchestrate'
|
||||
- 跳过意图识别
|
||||
- 调用 _stream_orchestrate(..., intent_name='start_analysis')
|
||||
- emit: start
|
||||
- emit: step_start / step_end × 4 (purpose / actor / behavior / narrative)
|
||||
- emit: flow { nodes, edges }
|
||||
- emit: done
|
||||
↓
|
||||
【前端】处理 SSE 事件
|
||||
- start
|
||||
→ message.phase = 'generate'
|
||||
→ 初始化 stages 和状态
|
||||
- step_start × 4
|
||||
→ 每个步骤的 status = 'running'
|
||||
→ 对应阶段初始化为空
|
||||
- step_end × 4
|
||||
→ 填充 stages[stepKey].cards
|
||||
→ message.rawSteps.push(data)
|
||||
→ status = 'done'
|
||||
- flow
|
||||
→ 流程图数据更新(供沙箱显示)
|
||||
- done
|
||||
→ message.phase = 'completed'
|
||||
↓
|
||||
【前端 UI】
|
||||
- ✅ 显示 4 个阶段的卡片
|
||||
- ✅ 显示流程图沙箱
|
||||
- ✅ 阶段完成时有 pop 动画和叙事卡片
|
||||
```
|
||||
|
||||
### 6.2 用户"继续分析"的流程
|
||||
|
||||
```
|
||||
【前次】完成了目的、行为体阶段,用户点击"停止"
|
||||
- 后端返回 step_end(预期行为/驱动因素)但用户取消
|
||||
- message.phase = 'paused'
|
||||
- message.rawSteps = [
|
||||
{ key: 'purpose', status: 'completed', ... },
|
||||
{ key: 'actor', status: 'completed', ... },
|
||||
{ key: 'behavior_factor', status: 'running', ... }
|
||||
]
|
||||
↓
|
||||
【用户输入】"继续"
|
||||
↓
|
||||
【前端】handleSend("继续")
|
||||
- requestBody: {
|
||||
intent_mode: 'auto',
|
||||
current_phase: 'paused', // 👈 关键
|
||||
last_steps: message.rawSteps, // 👈 关键
|
||||
}
|
||||
↓
|
||||
【后端】detect_intent("继续", current_phase='paused', last_steps=[...])
|
||||
- 规则识别:匹配 _CONTINUE_PATTERNS AND current_phase='paused'
|
||||
- 返回:IntentResult(intent='continue_analysis', confidence=0.90)
|
||||
↓
|
||||
【后端】_stream_orchestrate(..., intent_name='continue_analysis')
|
||||
- _resume_context_from_steps(req.last_steps, None)
|
||||
→ 检查已完成的步骤:['purpose', 'actor']
|
||||
→ resume_key = 'behavior_factor'(下一个未完成的)
|
||||
- emit: resume { resume_from: 'behavior_factor', mcp_list: ['behavior_factor', 'narrative'] }
|
||||
- emit: start { mcp_list: ['behavior_factor', 'narrative'] }
|
||||
- emit: step_start / step_end × 2 (behavior_factor, narrative)
|
||||
- emit: done
|
||||
↓
|
||||
【前端】处理 SSE 事件
|
||||
- intent: { name: 'continue_analysis', ... }
|
||||
- resume: { resume_from: 'behavior_factor' }
|
||||
→ message.resumedFrom = 'behavior_factor'
|
||||
→ isResume = true // 标记为继续,不清空旧 stages
|
||||
- start
|
||||
→ 因为 isResume=true,不清空 message.stages
|
||||
→ 保留已有的 purpose/actor 卡片
|
||||
- step_start / step_end × 2
|
||||
→ 追加新的 behavior/narrative 卡片
|
||||
→ message.rawSteps.push(behavior_data)
|
||||
→ message.rawSteps.push(narrative_data)
|
||||
- done
|
||||
→ message.phase = 'completed'
|
||||
↓
|
||||
【前端 UI】
|
||||
- ✅ 显示"⏸ 从「预期行为与驱动因素」继续"提示
|
||||
- ✅ 保留旧的目的/行为体卡片
|
||||
- ✅ 追加新的预期行为/叙事卡片
|
||||
```
|
||||
|
||||
### 6.3 用户"追问"的流程
|
||||
|
||||
```
|
||||
【前次】完成了完整的 6bf,message.phase = 'completed'
|
||||
- message.rawSteps = [
|
||||
{ key: 'purpose', ... },
|
||||
{ key: 'actor', ... },
|
||||
{ key: 'behavior_factor', ... },
|
||||
{ key: 'narrative', ... }
|
||||
]
|
||||
↓
|
||||
【用户输入】"第一条是什么意思"
|
||||
↓
|
||||
【前端】handleSend("第一条是什么意思")
|
||||
- requestBody: {
|
||||
intent_mode: 'auto',
|
||||
current_phase: 'completed', // 👈 关键
|
||||
last_steps: message.rawSteps,
|
||||
}
|
||||
↓
|
||||
【后端】detect_intent("第一条是什么意思", current_phase='completed', last_steps=[...])
|
||||
- 规则识别:匹配 _FOLLOW_UP_PATTERNS(r"第[一二三四五六七八九十\d]+[点条个项]")
|
||||
- 返回:IntentResult(intent='follow_up', confidence=0.88)
|
||||
↓
|
||||
【后端】_stream_follow_up(req, ...)
|
||||
- 提取 req.last_steps 中已完成步骤的数据
|
||||
- 构造 context_str(包含目的、行为体、预期行为、叙事)
|
||||
- 调用 LLM 流式回答
|
||||
- emit: delta { text: "..." } × N
|
||||
- emit: done
|
||||
↓
|
||||
【前端】处理 SSE 事件
|
||||
- intent: { name: 'follow_up', ... }
|
||||
→ flow = 'follow_up'
|
||||
→ message.phase = 'follow-up'
|
||||
→ message.outputFormat = 'markdown'
|
||||
- delta × N
|
||||
→ message.markdownContent += text
|
||||
→ 每块更新都触发 onUpdate,前端流式显示
|
||||
- done
|
||||
→ message.phase = 'completed'
|
||||
↓
|
||||
【前端 UI】
|
||||
- ✅ 显示 Markdown 格式的追问回答
|
||||
- ✅ 不显示旧的 6bf 卡片(因为 showAnalysisContent = false)
|
||||
- ✅ 流式效果:用户看到实时生成的文本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、常见问题解决
|
||||
|
||||
### Q1: 完成 6bf 后再选专家,为什么显示了旧的分析内容?
|
||||
|
||||
**原因**:AssistantMessage 中没有正确应用 `showAnalysisContent` 防护条件。
|
||||
|
||||
**检查**:
|
||||
```typescript
|
||||
const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;
|
||||
```
|
||||
|
||||
确保:
|
||||
- `isExpertPhase = message.phase === 'expert-match' || message.phase === 'expert-confirm'` ✓
|
||||
- 所有 `stages` / `finalAnswer` 的渲染都被 `{showAnalysisContent && (...)}` 包裹 ✓
|
||||
|
||||
### Q2: 继续分析后,旧卡片消失了
|
||||
|
||||
**原因**:SSE start 事件中清空了 stages(应该只在 !isResume 时清空)。
|
||||
|
||||
**检查** `api.ts` 中的 start 事件处理:
|
||||
```typescript
|
||||
case 'start':
|
||||
if (!isResume) { // 👈 这个条件必须有
|
||||
message.stages = {};
|
||||
}
|
||||
// ...
|
||||
break;
|
||||
```
|
||||
|
||||
### Q3: 用户点"继续",但后端返回了"开始新分析"
|
||||
|
||||
**原因**:`current_phase` 未传递为 'paused',或未手动设置 `phase = 'paused'`。
|
||||
|
||||
**检查** `SixStepChatPage.handleStop()`:
|
||||
```typescript
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev];
|
||||
const lastMsg = newMessages[newMessages.length - 1];
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
lastMsg.phase = 'paused'; // 👈 必须设置
|
||||
}
|
||||
return newMessages;
|
||||
});
|
||||
setCurrentPhase('paused'); // 👈 必须同步状态
|
||||
```
|
||||
|
||||
### Q4: 追问回答为空
|
||||
|
||||
**原因**:
|
||||
1. `current_phase !== 'completed'`(后端不识别为 follow_up)
|
||||
2. `last_steps` 为空或格式错误
|
||||
3. LLM 调用失败(查看后端日志)
|
||||
|
||||
**检查**:
|
||||
- 确保 message.rawSteps 在 step_end 时被正确赋值
|
||||
- 确保 request 中 `last_steps` 被传递为 `message.rawSteps`
|
||||
- 后端日志是否有 `_stream_follow_up` 的报错
|
||||
|
||||
---
|
||||
|
||||
## 八、开发建议
|
||||
|
||||
1. **调试技巧**:在 `onUpdate` 中 `console.log(message)` 观察每个事件的状态变化
|
||||
2. **SSE 事件顺序不可打破**:前端严格依赖事件顺序做状态机转换
|
||||
3. **rawSteps 的完整性**:非 info_prepare 的所有 step_end 都必须存档,否则 continue/follow_up 会丢失数据
|
||||
4. **phase 与 outputFormat 的对应**:
|
||||
- follow-up → outputFormat = 'markdown'
|
||||
- generate → outputFormat = 'structured'(卡片渲染)
|
||||
- expert-match → 无 outputFormat(专家卡片独立)
|
||||
|
||||
---
|
||||
|
||||
**相关文件导航**:
|
||||
- 后端意图系统:`backend/app/intent/`
|
||||
- 后端编排系统:`backend/app/orchestrator.py`
|
||||
- 前端核心:`SixStepChatPage.tsx` (state hub) → `api.ts` (SSE handler) → `SixStepConversation.tsx` (rendering)
|
||||
- 前端类型:`types.ts`
|
||||
- 前端原始文档:`doc.md`
|
||||
@@ -0,0 +1,233 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Megaphone, Target, Users, X, Zap } from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import type { ExpertCard, SelectableMcpKey } from './types';
|
||||
|
||||
interface McpOption {
|
||||
key: SelectableMcpKey;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
accent: 'purpose' | 'actor' | 'behavior' | 'narrative';
|
||||
}
|
||||
|
||||
const MCP_OPTIONS: McpOption[] = [
|
||||
{ key: 'purpose', name: '目的', description: '识别本次推演的核心目的,构成后续推理的锚点。', icon: Target, accent: 'purpose' },
|
||||
{ key: 'actor', name: '行为体', description: '推演支撑目的所需的关键行为体角色。', icon: Users, accent: 'actor' },
|
||||
{ key: 'behavior_factor', name: '预期行为和驱动因素', description: '为每个行为体推演预期行为及背后的驱动因素。', icon: Zap, accent: 'behavior' },
|
||||
{ key: 'narrative', name: '叙事', description: '基于驱动因素构建可被复用的叙事框架。', icon: Megaphone, accent: 'narrative' },
|
||||
];
|
||||
|
||||
const ACCENT_LIGHT: Record<McpOption['accent'], { selected: string; idle: string; chip: string }> = {
|
||||
purpose: {
|
||||
selected: 'border-orange-300 bg-orange-50/70 ring-1 ring-orange-200',
|
||||
idle: 'border-gray-200 bg-white hover:border-orange-200',
|
||||
chip: 'bg-orange-50 text-orange-600',
|
||||
},
|
||||
actor: {
|
||||
selected: 'border-red-300 bg-red-50/70 ring-1 ring-red-200',
|
||||
idle: 'border-gray-200 bg-white hover:border-red-200',
|
||||
chip: 'bg-red-50 text-red-600',
|
||||
},
|
||||
behavior: {
|
||||
selected: 'border-emerald-300 bg-emerald-50/70 ring-1 ring-emerald-200',
|
||||
idle: 'border-gray-200 bg-white hover:border-emerald-200',
|
||||
chip: 'bg-emerald-50 text-emerald-600',
|
||||
},
|
||||
narrative: {
|
||||
selected: 'border-amber-300 bg-amber-50/70 ring-1 ring-amber-200',
|
||||
idle: 'border-gray-200 bg-white hover:border-amber-200',
|
||||
chip: 'bg-amber-50 text-amber-600',
|
||||
},
|
||||
};
|
||||
|
||||
const ACCENT_DARK: Record<McpOption['accent'], { selected: string; idle: string; chip: string }> = {
|
||||
purpose: {
|
||||
selected: 'border-violet-300/50 bg-violet-500/15 ring-1 ring-violet-300/30',
|
||||
idle: 'border-slate-700 bg-slate-800/60 hover:border-violet-300/40',
|
||||
chip: 'bg-violet-500/20 text-violet-200',
|
||||
},
|
||||
actor: {
|
||||
selected: 'border-cyan-300/50 bg-cyan-500/15 ring-1 ring-cyan-300/30',
|
||||
idle: 'border-slate-700 bg-slate-800/60 hover:border-cyan-300/40',
|
||||
chip: 'bg-cyan-500/20 text-cyan-200',
|
||||
},
|
||||
behavior: {
|
||||
selected: 'border-emerald-300/50 bg-emerald-500/15 ring-1 ring-emerald-300/30',
|
||||
idle: 'border-slate-700 bg-slate-800/60 hover:border-emerald-300/40',
|
||||
chip: 'bg-emerald-500/20 text-emerald-200',
|
||||
},
|
||||
narrative: {
|
||||
selected: 'border-amber-300/50 bg-amber-500/15 ring-1 ring-amber-300/30',
|
||||
idle: 'border-slate-700 bg-slate-800/60 hover:border-amber-300/40',
|
||||
chip: 'bg-amber-500/20 text-amber-200',
|
||||
},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
expert: ExpertCard | null;
|
||||
initialSelected?: SelectableMcpKey[];
|
||||
onCancel: () => void;
|
||||
onConfirm: (selected: SelectableMcpKey[]) => void;
|
||||
}
|
||||
|
||||
const McpSelectModal: React.FC<Props> = ({ open, expert, initialSelected, onCancel, onConfirm }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const defaultAll = useMemo<SelectableMcpKey[]>(() => MCP_OPTIONS.map((m) => m.key), []);
|
||||
const [selected, setSelected] = useState<SelectableMcpKey[]>(
|
||||
initialSelected && initialSelected.length > 0 ? initialSelected : defaultAll,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected(initialSelected && initialSelected.length > 0 ? initialSelected : defaultAll);
|
||||
setError(null);
|
||||
}
|
||||
}, [defaultAll, initialSelected, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const toggle = (key: SelectableMcpKey) => {
|
||||
setSelected((prev) => {
|
||||
const has = prev.includes(key);
|
||||
if (has) {
|
||||
if (prev.length <= 1) {
|
||||
setError('至少选择一个生成步骤');
|
||||
return prev;
|
||||
}
|
||||
setError(null);
|
||||
return prev.filter((k) => k !== key);
|
||||
}
|
||||
setError(null);
|
||||
return [...prev, key];
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selected.length === 0) {
|
||||
setError('至少选择一个生成步骤');
|
||||
return;
|
||||
}
|
||||
const ordered = MCP_OPTIONS.map((m) => m.key).filter((k) => selected.includes(k));
|
||||
onConfirm(ordered);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center px-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
role="presentation"
|
||||
/>
|
||||
<div
|
||||
className={`relative z-10 w-full max-w-xl rounded-2xl border shadow-2xl ${
|
||||
isDark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-200 bg-white text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center justify-between border-b px-5 py-4 ${
|
||||
isDark ? 'border-slate-700' : 'border-gray-100'
|
||||
}`}>
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold">选择本次执行的 MCP</div>
|
||||
<div className={`text-xs mt-0.5 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{expert ? `专家:${expert.name}` : '默认全部勾选,至少保留 1 个步骤'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
|
||||
isDark ? 'text-slate-400 hover:bg-slate-800' : 'text-gray-400 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto px-5 py-4 space-y-2">
|
||||
{MCP_OPTIONS.map((opt) => {
|
||||
const isSelected = selected.includes(opt.key);
|
||||
const palette = isDark ? ACCENT_DARK[opt.accent] : ACCENT_LIGHT[opt.accent];
|
||||
const Icon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
onClick={() => toggle(opt.key)}
|
||||
className={`w-full text-left rounded-xl border px-3 py-3 transition ${
|
||||
isSelected ? palette.selected : palette.idle
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`flex h-9 w-9 items-center justify-center rounded-xl shrink-0 ${palette.chip}`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold">{opt.name}</div>
|
||||
<div className={`text-xs mt-0.5 leading-relaxed ${isDark ? 'text-slate-400' : 'text-gray-600'}`}>
|
||||
{opt.description}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 mt-0.5 flex h-5 w-5 items-center justify-center rounded-md border transition ${
|
||||
isSelected
|
||||
? isDark
|
||||
? 'border-cyan-300 bg-cyan-400/40 text-cyan-50'
|
||||
: 'border-red-400 bg-red-500 text-white'
|
||||
: isDark
|
||||
? 'border-slate-600 text-transparent'
|
||||
: 'border-gray-300 text-transparent'
|
||||
}`}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<div className={`text-xs ${isDark ? 'text-rose-300' : 'text-rose-600'}`}>{error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center justify-between gap-3 border-t px-5 py-3 ${
|
||||
isDark ? 'border-slate-700' : 'border-gray-100'
|
||||
}`}>
|
||||
<div className={`text-[11px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
后端固定执行顺序:目的 → 行为体 → 预期行为和驱动因素 → 叙事
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={`rounded-lg border px-3 py-1.5 text-sm font-medium transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm font-semibold text-white transition ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400'
|
||||
: 'bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-400 hover:to-rose-400'
|
||||
}`}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default McpSelectModal;
|
||||
@@ -0,0 +1,360 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
Compass,
|
||||
FileSearch,
|
||||
Megaphone,
|
||||
ScrollText,
|
||||
Sparkles,
|
||||
Target,
|
||||
TrendingUp,
|
||||
UserCheck,
|
||||
Users,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import type {
|
||||
ActorCard,
|
||||
BehaviorDriverCard,
|
||||
NarrativeCard,
|
||||
PurposeCard,
|
||||
} from './types';
|
||||
|
||||
interface CollapsibleProps {
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
variant?: 'default' | 'subtle';
|
||||
}
|
||||
|
||||
export const InlineCollapsible: React.FC<CollapsibleProps> = ({ title, children, defaultOpen = true, variant = 'default' }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [open, setOpen] = React.useState(defaultOpen);
|
||||
|
||||
const wrapperClass = variant === 'subtle'
|
||||
? `${isDark ? 'border-slate-700/50 bg-slate-900/30' : 'border-gray-100 bg-white/80'}`
|
||||
: `${isDark ? 'border-slate-700/60 bg-slate-800/40' : 'border-red-100 bg-white'}`;
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border overflow-hidden ${wrapperClass}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">{title}</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 shrink-0 transition-transform duration-300 ${
|
||||
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 pt-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CardIndexBadgeProps {
|
||||
index: number;
|
||||
variant: 'purpose' | 'actor' | 'behavior' | 'narrative';
|
||||
}
|
||||
|
||||
const VARIANT_COLOR: Record<CardIndexBadgeProps['variant'], { dark: string; light: string }> = {
|
||||
purpose: { dark: 'bg-violet-500/20 text-violet-200 border-violet-400/30', light: 'bg-orange-50 text-orange-600 border-orange-200' },
|
||||
actor: { dark: 'bg-cyan-500/20 text-cyan-200 border-cyan-400/30', light: 'bg-red-50 text-red-600 border-red-200' },
|
||||
behavior: { dark: 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30', light: 'bg-emerald-50 text-emerald-600 border-emerald-200' },
|
||||
narrative: { dark: 'bg-amber-500/20 text-amber-200 border-amber-400/30', light: 'bg-amber-50 text-amber-600 border-amber-200' },
|
||||
};
|
||||
|
||||
const CardIndexBadge: React.FC<CardIndexBadgeProps> = ({ index, variant }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const cls = VARIANT_COLOR[variant][isDark ? 'dark' : 'light'];
|
||||
return (
|
||||
<span className={`w-7 h-7 rounded-xl border flex items-center justify-center text-xs font-semibold shrink-0 ${cls}`}>
|
||||
{index.toString().padStart(2, '0')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const PurposeCardItem: React.FC<{ card: PurposeCard }> = ({ card }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<div className={`group rounded-2xl border p-3 transition-all sixstep-fade-up ${
|
||||
isDark
|
||||
? 'border-violet-400/20 bg-gradient-to-br from-slate-800/60 to-violet-900/20 hover:border-violet-300/40'
|
||||
: 'border-orange-100 bg-gradient-to-br from-white to-orange-50/40 hover:border-orange-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<CardIndexBadge index={card.index} variant="purpose" />
|
||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
|
||||
isDark ? 'bg-violet-500/20 text-violet-200' : 'bg-orange-50 text-orange-500'
|
||||
}`}>
|
||||
<Target className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{card.title}
|
||||
</div>
|
||||
<div className={`text-xs mt-1 leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
|
||||
<span className={`inline-flex items-center gap-1 mr-1 font-medium ${isDark ? 'text-violet-200' : 'text-orange-600'}`}>
|
||||
<Compass className="w-3 h-3" />理由
|
||||
</span>
|
||||
{card.reason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ActorCardItem: React.FC<{ card: ActorCard }> = ({ card }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<div className={`rounded-2xl border p-3 transition-all sixstep-fade-up ${
|
||||
isDark
|
||||
? 'border-cyan-400/20 bg-gradient-to-br from-slate-800/60 to-cyan-900/20 hover:border-cyan-300/40'
|
||||
: 'border-red-100 bg-gradient-to-br from-white to-red-50/40 hover:border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<CardIndexBadge index={card.index} variant="actor" />
|
||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
|
||||
isDark ? 'bg-cyan-500/20 text-cyan-200' : 'bg-red-50 text-red-500'
|
||||
}`}>
|
||||
<UserCheck className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{card.title}
|
||||
</div>
|
||||
<div className={`text-xs mt-1 leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
|
||||
{card.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BehaviorCardItem: React.FC<{ card: BehaviorDriverCard }> = ({ card }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<div className={`rounded-2xl border overflow-hidden transition-all sixstep-fade-up ${
|
||||
isDark
|
||||
? 'border-emerald-400/20 bg-gradient-to-br from-slate-800/60 to-emerald-900/20 hover:border-emerald-300/40'
|
||||
: 'border-emerald-100 bg-gradient-to-br from-white to-emerald-50/40 hover:border-emerald-200'
|
||||
}`}>
|
||||
<div className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<CardIndexBadge index={card.index} variant="behavior" />
|
||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
|
||||
isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-50 text-emerald-600'
|
||||
}`}>
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-[11px] uppercase tracking-wider font-medium mb-1 ${isDark ? 'text-emerald-300/80' : 'text-emerald-600'}`}>
|
||||
预期行为
|
||||
</div>
|
||||
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{card.expectedBehavior}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`border-t px-3 py-3 ${
|
||||
isDark ? 'border-emerald-400/15 bg-emerald-500/5' : 'border-emerald-100 bg-emerald-50/40'
|
||||
}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<Zap className={`w-3.5 h-3.5 mt-0.5 shrink-0 ${isDark ? 'text-emerald-300' : 'text-emerald-600'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-[11px] uppercase tracking-wider font-medium mb-1 ${isDark ? 'text-emerald-300/80' : 'text-emerald-600'}`}>
|
||||
驱动因素
|
||||
</div>
|
||||
<div className={`text-xs leading-relaxed ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
|
||||
{card.driver}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NarrativeCardItem: React.FC<{ card: NarrativeCard }> = ({ card }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<div className={`rounded-2xl border p-3 transition-all sixstep-fade-up ${
|
||||
isDark
|
||||
? 'border-amber-400/20 bg-gradient-to-br from-slate-800/60 to-amber-900/20 hover:border-amber-300/40'
|
||||
: 'border-amber-100 bg-gradient-to-br from-white to-amber-50/40 hover:border-amber-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<CardIndexBadge index={card.index} variant="narrative" />
|
||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
|
||||
isDark ? 'bg-amber-500/20 text-amber-200' : 'bg-amber-50 text-amber-600'
|
||||
}`}>
|
||||
<ScrollText className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{card.title}
|
||||
</div>
|
||||
<div className={`text-xs mt-1 leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
|
||||
<span className={`inline-flex items-center gap-1 mr-1 font-medium ${isDark ? 'text-amber-200' : 'text-amber-600'}`}>
|
||||
<Sparkles className="w-3 h-3" />理由
|
||||
</span>
|
||||
{card.reason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface GroupHeaderProps {
|
||||
label: string;
|
||||
parentTitle: string;
|
||||
count: number;
|
||||
variant: 'actor' | 'behavior' | 'narrative';
|
||||
}
|
||||
|
||||
export const ParentGroupHeader: React.FC<GroupHeaderProps> = ({ label, parentTitle, count, variant }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const Icon = variant === 'actor' ? Target : variant === 'behavior' ? Users : Zap;
|
||||
const accent = variant === 'actor'
|
||||
? (isDark ? 'text-violet-200 bg-violet-500/15 border-violet-400/30' : 'text-orange-600 bg-orange-50 border-orange-200')
|
||||
: variant === 'behavior'
|
||||
? (isDark ? 'text-cyan-200 bg-cyan-500/15 border-cyan-400/30' : 'text-red-600 bg-red-50 border-red-200')
|
||||
: (isDark ? 'text-emerald-200 bg-emerald-500/15 border-emerald-400/30' : 'text-emerald-600 bg-emerald-50 border-emerald-200');
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full border w-fit ${accent}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-medium">{label}</span>
|
||||
<span className={`text-xs font-semibold ${isDark ? 'text-white' : 'text-gray-900'} truncate max-w-[260px]`} title={parentTitle}>
|
||||
{parentTitle}
|
||||
</span>
|
||||
<span className={`text-[10px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>· {count} 张</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ThinkingBlockProps {
|
||||
text: string;
|
||||
loading?: boolean;
|
||||
empty?: string;
|
||||
}
|
||||
|
||||
export const ThinkingBlock: React.FC<ThinkingBlockProps> = ({ text, loading, empty = '正在生成思考内容...' }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<div className={`rounded-2xl border px-4 py-3 ${
|
||||
isDark ? 'bg-slate-900/30 border-slate-700/60' : 'bg-white border-gray-100'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileSearch className={`w-3.5 h-3.5 ${isDark ? 'text-cyan-300' : 'text-red-500'}`} />
|
||||
<span className={`text-[11px] font-medium uppercase tracking-wider ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
思考过程
|
||||
</span>
|
||||
{loading && (
|
||||
<span className={`text-[11px] ${isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}`}>
|
||||
生成中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{text ? (
|
||||
<pre className={`whitespace-pre-wrap text-xs leading-relaxed font-sans ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
|
||||
{text}
|
||||
</pre>
|
||||
) : (
|
||||
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{empty}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StageHeaderProps {
|
||||
title: string;
|
||||
desc?: string;
|
||||
count?: number;
|
||||
loading?: boolean;
|
||||
done?: boolean;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
accent: 'purpose' | 'actor' | 'behavior' | 'narrative';
|
||||
}
|
||||
|
||||
const ACCENT_BG: Record<StageHeaderProps['accent'], { dark: string; light: string }> = {
|
||||
purpose: { dark: 'bg-violet-500/15 text-violet-200', light: 'bg-orange-50 text-orange-500' },
|
||||
actor: { dark: 'bg-cyan-500/15 text-cyan-200', light: 'bg-red-50 text-red-500' },
|
||||
behavior: { dark: 'bg-emerald-500/15 text-emerald-200', light: 'bg-emerald-50 text-emerald-600' },
|
||||
narrative: { dark: 'bg-amber-500/15 text-amber-200', light: 'bg-amber-50 text-amber-600' },
|
||||
};
|
||||
|
||||
export const StageHeader: React.FC<StageHeaderProps> = ({ title, desc, count, loading, done, icon: Icon, accent }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const cls = ACCENT_BG[accent][isDark ? 'dark' : 'light'];
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${cls} ${loading ? 'sixstep-pulse' : ''}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'} flex items-center gap-2`}>
|
||||
<span className="truncate">{title}</span>
|
||||
{typeof count === 'number' && (
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-slate-700 text-slate-300' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{count} 张
|
||||
</span>
|
||||
)}
|
||||
{done && (
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-emerald-500/20 text-emerald-300' : 'bg-emerald-50 text-emerald-600'
|
||||
}`}>
|
||||
已完成
|
||||
</span>
|
||||
)}
|
||||
{loading && (
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-cyan-500/20 text-cyan-200' : 'bg-red-50 text-red-600'
|
||||
}`}>
|
||||
生成中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{desc && (
|
||||
<div className={`text-xs mt-0.5 ${
|
||||
loading ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-slate-400' : 'text-gray-500')
|
||||
}`}>
|
||||
{desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const STAGE_ICONS = {
|
||||
purpose: Target,
|
||||
actor: Users,
|
||||
behavior: Zap,
|
||||
narrative: Megaphone,
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Eraser, MessageSquareText, Pencil, Plus, Trash2, X } from 'lucide-react';
|
||||
|
||||
export interface SixStepConversationMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
isDark: boolean;
|
||||
conversations: SixStepConversationMeta[];
|
||||
activeId: string | null;
|
||||
onClose: () => void;
|
||||
onOpen: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
onNew: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
const UNTITLED = '未命名对话';
|
||||
|
||||
const SixStepChatHistory: React.FC<Props> = ({
|
||||
isOpen,
|
||||
isDark,
|
||||
conversations,
|
||||
activeId,
|
||||
onClose,
|
||||
onOpen,
|
||||
onSelect,
|
||||
onNew,
|
||||
onDelete,
|
||||
onRename,
|
||||
onClearAll,
|
||||
}) => {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [draftTitle, setDraftTitle] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingId && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [editingId]);
|
||||
|
||||
const startEdit = (conversation: SixStepConversationMeta) => {
|
||||
setEditingId(conversation.id);
|
||||
setDraftTitle(conversation.title);
|
||||
};
|
||||
|
||||
const commitEdit = () => {
|
||||
if (!editingId) return;
|
||||
onRename(editingId, draftTitle.trim() || UNTITLED);
|
||||
setEditingId(null);
|
||||
setDraftTitle('');
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setDraftTitle('');
|
||||
};
|
||||
|
||||
const handleDelete = (conversation: SixStepConversationMeta) => {
|
||||
const ok = window.confirm(`确认删除对话“${conversation.title || UNTITLED}”吗?`);
|
||||
if (!ok) return;
|
||||
onDelete(conversation.id);
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
if (conversations.length === 0) return;
|
||||
const ok = window.confirm('确认清空当前对话列表中的全部历史记录吗?此操作无法撤销。');
|
||||
if (!ok) return;
|
||||
onClearAll();
|
||||
};
|
||||
|
||||
const railButtonClass = (active = false) =>
|
||||
`flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border transition ${
|
||||
active
|
||||
? isDark
|
||||
? 'border-cyan-400/40 bg-cyan-500/10 text-cyan-200 shadow-sm'
|
||||
: 'border-red-200 bg-red-50/70 text-red-500 shadow-sm'
|
||||
: isDark
|
||||
? 'border-slate-700 bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-100'
|
||||
: 'border-gray-200 bg-white text-gray-500 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`;
|
||||
|
||||
const handleCollapsedSelect = (id: string) => {
|
||||
onOpen();
|
||||
window.setTimeout(() => {
|
||||
onSelect(id);
|
||||
}, 120);
|
||||
};
|
||||
|
||||
const handleCollapsedNew = () => {
|
||||
onOpen();
|
||||
window.setTimeout(() => {
|
||||
onNew();
|
||||
}, 120);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`flex h-full shrink-0 flex-col overflow-hidden border-r transition-[width,opacity] duration-300 ease-out ${
|
||||
isOpen ? 'pointer-events-auto w-[300px] opacity-100' : 'pointer-events-auto w-[72px] opacity-100'
|
||||
} ${
|
||||
isDark
|
||||
? 'border-slate-700/50 bg-slate-900/60 text-slate-100'
|
||||
: 'border-red-100 bg-gray-50/70 text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{isOpen ? (
|
||||
<>
|
||||
<div
|
||||
className={`border-b px-4 py-4 transition-all duration-300 ease-out ${isDark ? 'border-slate-700/50' : 'border-red-100'} ${
|
||||
isOpen ? 'translate-x-0 opacity-100' : '-translate-x-2 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">聊天记录</h3>
|
||||
<p className={`mt-1 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
查看和管理当前页面的历史对话
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title="收起聊天记录"
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNew}
|
||||
className={`inline-flex h-9 items-center justify-center gap-2 rounded-lg border text-sm font-medium transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
|
||||
: 'border-red-200 bg-white text-red-600 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建对话
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearAll}
|
||||
disabled={conversations.length === 0}
|
||||
className={`inline-flex h-9 items-center justify-center gap-2 rounded-lg border text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Eraser className="h-4 w-4" />
|
||||
清空记录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`min-h-0 flex-1 space-y-1 overflow-y-auto px-3 py-3 transition-all duration-300 ease-out ${
|
||||
isOpen ? 'translate-x-0 opacity-100' : '-translate-x-2 opacity-0'
|
||||
}`}
|
||||
>
|
||||
{conversations.length === 0 ? (
|
||||
<div
|
||||
className={`rounded-xl border border-dashed px-3 py-8 text-center text-xs ${
|
||||
isDark ? 'border-slate-700 text-slate-500' : 'border-gray-200 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
还没有任何对话记录
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conversation) => {
|
||||
const isActive = activeId === conversation.id;
|
||||
const isEditing = editingId === conversation.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={conversation.id}
|
||||
role={isEditing ? undefined : 'button'}
|
||||
tabIndex={isEditing ? -1 : 0}
|
||||
onClick={() => !isEditing && onSelect(conversation.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (!isEditing && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
onSelect(conversation.id);
|
||||
}
|
||||
}}
|
||||
className={`group flex cursor-pointer items-center gap-2 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 ${
|
||||
isActive
|
||||
? isDark
|
||||
? 'border-cyan-400/40 bg-cyan-500/10 text-slate-50 shadow-sm'
|
||||
: 'border-red-200 bg-red-50/70 text-gray-900 shadow-sm'
|
||||
: isDark
|
||||
? 'border-transparent text-slate-300 hover:border-slate-700 hover:bg-slate-800/60 hover:text-slate-100'
|
||||
: 'border-transparent text-gray-700 hover:border-gray-200 hover:bg-white hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
|
||||
isActive
|
||||
? isDark
|
||||
? 'bg-cyan-500/20 text-cyan-200'
|
||||
: 'bg-white text-red-500 ring-1 ring-red-100'
|
||||
: isDark
|
||||
? 'bg-slate-800 text-slate-400'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<MessageSquareText className="h-4 w-4" />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draftTitle}
|
||||
onChange={(event) => setDraftTitle(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commitEdit();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
}}
|
||||
onBlur={commitEdit}
|
||||
className={`w-full border-b bg-transparent text-sm font-medium outline-none ${
|
||||
isDark ? 'border-cyan-400/60 text-white' : 'border-red-300 text-gray-900'
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="truncate text-sm font-medium">{conversation.title || UNTITLED}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
commitEdit();
|
||||
}}
|
||||
title="保存"
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
|
||||
isDark
|
||||
? 'text-emerald-300 hover:bg-emerald-500/15'
|
||||
: 'text-emerald-600 hover:bg-emerald-50'
|
||||
}`}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
startEdit(conversation);
|
||||
}}
|
||||
title="重命名"
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
|
||||
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
} ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleDelete(conversation);
|
||||
}}
|
||||
title="删除对话"
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
|
||||
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
} ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-rose-500/15 hover:text-rose-300'
|
||||
: 'text-gray-400 hover:bg-rose-50 hover:text-rose-500'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center gap-3 px-2 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCollapsedNew}
|
||||
title="新建对话"
|
||||
className={`${railButtonClass(false)} sixstep-fade-up`}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="min-h-0 w-full flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{conversations.map((conversation, index) => (
|
||||
<button
|
||||
key={conversation.id}
|
||||
type="button"
|
||||
onClick={() => handleCollapsedSelect(conversation.id)}
|
||||
title={conversation.title || UNTITLED}
|
||||
className={`${railButtonClass(activeId === conversation.id)} transition-all duration-300 ease-out`}
|
||||
style={{ transitionDelay: `${Math.min(index, 6) * 25}ms` }}
|
||||
>
|
||||
<MessageSquareText className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default SixStepChatHistory;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,660 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { Bot, CheckCircle2, Database, FileCheck2, Sparkles, UserRound } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import ExpertMatchCards from './ExpertMatchCards';
|
||||
import StreamErrorNotice from './StreamErrorNotice';
|
||||
import {
|
||||
ActorCardItem,
|
||||
BehaviorCardItem,
|
||||
InlineCollapsible,
|
||||
NarrativeCardItem,
|
||||
ParentGroupHeader,
|
||||
PurposeCardItem,
|
||||
STAGE_ICONS,
|
||||
StageHeader,
|
||||
ThinkingBlock,
|
||||
} from './SixStepCards';
|
||||
import type {
|
||||
ActorCard,
|
||||
BehaviorDriverCard,
|
||||
InfoStageData,
|
||||
NarrativeCard,
|
||||
PurposeCard,
|
||||
SixStepMessage,
|
||||
} from './types';
|
||||
|
||||
export interface ExpertMatchHandlers {
|
||||
onSelectExpert: (messageId: string, expertId: string) => void;
|
||||
onRefetchExpert: (messageId: string) => void;
|
||||
onCreateExpert: (messageId: string) => void;
|
||||
onConfirmExpert: (messageId: string) => void;
|
||||
}
|
||||
|
||||
interface SixStepConversationProps {
|
||||
messages: SixStepMessage[];
|
||||
isGenerating: boolean;
|
||||
activeMessageId?: string | null;
|
||||
expertHandlers?: ExpertMatchHandlers;
|
||||
}
|
||||
|
||||
interface AvatarProps {
|
||||
role: 'user' | 'assistant';
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const Avatar: React.FC<AvatarProps> = ({ role, active }) => {
|
||||
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 shrink-0 shadow-sm overflow-hidden ${
|
||||
role === 'assistant'
|
||||
? 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'
|
||||
: 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'
|
||||
} ${active ? 'sixstep-pulse' : ''}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
{role === 'assistant' && active && (
|
||||
<Sparkles className="absolute -right-0.5 -top-0.5 w-3.5 h-3.5 text-yellow-200" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InfoStage: React.FC<{ data: InfoStageData; running: boolean }> = ({ data, running }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<InlineCollapsible
|
||||
defaultOpen={false}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center shrink-0 ${
|
||||
isDark
|
||||
? `bg-blue-500/15 text-blue-200 ${running ? 'sixstep-pulse' : ''}`
|
||||
: `bg-blue-50 text-blue-500 ${running ? 'sixstep-pulse' : ''}`
|
||||
}`}>
|
||||
<Database className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
<span>信息准备</span>
|
||||
{data.complete && (
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-emerald-500/20 text-emerald-300' : 'bg-emerald-50 text-emerald-600'
|
||||
}`}>已完成</span>
|
||||
)}
|
||||
{running && (
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-blue-500/20 text-blue-200' : 'bg-blue-50 text-blue-600'
|
||||
}`}>收集中</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-xs mt-0.5 ${
|
||||
running
|
||||
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
|
||||
: isDark ? 'text-slate-400' : 'text-gray-500'
|
||||
}`}>
|
||||
{running ? '正在收集热搜与上下文信息...' : '已整合信息准备素材'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{data.content ? (
|
||||
<div className={`prose prose-sm max-w-none text-sm leading-relaxed ${
|
||||
isDark ? 'prose-invert text-slate-300' : 'text-gray-700'
|
||||
}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
{running ? '正在获取信息...' : '无内容'}
|
||||
</div>
|
||||
)}
|
||||
</InlineCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
const MarkdownMessage: React.FC<{ content: string; isDark: boolean }> = ({ content, isDark }) => (
|
||||
<div className={`rounded-2xl border px-5 py-4 ${
|
||||
isDark
|
||||
? 'border-slate-700/50 bg-slate-800/30'
|
||||
: 'border-gray-100 bg-white'
|
||||
}`}>
|
||||
<div className={`prose prose-sm max-w-none leading-relaxed ${isDark ? 'prose-invert' : ''}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PurposeStage: React.FC<{ data: NonNullable<SixStepMessage['stages']>['purpose']; running: boolean }> = ({ data, running }) => {
|
||||
if (!data) return null;
|
||||
const Icon = STAGE_ICONS.purpose;
|
||||
const cards = data.cards ?? [];
|
||||
return (
|
||||
<InlineCollapsible
|
||||
defaultOpen
|
||||
title={
|
||||
<StageHeader
|
||||
title="目的生成"
|
||||
desc={running ? '正在推演核心目的...' : '已识别核心目的方向'}
|
||||
count={cards.length}
|
||||
loading={running}
|
||||
done={data.complete}
|
||||
icon={Icon}
|
||||
accent="purpose"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
|
||||
<InlineCollapsible
|
||||
variant="subtle"
|
||||
defaultOpen
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">目的卡片列表</span>
|
||||
<span className="text-[10px] opacity-70">{cards.length} 张</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{cards.length === 0 ? (
|
||||
<div className="text-xs opacity-60">等待生成目的卡片...</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{cards.map((card) => (
|
||||
<PurposeCardItem key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</InlineCollapsible>
|
||||
</div>
|
||||
</InlineCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
const ActorStage: React.FC<{
|
||||
data: NonNullable<SixStepMessage['stages']>['actor'];
|
||||
purposes: PurposeCard[];
|
||||
running: boolean;
|
||||
}> = ({ data, purposes, running }) => {
|
||||
if (!data) return null;
|
||||
const Icon = STAGE_ICONS.actor;
|
||||
const cards = data.cards ?? [];
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, ActorCard[]>();
|
||||
cards.forEach((card) => {
|
||||
const list = map.get(card.purposeId) ?? [];
|
||||
list.push(card);
|
||||
map.set(card.purposeId, list);
|
||||
});
|
||||
return purposes
|
||||
.map((purpose) => ({ purpose, items: map.get(purpose.id) ?? [] }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
}, [cards, purposes]);
|
||||
|
||||
return (
|
||||
<InlineCollapsible
|
||||
defaultOpen
|
||||
title={
|
||||
<StageHeader
|
||||
title="行为体生成"
|
||||
desc={running ? '正在匹配行为体角色...' : '已生成行为体清单'}
|
||||
count={cards.length}
|
||||
loading={running}
|
||||
done={data.complete}
|
||||
icon={Icon}
|
||||
accent="actor"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
|
||||
<InlineCollapsible
|
||||
variant="subtle"
|
||||
defaultOpen
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">行为体卡片列表</span>
|
||||
<span className="text-[10px] opacity-70">{cards.length} 张</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{groups.length === 0 ? (
|
||||
<div className="text-xs opacity-60">等待生成行为体卡片...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.purpose.id} className="space-y-2 sixstep-fade-up">
|
||||
<ParentGroupHeader label="所属目的" parentTitle={group.purpose.title} count={group.items.length} variant="actor" />
|
||||
<div className="space-y-2 pl-2 border-l-2 border-dashed border-slate-400/20">
|
||||
{group.items.map((card) => (
|
||||
<ActorCardItem key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</InlineCollapsible>
|
||||
</div>
|
||||
</InlineCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
const BehaviorStage: React.FC<{
|
||||
data: NonNullable<SixStepMessage['stages']>['behavior'];
|
||||
actors: ActorCard[];
|
||||
running: boolean;
|
||||
}> = ({ data, actors, running }) => {
|
||||
if (!data) return null;
|
||||
const Icon = STAGE_ICONS.behavior;
|
||||
const cards = data.cards ?? [];
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, BehaviorDriverCard[]>();
|
||||
cards.forEach((card) => {
|
||||
const list = map.get(card.actorId) ?? [];
|
||||
list.push(card);
|
||||
map.set(card.actorId, list);
|
||||
});
|
||||
return actors
|
||||
.map((actor) => ({ actor, items: map.get(actor.id) ?? [] }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
}, [cards, actors]);
|
||||
|
||||
return (
|
||||
<InlineCollapsible
|
||||
defaultOpen
|
||||
title={
|
||||
<StageHeader
|
||||
title="预期行为与驱动因素生成"
|
||||
desc={running ? '正在推演行为及驱动因素...' : '已生成预期行为与驱动因素'}
|
||||
count={cards.length}
|
||||
loading={running}
|
||||
done={data.complete}
|
||||
icon={Icon}
|
||||
accent="behavior"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
|
||||
<InlineCollapsible
|
||||
variant="subtle"
|
||||
defaultOpen
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">预期行为与驱动因素卡片列表</span>
|
||||
<span className="text-[10px] opacity-70">{cards.length} 张</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{groups.length === 0 ? (
|
||||
<div className="text-xs opacity-60">等待生成预期行为与驱动因素卡片...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.actor.id} className="space-y-2 sixstep-fade-up">
|
||||
<ParentGroupHeader label="所属行为体" parentTitle={group.actor.title} count={group.items.length} variant="behavior" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2 pl-2 border-l-2 border-dashed border-slate-400/20">
|
||||
{group.items.map((card) => (
|
||||
<BehaviorCardItem key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</InlineCollapsible>
|
||||
</div>
|
||||
</InlineCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
const NarrativeStage: React.FC<{
|
||||
data: NonNullable<SixStepMessage['stages']>['narrative'];
|
||||
behaviors: BehaviorDriverCard[];
|
||||
running: boolean;
|
||||
}> = ({ data, behaviors, running }) => {
|
||||
if (!data) return null;
|
||||
const Icon = STAGE_ICONS.narrative;
|
||||
const cards = data.cards ?? [];
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, NarrativeCard[]>();
|
||||
cards.forEach((card) => {
|
||||
const list = map.get(card.driverId) ?? [];
|
||||
list.push(card);
|
||||
map.set(card.driverId, list);
|
||||
});
|
||||
return behaviors
|
||||
.map((behavior) => ({ behavior, items: map.get(behavior.id) ?? [] }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
}, [cards, behaviors]);
|
||||
|
||||
return (
|
||||
<InlineCollapsible
|
||||
defaultOpen
|
||||
title={
|
||||
<StageHeader
|
||||
title="叙事生成"
|
||||
desc={running ? '正在构建叙事框架...' : '已生成叙事卡片'}
|
||||
count={cards.length}
|
||||
loading={running}
|
||||
done={data.complete}
|
||||
icon={Icon}
|
||||
accent="narrative"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
|
||||
<InlineCollapsible
|
||||
variant="subtle"
|
||||
defaultOpen
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">叙事卡片列表</span>
|
||||
<span className="text-[10px] opacity-70">{cards.length} 张</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{groups.length === 0 ? (
|
||||
<div className="text-xs opacity-60">等待生成叙事卡片...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.behavior.id} className="space-y-2 sixstep-fade-up">
|
||||
<ParentGroupHeader
|
||||
label="所属驱动因素"
|
||||
parentTitle={group.behavior.driver}
|
||||
count={group.items.length}
|
||||
variant="narrative"
|
||||
/>
|
||||
<div className="space-y-2 pl-2 border-l-2 border-dashed border-slate-400/20">
|
||||
{group.items.map((card) => (
|
||||
<NarrativeCardItem key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</InlineCollapsible>
|
||||
</div>
|
||||
</InlineCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
const FinalAnswer: React.FC<{ text: string; running: boolean }> = ({ text, running }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
if (!text && !running) return null;
|
||||
return (
|
||||
<div className={`rounded-2xl border p-4 ${
|
||||
isDark
|
||||
? 'border-slate-600/40 bg-slate-700/40 shadow-lg shadow-black/10'
|
||||
: 'border-red-100 bg-white shadow-sm'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileCheck2 className={`w-4 h-4 ${isDark ? 'text-emerald-300' : 'text-emerald-600'}`} />
|
||||
<span className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>最终结果</span>
|
||||
{running && (
|
||||
<span className={`text-[11px] ${isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}`}>
|
||||
正在汇总输出
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className={`whitespace-pre-wrap text-sm leading-relaxed font-sans ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
|
||||
{text || '准备汇总输出...'}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectedSummaryPill: React.FC<{ message: SixStepMessage }> = ({ message }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
if (!message.selectedExpert) return null;
|
||||
const mcps = message.selectedMcps ?? [];
|
||||
const labelMap: Record<string, string> = {
|
||||
purpose: '目的',
|
||||
actor: '行为体',
|
||||
behavior_factor: '预期行为和驱动因素',
|
||||
narrative: '叙事',
|
||||
};
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-2 rounded-2xl border px-3 py-2 ${
|
||||
isDark ? 'border-cyan-400/30 bg-cyan-500/10 text-cyan-100' : 'border-red-200 bg-red-50/70 text-red-700'
|
||||
}`}>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-xs font-semibold">已选专家</span>
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-slate-900/40 text-cyan-100' : 'bg-white text-red-600 shadow-sm'
|
||||
}`}>
|
||||
{message.selectedExpert.name}
|
||||
</span>
|
||||
{mcps.length > 0 && (
|
||||
<>
|
||||
<span className={`text-[11px] ${isDark ? 'text-slate-300' : 'text-gray-500'}`}>·</span>
|
||||
<span className={`text-[11px] ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
|
||||
执行步骤:{mcps.map((k) => labelMap[k] || k).join(' → ')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AssistantMessageProps {
|
||||
message: SixStepMessage;
|
||||
active: boolean;
|
||||
expertHandlers?: ExpertMatchHandlers;
|
||||
}
|
||||
|
||||
const AssistantMessage: React.FC<AssistantMessageProps> = ({ message, active, expertHandlers }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const isMarkdown = message.outputFormat === 'markdown';
|
||||
const isFollowUp = message.phase === 'follow-up';
|
||||
const stages = message.stages ?? {};
|
||||
const purposes = stages.purpose?.cards ?? [];
|
||||
const actors = stages.actor?.cards ?? [];
|
||||
const behaviors = stages.behavior?.cards ?? [];
|
||||
|
||||
const isInfoRunning = message.mainStepStatus?.info === 'running';
|
||||
const isPurposeRunning = message.subStepStatus?.purpose === 'running';
|
||||
const isActorRunning = message.subStepStatus?.actor === 'running';
|
||||
const isBehaviorRunning = message.subStepStatus?.behavior === 'running';
|
||||
const isNarrativeRunning = message.subStepStatus?.narrative === 'running';
|
||||
const isFinalRunning = message.mainStepStatus?.final === 'running';
|
||||
const generating =
|
||||
isInfoRunning ||
|
||||
message.mainStepStatus?.sixstep === 'running' ||
|
||||
isFinalRunning;
|
||||
// follow-up 在等待第一个 delta 时显示加载态
|
||||
const followUpGenerating = isFollowUp && active && !message.markdownContent;
|
||||
|
||||
const noStageYet = !stages.info && !stages.purpose && !stages.actor && !stages.behavior && !stages.narrative;
|
||||
|
||||
const expertMatch = message.expertMatch;
|
||||
const inExpertPhase = message.phase === 'expert-match' && !!expertMatch;
|
||||
const expertConfirmed = expertMatch?.status === 'confirmed' || !!message.selectedExpert;
|
||||
const expertActive = active && inExpertPhase && !expertConfirmed;
|
||||
|
||||
// Only render info/stages/finalAnswer when we're in an analysis phase.
|
||||
// Expert-match, clarifying, and follow-up phases show their own dedicated UI instead.
|
||||
const showAnalysisContent =
|
||||
!inExpertPhase &&
|
||||
message.phase !== 'clarifying' &&
|
||||
message.phase !== 'follow-up';
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<Avatar role="assistant" active={generating || expertActive || followUpGenerating} />
|
||||
<div className={`flex-1 min-w-0 max-w-full rounded-2xl px-4 py-3 border ${
|
||||
isDark
|
||||
? 'bg-slate-800/40 border-slate-700/50'
|
||||
: 'bg-transparent border-transparent shadow-none px-0 py-0'
|
||||
}`}>
|
||||
<div className="space-y-3">
|
||||
{/* Expert match phase: show expert cards before stages */}
|
||||
{inExpertPhase && expertMatch && (
|
||||
<ExpertMatchCards
|
||||
data={expertMatch}
|
||||
locked={expertConfirmed || !active}
|
||||
onSelectExpert={(id) => expertHandlers?.onSelectExpert(message.id, id)}
|
||||
onRefetch={() => expertHandlers?.onRefetchExpert(message.id)}
|
||||
onCreateExpert={() => expertHandlers?.onCreateExpert(message.id)}
|
||||
onConfirm={() => expertHandlers?.onConfirmExpert(message.id)}
|
||||
/>
|
||||
)}
|
||||
{inExpertPhase && (message.errors?.length ?? 0) > 0 && (
|
||||
<StreamErrorNotice errors={message.errors!} />
|
||||
)}
|
||||
|
||||
{/* Selected expert / MCP summary once moved to generate phase */}
|
||||
{message.selectedExpert && message.phase !== 'expert-match' && (
|
||||
<SelectedSummaryPill message={message} />
|
||||
)}
|
||||
|
||||
{showAnalysisContent && (
|
||||
<>
|
||||
{/* Info preparing placeholder when no stage data yet */}
|
||||
{isInfoRunning && noStageYet && (
|
||||
<div className={`rounded-2xl border px-4 py-3 ${
|
||||
isDark ? 'border-slate-700/60 bg-slate-900/40 text-slate-300' : 'border-blue-100 bg-white text-gray-600'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Database className={`w-4 h-4 ${isDark ? 'text-blue-300' : 'text-blue-500'}`} />
|
||||
<span className={isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}>
|
||||
正在调用信息准备,收集热搜与上下文...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info prepare result (collapsible) */}
|
||||
{stages.info && <InfoStage data={stages.info} running={isInfoRunning} />}
|
||||
|
||||
{(isMarkdown || isFollowUp) ? (
|
||||
/* ---- Markdown / Follow-up mode ---- */
|
||||
<>
|
||||
{(message.markdownContent || generating || followUpGenerating) && (
|
||||
message.markdownContent ? (
|
||||
<MarkdownMessage content={message.markdownContent} isDark={isDark} />
|
||||
) : (
|
||||
<div className={`rounded-2xl border px-4 py-3 ${
|
||||
isDark ? 'border-slate-700/60 bg-slate-900/40' : 'border-gray-100 bg-white'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Sparkles className={`w-4 h-4 ${isDark ? 'text-cyan-300' : 'text-red-500'}`} />
|
||||
<span className={isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}>
|
||||
{isFollowUp ? '正在回答你的问题...' : '正在生成推演内容...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{(message.errors?.length ?? 0) > 0 && (
|
||||
<StreamErrorNotice errors={message.errors!} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* ---- Structured mode ---- */
|
||||
<>
|
||||
{stages.purpose && <PurposeStage data={stages.purpose} running={isPurposeRunning} />}
|
||||
{stages.actor && <ActorStage data={stages.actor} purposes={purposes} running={isActorRunning} />}
|
||||
{stages.behavior && <BehaviorStage data={stages.behavior} actors={actors} running={isBehaviorRunning} />}
|
||||
{stages.narrative && (
|
||||
<NarrativeStage data={stages.narrative} behaviors={behaviors} running={isNarrativeRunning} />
|
||||
)}
|
||||
{(message.finalAnswer || isFinalRunning) && (
|
||||
<FinalAnswer text={message.finalAnswer ?? ''} running={isFinalRunning} />
|
||||
)}
|
||||
{(message.errors?.length ?? 0) > 0 && (
|
||||
<StreamErrorNotice errors={message.errors!} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UserMessage: React.FC<{ message: SixStepMessage }> = ({ message }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<div className="flex gap-3 justify-end">
|
||||
<div className={`max-w-[72%] rounded-2xl px-4 py-3 shadow-sm ${
|
||||
isDark ? 'bg-blue-600/70 text-white' : 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
|
||||
}`}>
|
||||
<p className="whitespace-pre-wrap leading-relaxed text-sm">{message.content}</p>
|
||||
</div>
|
||||
<Avatar role="user" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SixStepConversation: React.FC<SixStepConversationProps> = ({ messages, isGenerating, activeMessageId, expertHandlers }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [messages, isGenerating]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className={`h-full flex flex-col items-center justify-center px-6 text-center ${
|
||||
isDark ? 'text-slate-400' : 'text-gray-500'
|
||||
}`}>
|
||||
<div className={`w-20 h-20 rounded-3xl mb-5 flex items-center justify-center ${
|
||||
isDark ? 'bg-slate-700/40 text-cyan-300' : 'bg-gradient-to-br from-red-50 to-orange-50 text-red-500'
|
||||
}`}>
|
||||
<Sparkles className="w-10 h-10 opacity-80" />
|
||||
</div>
|
||||
<p className={`text-lg font-medium ${isDark ? 'text-white' : 'text-gray-800'}`}>开始 6 步法推演</p>
|
||||
<p className="text-sm mt-2 max-w-md">
|
||||
输入问题或场景描述,模型会按照"信息准备 → 6 步法推演 → 最终结果"的流程逐步生成结构化输出。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="h-full overflow-y-auto px-5 py-5 space-y-5">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id}>
|
||||
{msg.role === 'user' ? (
|
||||
<UserMessage message={msg} />
|
||||
) : (
|
||||
<AssistantMessage
|
||||
message={msg}
|
||||
active={msg.id === activeMessageId}
|
||||
expertHandlers={expertHandlers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SixStepConversation;
|
||||
@@ -0,0 +1,653 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
Compass,
|
||||
Crosshair,
|
||||
Download,
|
||||
Megaphone,
|
||||
Minus,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
ScrollText,
|
||||
Target,
|
||||
TrendingUp,
|
||||
UserCheck,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import type { ExpertCard, SixStepMessage } from './types';
|
||||
import {
|
||||
CURRENT_PAIR_BY_STAGE,
|
||||
EDGE_COLOR,
|
||||
EDGE_LABEL,
|
||||
STATUS_TITLE,
|
||||
VARIANT_LABEL,
|
||||
buildEdgePath,
|
||||
buildLayout,
|
||||
type LayoutEdge,
|
||||
type LayoutNode,
|
||||
} from './flowLayout';
|
||||
|
||||
interface SixStepFlowFullscreenProps {
|
||||
message?: SixStepMessage;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SCALE_MIN = 0.2;
|
||||
const SCALE_MAX = 3;
|
||||
|
||||
const buildExpertRootDetail = (expert: ExpertCard): string => {
|
||||
const segments = [expert.title, expert.detail].filter((item): item is string => Boolean(item && item.trim()));
|
||||
return segments.join(' · ') || '已确认专家';
|
||||
};
|
||||
|
||||
const VARIANT_TOKEN: Record<LayoutNode['variant'], {
|
||||
dark: string; light: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
iconCls: { dark: string; light: string };
|
||||
}> = {
|
||||
agent: { dark: 'from-slate-700/80 to-slate-900/80 border-slate-500/60', light: 'from-white to-slate-50 border-slate-300', icon: Bot, iconCls: { dark: 'bg-slate-500/30 text-slate-200', light: 'bg-slate-100 text-slate-600' } },
|
||||
purpose: { dark: 'from-violet-700/40 to-violet-900/40 border-violet-400/50', light: 'from-orange-50 to-white border-orange-300', icon: Target, iconCls: { dark: 'bg-violet-500/30 text-violet-200', light: 'bg-orange-100 text-orange-600' } },
|
||||
actor: { dark: 'from-cyan-700/40 to-cyan-900/40 border-cyan-400/50', light: 'from-red-50 to-white border-red-300', icon: UserCheck, iconCls: { dark: 'bg-cyan-500/30 text-cyan-200', light: 'bg-red-100 text-red-600' } },
|
||||
behavior: { dark: 'from-emerald-700/40 to-emerald-900/40 border-emerald-400/50', light: 'from-emerald-50 to-white border-emerald-300', icon: TrendingUp,iconCls: { dark: 'bg-emerald-500/30 text-emerald-200', light: 'bg-emerald-100 text-emerald-700' } },
|
||||
driver: { dark: 'from-emerald-800/40 to-teal-900/40 border-teal-400/50', light: 'from-teal-50 to-white border-teal-300', icon: Zap, iconCls: { dark: 'bg-teal-500/30 text-teal-200', light: 'bg-teal-100 text-teal-700' } },
|
||||
narrative: { dark: 'from-amber-700/40 to-amber-900/40 border-amber-400/50', light: 'from-amber-50 to-white border-amber-300', icon: ScrollText,iconCls: { dark: 'bg-amber-500/30 text-amber-200', light: 'bg-amber-100 text-amber-600' } },
|
||||
};
|
||||
|
||||
const MINIMAP_VARIANT_FILL: Record<LayoutNode['variant'], { dark: string; light: string }> = {
|
||||
agent: { dark: '#475569', light: '#cbd5e1' },
|
||||
purpose: { dark: '#7c3aed', light: '#fb923c' },
|
||||
actor: { dark: '#06b6d4', light: '#ef4444' },
|
||||
behavior: { dark: '#10b981', light: '#10b981' },
|
||||
driver: { dark: '#0d9488', light: '#0d9488' },
|
||||
narrative: { dark: '#f59e0b', light: '#f59e0b' },
|
||||
};
|
||||
|
||||
const DetailNode: React.FC<{
|
||||
node: LayoutNode;
|
||||
isDark: boolean;
|
||||
collapsible: boolean;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse?: (nodeId: string) => void;
|
||||
entering: boolean;
|
||||
exiting: boolean;
|
||||
}> = ({ node, isDark, collapsible, collapsed, onToggleCollapse, entering, exiting }) => {
|
||||
const token = VARIANT_TOKEN[node.variant];
|
||||
const Icon = token.icon;
|
||||
const grad = isDark ? token.dark : token.light;
|
||||
const canToggle = collapsible && Boolean(onToggleCollapse);
|
||||
const handleClick = (e: React.MouseEvent<SVGGElement>) => {
|
||||
if (!canToggle) return;
|
||||
e.stopPropagation();
|
||||
onToggleCollapse?.(node.id);
|
||||
};
|
||||
const handleMouseDown = (e: React.MouseEvent<SVGGElement>) => {
|
||||
if (canToggle) e.stopPropagation();
|
||||
};
|
||||
return (
|
||||
<g
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
cursor: canToggle ? 'pointer' : undefined,
|
||||
opacity: exiting ? 0 : 1,
|
||||
transform: entering || exiting ? 'translateY(8px)' : 'translateY(0)',
|
||||
transformOrigin: `${node.x + node.width / 2}px ${node.y + node.height / 2}px`,
|
||||
transition: `opacity ${COLLAPSE_TRANSITION_MS}ms ease, transform ${COLLAPSE_TRANSITION_MS}ms ease`,
|
||||
}}
|
||||
>
|
||||
<foreignObject x={node.x} y={node.y} width={node.width} height={node.height}>
|
||||
<div className={`relative w-full h-full rounded-2xl border bg-gradient-to-br ${grad} backdrop-blur-sm p-2.5 shadow-sm overflow-hidden`}>
|
||||
{collapsible && (
|
||||
<div
|
||||
className={`absolute right-2 top-2 w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-bold ${
|
||||
isDark ? 'bg-slate-800/75 text-slate-200' : 'bg-white text-slate-600 border border-slate-200'
|
||||
}`}
|
||||
title={collapsed ? '展开子节点' : '收起子节点'}
|
||||
>
|
||||
{collapsed ? '+' : '-'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<div className={`w-5 h-5 rounded-md flex items-center justify-center ${token.iconCls[isDark ? 'dark' : 'light']}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
</div>
|
||||
<span className={`text-[10px] uppercase tracking-wider font-semibold ${isDark ? 'text-white/70' : 'text-slate-500'}`}>
|
||||
{VARIANT_LABEL[node.variant]}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-xs font-semibold leading-tight line-clamp-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{node.title}
|
||||
</div>
|
||||
<div className={`text-[11px] leading-snug line-clamp-2 mt-0.5 ${isDark ? 'text-slate-300' : 'text-slate-600'}`}>
|
||||
{node.detail || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolbarButton: React.FC<{ title: string; onClick: () => void; isDark: boolean; children: React.ReactNode }> = ({ title, onClick, isDark, children }) => (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className={`sixstep-toolbar-btn ${
|
||||
isDark ? 'text-slate-300 hover:bg-slate-700/70 hover:text-white' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
interface MinimapProps {
|
||||
layout: { nodes: LayoutNode[]; width: number; height: number };
|
||||
transform: { tx: number; ty: number; scale: number };
|
||||
viewportSize: { width: number; height: number };
|
||||
onJump: (worldX: number, worldY: number) => void;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const MINIMAP_W = 220;
|
||||
const MINIMAP_H = 150;
|
||||
const COLLAPSE_TRANSITION_MS = 240;
|
||||
|
||||
const Minimap: React.FC<MinimapProps> = ({ layout, transform, viewportSize, onJump, isDark }) => {
|
||||
const ratio = layout.width > 0 && layout.height > 0
|
||||
? Math.min((MINIMAP_W - 10) / layout.width, (MINIMAP_H - 10) / layout.height)
|
||||
: 0.1;
|
||||
const renderW = layout.width * ratio;
|
||||
const renderH = layout.height * ratio;
|
||||
const offsetX = (MINIMAP_W - renderW) / 2;
|
||||
const offsetY = (MINIMAP_H - renderH) / 2;
|
||||
|
||||
// Visible area (in world coords): from world (-tx/scale, -ty/scale) with size viewportW/scale × viewportH/scale
|
||||
const viewW = viewportSize.width / transform.scale;
|
||||
const viewH = viewportSize.height / transform.scale;
|
||||
const viewX = -transform.tx / transform.scale;
|
||||
const viewY = -transform.ty / transform.scale;
|
||||
|
||||
const handleClick = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left - offsetX;
|
||||
const my = e.clientY - rect.top - offsetY;
|
||||
const wx = mx / ratio;
|
||||
const wy = my / ratio;
|
||||
onJump(wx, wy);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl overflow-hidden border shadow-lg backdrop-blur-md ${
|
||||
isDark ? 'bg-slate-900/80 border-slate-700/70' : 'bg-white/90 border-gray-200'
|
||||
}`}>
|
||||
<div className={`px-3 py-1.5 text-[10px] uppercase tracking-wider border-b font-medium ${
|
||||
isDark ? 'text-slate-400 border-slate-700/50' : 'text-gray-500 border-gray-100'
|
||||
}`}>
|
||||
缩略图
|
||||
</div>
|
||||
<svg
|
||||
width={MINIMAP_W}
|
||||
height={MINIMAP_H}
|
||||
onClick={handleClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<rect width={MINIMAP_W} height={MINIMAP_H} fill={isDark ? '#0f172a' : '#f8fafc'} />
|
||||
<g transform={`translate(${offsetX}, ${offsetY}) scale(${ratio})`}>
|
||||
{layout.nodes.map((n) => (
|
||||
<rect
|
||||
key={n.id}
|
||||
x={n.x}
|
||||
y={n.y}
|
||||
width={n.width}
|
||||
height={n.height}
|
||||
rx={6}
|
||||
fill={MINIMAP_VARIANT_FILL[n.variant][isDark ? 'dark' : 'light']}
|
||||
opacity={0.75}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
<rect
|
||||
className="sixstep-minimap-rect"
|
||||
x={offsetX + viewX * ratio}
|
||||
y={offsetY + viewY * ratio}
|
||||
width={Math.max(6, viewW * ratio)}
|
||||
height={Math.max(6, viewH * ratio)}
|
||||
rx={3}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SixStepFlowFullscreen: React.FC<SixStepFlowFullscreenProps> = ({ message, onClose }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const purposes = message?.stages?.purpose?.cards ?? [];
|
||||
const actors = message?.stages?.actor?.cards ?? [];
|
||||
const behaviors = message?.stages?.behavior?.cards ?? [];
|
||||
const narratives = message?.stages?.narrative?.cards ?? [];
|
||||
const selectedExpert = message?.selectedExpert;
|
||||
|
||||
const layout = useMemo(
|
||||
() => buildLayout(purposes, actors, behaviors, narratives, {
|
||||
rootTitle: selectedExpert?.name,
|
||||
rootDetail: selectedExpert ? buildExpertRootDetail(selectedExpert) : undefined,
|
||||
}),
|
||||
[purposes, actors, behaviors, narratives, selectedExpert],
|
||||
);
|
||||
|
||||
const currentSub = message?.currentSubStep;
|
||||
const activePair = currentSub ? CURRENT_PAIR_BY_STAGE[currentSub] : null;
|
||||
const statusTitle = currentSub ? STATUS_TITLE[currentSub] : '推演结果可视化';
|
||||
const generating = Boolean(currentSub);
|
||||
|
||||
const [transform, setTransform] = useState({ tx: 0, ty: 0, scale: 1 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [viewportSize, setViewportSize] = useState({ width: 800, height: 600 });
|
||||
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set());
|
||||
const [displayedNodeIds, setDisplayedNodeIds] = useState<Set<string>>(new Set());
|
||||
const [enteringNodeIds, setEnteringNodeIds] = useState<Set<string>>(new Set());
|
||||
const [exitingNodeIds, setExitingNodeIds] = useState<Set<string>>(new Set());
|
||||
const [displayedEdgeIds, setDisplayedEdgeIds] = useState<Set<string>>(new Set());
|
||||
const [exitingEdgeIds, setExitingEdgeIds] = useState<Set<string>>(new Set());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const panStart = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
|
||||
const transitionTimerRef = useRef<number | null>(null);
|
||||
|
||||
const nodeMap = useMemo(() => new Map(layout.nodes.map((node) => [node.id, node])), [layout.nodes]);
|
||||
|
||||
const targetVisibleNodeSet = useMemo(() => {
|
||||
const visible = new Set<string>();
|
||||
layout.nodes.forEach((node) => {
|
||||
let parentId = node.parent;
|
||||
let hidden = false;
|
||||
while (parentId) {
|
||||
if (collapsedNodeIds.has(parentId)) {
|
||||
hidden = true;
|
||||
break;
|
||||
}
|
||||
parentId = nodeMap.get(parentId)?.parent;
|
||||
}
|
||||
if (!hidden) visible.add(node.id);
|
||||
});
|
||||
return visible;
|
||||
}, [collapsedNodeIds, layout.nodes, nodeMap]);
|
||||
|
||||
const targetVisibleEdgeSet = useMemo(() => {
|
||||
const visible = new Set<string>();
|
||||
layout.edges.forEach((edge) => {
|
||||
if (targetVisibleNodeSet.has(edge.fromId) && targetVisibleNodeSet.has(edge.toId)) {
|
||||
visible.add(edge.id);
|
||||
}
|
||||
});
|
||||
return visible;
|
||||
}, [layout.edges, targetVisibleNodeSet]);
|
||||
|
||||
const renderedNodeSet = displayedNodeIds.size > 0 ? displayedNodeIds : targetVisibleNodeSet;
|
||||
const renderedEdgeSet = displayedEdgeIds.size > 0 ? displayedEdgeIds : targetVisibleEdgeSet;
|
||||
const renderedNodes = useMemo(
|
||||
() => layout.nodes.filter((node) => renderedNodeSet.has(node.id)),
|
||||
[layout.nodes, renderedNodeSet],
|
||||
);
|
||||
const renderedEdges = useMemo(
|
||||
() => layout.edges.filter((edge) => renderedEdgeSet.has(edge.id)),
|
||||
[layout.edges, renderedEdgeSet],
|
||||
);
|
||||
|
||||
const toggleNodeCollapse = (nodeId: string) => {
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node || node.children.length === 0) return;
|
||||
setCollapsedNodeIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) next.delete(nodeId);
|
||||
else next.add(nodeId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setViewportSize({ width: rect.width, height: rect.height });
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
// Native wheel listener with passive:false so preventDefault actually blocks browser zoom
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
const delta = -e.deltaY * 0.0015;
|
||||
setTransform((prev) => {
|
||||
const nextScale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, prev.scale * (1 + delta)));
|
||||
const ratio = nextScale / prev.scale;
|
||||
return { tx: mx - (mx - prev.tx) * ratio, ty: my - (my - prev.ty) * ratio, scale: nextScale };
|
||||
});
|
||||
};
|
||||
container.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => container.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
const handlePanStart = (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
panStart.current = { x: e.clientX, y: e.clientY, tx: transform.tx, ty: transform.ty };
|
||||
setIsPanning(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPanning) return;
|
||||
const move = (e: MouseEvent) => {
|
||||
const start = panStart.current;
|
||||
if (!start) return;
|
||||
setTransform((prev) => ({ ...prev, tx: start.tx + (e.clientX - start.x), ty: start.ty + (e.clientY - start.y) }));
|
||||
};
|
||||
const up = () => {
|
||||
panStart.current = null;
|
||||
setIsPanning(false);
|
||||
};
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', move);
|
||||
window.removeEventListener('mouseup', up);
|
||||
};
|
||||
}, [isPanning]);
|
||||
|
||||
const fitToView = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const scale = Math.min((rect.width - 80) / Math.max(layout.width, 1), (rect.height - 80) / Math.max(layout.height, 1), 1);
|
||||
const tx = (rect.width - layout.width * scale) / 2;
|
||||
const ty = (rect.height - layout.height * scale) / 2;
|
||||
setTransform({ tx, ty, scale });
|
||||
};
|
||||
|
||||
const centerView = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
setTransform((prev) => ({
|
||||
...prev,
|
||||
tx: (rect.width - layout.width * prev.scale) / 2,
|
||||
ty: (rect.height - layout.height * prev.scale) / 2,
|
||||
}));
|
||||
};
|
||||
|
||||
const zoomIn = () => setTransform((prev) => ({ ...prev, scale: Math.min(SCALE_MAX, prev.scale * 1.2) }));
|
||||
const zoomOut = () => setTransform((prev) => ({ ...prev, scale: Math.max(SCALE_MIN, prev.scale / 1.2) }));
|
||||
const reset = () => setTransform({ tx: 0, ty: 0, scale: 1 });
|
||||
|
||||
const fitAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!fitAppliedRef.current && viewportSize.width > 0 && layout.nodes.length > 1) {
|
||||
fitToView();
|
||||
fitAppliedRef.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewportSize.width, layout.nodes.length]);
|
||||
|
||||
const handleMinimapJump = (wx: number, wy: number) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
setTransform((prev) => ({
|
||||
...prev,
|
||||
tx: rect.width / 2 - wx * prev.scale,
|
||||
ty: rect.height / 2 - wy * prev.scale,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const cloned = svg.cloneNode(true) as SVGSVGElement;
|
||||
cloned.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
cloned.setAttribute('width', String(layout.width));
|
||||
cloned.setAttribute('height', String(layout.height));
|
||||
cloned.setAttribute('viewBox', `0 0 ${layout.width} ${layout.height}`);
|
||||
const inner = cloned.querySelector('g');
|
||||
if (inner) inner.setAttribute('transform', '');
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(cloned);
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `6步法推演-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.svg`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const validIds = new Set(layout.nodes.filter((node) => node.children.length > 0).map((node) => node.id));
|
||||
setCollapsedNodeIds((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((id) => {
|
||||
if (validIds.has(id)) next.add(id);
|
||||
else changed = true;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [layout.nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (transitionTimerRef.current) {
|
||||
window.clearTimeout(transitionTimerRef.current);
|
||||
transitionTimerRef.current = null;
|
||||
}
|
||||
|
||||
const prevNodes = displayedNodeIds;
|
||||
const nextNodes = targetVisibleNodeSet;
|
||||
const prevEdges = displayedEdgeIds;
|
||||
const nextEdges = targetVisibleEdgeSet;
|
||||
|
||||
const enteringNodes = new Set([...nextNodes].filter((id) => !prevNodes.has(id)));
|
||||
const leavingNodes = new Set([...prevNodes].filter((id) => !nextNodes.has(id)));
|
||||
const leavingEdges = new Set([...prevEdges].filter((id) => !nextEdges.has(id)));
|
||||
const mergedNodes = new Set([...prevNodes, ...nextNodes]);
|
||||
const mergedEdges = new Set([...prevEdges, ...nextEdges]);
|
||||
|
||||
setEnteringNodeIds(enteringNodes);
|
||||
setExitingNodeIds(leavingNodes);
|
||||
setExitingEdgeIds(leavingEdges);
|
||||
setDisplayedNodeIds(mergedNodes);
|
||||
setDisplayedEdgeIds(mergedEdges);
|
||||
|
||||
transitionTimerRef.current = window.setTimeout(() => {
|
||||
setDisplayedNodeIds(new Set(nextNodes));
|
||||
setDisplayedEdgeIds(new Set(nextEdges));
|
||||
setEnteringNodeIds(new Set());
|
||||
setExitingNodeIds(new Set());
|
||||
setExitingEdgeIds(new Set());
|
||||
transitionTimerRef.current = null;
|
||||
}, COLLAPSE_TRANSITION_MS);
|
||||
}, [targetVisibleNodeSet, targetVisibleEdgeSet]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (transitionTimerRef.current) window.clearTimeout(transitionTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-50 flex flex-col ${isDark ? 'bg-slate-950/95' : 'bg-white/95'} backdrop-blur-sm`}>
|
||||
<div className={`flex items-center justify-between gap-3 px-5 py-3 border-b ${
|
||||
isDark ? 'border-slate-700/60 bg-slate-900/70' : 'border-red-100 bg-white'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<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'
|
||||
} ${generating ? 'sixstep-pulse' : ''}`}>
|
||||
<Compass className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className={`text-base font-semibold ${
|
||||
generating ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-white' : 'text-gray-900')
|
||||
}`}>
|
||||
{statusTitle}
|
||||
</div>
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
全屏视图 · 完整内容展示
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton title="放大" onClick={zoomIn} isDark={isDark}><Plus className="w-4 h-4" /></ToolbarButton>
|
||||
<ToolbarButton title="缩小" onClick={zoomOut} isDark={isDark}><Minus className="w-4 h-4" /></ToolbarButton>
|
||||
<ToolbarButton title="恢复 1:1" onClick={reset} isDark={isDark}><RotateCcw className="w-4 h-4" /></ToolbarButton>
|
||||
<ToolbarButton title="定位到画布中央" onClick={centerView} isDark={isDark}><Crosshair className="w-4 h-4" /></ToolbarButton>
|
||||
<ToolbarButton title="下载流程图(SVG)" onClick={handleDownload} isDark={isDark}><Download className="w-4 h-4" /></ToolbarButton>
|
||||
<span className={`mx-2 h-5 w-px ${isDark ? 'bg-slate-700' : 'bg-gray-200'}`} />
|
||||
<span className={`text-[11px] tabular-nums ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{Math.round(transform.scale * 100)}%
|
||||
</span>
|
||||
<ToolbarButton title="关闭全屏 (Esc)" onClick={onClose} isDark={isDark}>
|
||||
<X className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center gap-3 px-5 py-2 border-b text-[11px] flex-wrap ${
|
||||
isDark ? 'border-slate-700/60 text-slate-400 bg-slate-900/40' : 'border-red-100 text-gray-500 bg-white'
|
||||
}`}>
|
||||
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
|
||||
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
|
||||
const active = activePair === pair;
|
||||
return (
|
||||
<span key={pair} className="inline-flex items-center gap-1.5">
|
||||
<span className={`inline-block w-6 h-0.5 rounded-full ${active ? 'sixstep-ant-line' : ''}`} style={{ background: color, stroke: color }} />
|
||||
<span className={active ? (isDark ? 'text-cyan-200' : 'text-red-600') : ''}>{EDGE_LABEL[pair]}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`flex-1 overflow-hidden relative ${isPanning ? 'sixstep-canvas-grabbing' : 'sixstep-canvas-grab'}`}
|
||||
onMouseDown={handlePanStart}
|
||||
>
|
||||
{renderedNodes.length <= 1 ? (
|
||||
<div className={`h-full flex flex-col items-center justify-center text-center ${
|
||||
isDark ? 'text-slate-500' : 'text-gray-400'
|
||||
}`}>
|
||||
<Megaphone className="w-12 h-12 opacity-40 mb-3" />
|
||||
<div className={`text-base font-medium ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>等待推演开始</div>
|
||||
<div className="text-sm mt-1">推演产出将以流程图形式呈现</div>
|
||||
</div>
|
||||
) : (
|
||||
<svg ref={svgRef} width="100%" height="100%" className="block select-none">
|
||||
<defs>
|
||||
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
|
||||
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
|
||||
return (
|
||||
<marker
|
||||
key={pair}
|
||||
id={`sixstep-fs-arrow-${pair}`}
|
||||
viewBox="0 0 8 8"
|
||||
refX="7"
|
||||
refY="4"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 8 4 L 0 8 z" fill={color} />
|
||||
</marker>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
|
||||
<g transform={`translate(${transform.tx}, ${transform.ty}) scale(${transform.scale})`}>
|
||||
{renderedEdges.map((edge) => {
|
||||
const from = nodeMap.get(edge.fromId);
|
||||
const to = nodeMap.get(edge.toId);
|
||||
if (!from || !to) return null;
|
||||
const color = EDGE_COLOR[edge.pair][isDark ? 'dark' : 'light'];
|
||||
const isActive = activePair === edge.pair;
|
||||
const exiting = exitingEdgeIds.has(edge.id);
|
||||
return (
|
||||
<path
|
||||
key={edge.id}
|
||||
d={buildEdgePath(from, to)}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={isActive ? 2.4 : 1.8}
|
||||
strokeOpacity={exiting ? 0 : isActive ? 1 : 0.75}
|
||||
className={isActive ? 'sixstep-ant-line' : ''}
|
||||
markerEnd={`url(#sixstep-fs-arrow-${edge.pair})`}
|
||||
style={{
|
||||
opacity: exiting ? 0 : 1,
|
||||
transition: `opacity ${COLLAPSE_TRANSITION_MS}ms ease`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{renderedNodes.map((node) => (
|
||||
<DetailNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
isDark={isDark}
|
||||
collapsible={node.children.length > 0}
|
||||
collapsed={collapsedNodeIds.has(node.id)}
|
||||
onToggleCollapse={toggleNodeCollapse}
|
||||
entering={enteringNodeIds.has(node.id)}
|
||||
exiting={exitingNodeIds.has(node.id)}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{targetVisibleNodeSet.size > 1 && (
|
||||
<div className="absolute bottom-4 right-4">
|
||||
<Minimap
|
||||
layout={{
|
||||
nodes: layout.nodes.filter((node) => targetVisibleNodeSet.has(node.id)),
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
}}
|
||||
transform={transform}
|
||||
viewportSize={viewportSize}
|
||||
onJump={handleMinimapJump}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`absolute bottom-4 left-4 px-2 py-1 rounded-md text-[10px] backdrop-blur-md ${
|
||||
isDark ? 'bg-slate-800/70 text-slate-400 border border-slate-700/50' : 'bg-white/90 text-gray-500 border border-gray-200'
|
||||
}`}>
|
||||
点击父节点可收起/展开子节点
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SixStepFlowFullscreen;
|
||||
@@ -0,0 +1,520 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
Compass,
|
||||
Crosshair,
|
||||
Maximize2,
|
||||
Megaphone,
|
||||
Minus,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
ScrollText,
|
||||
Target,
|
||||
TrendingUp,
|
||||
UserCheck,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import type { ExpertCard, SixStepMessage } from './types';
|
||||
import {
|
||||
CURRENT_PAIR_BY_STAGE,
|
||||
EDGE_COLOR,
|
||||
EDGE_LABEL,
|
||||
STATUS_TITLE,
|
||||
VARIANT_ABBREV,
|
||||
VARIANT_LABEL,
|
||||
buildEdgePath,
|
||||
buildLayout,
|
||||
type LayoutEdge,
|
||||
type LayoutNode,
|
||||
} from './flowLayout';
|
||||
|
||||
interface SixStepFlowSandboxProps {
|
||||
message?: SixStepMessage;
|
||||
width: number;
|
||||
onResizeStart: (e: React.MouseEvent) => void;
|
||||
onClose: () => void;
|
||||
onOpenFullscreen: () => void;
|
||||
}
|
||||
|
||||
const COMPACT_OPTIONS = {
|
||||
nodeWidth: 120,
|
||||
nodeHeight: 60,
|
||||
levelGap: 80,
|
||||
nodeGap: 24,
|
||||
paddingX: 32,
|
||||
paddingY: 28,
|
||||
};
|
||||
|
||||
const VARIANT_GRADIENT: Record<LayoutNode['variant'], { dark: string; light: string }> = {
|
||||
agent: { dark: 'from-slate-700/80 to-slate-900/80 border-slate-500/60', light: 'from-white to-slate-50 border-slate-300' },
|
||||
purpose: { dark: 'from-violet-700/40 to-violet-900/40 border-violet-400/50', light: 'from-orange-50 to-white border-orange-300' },
|
||||
actor: { dark: 'from-cyan-700/40 to-cyan-900/40 border-cyan-400/50', light: 'from-red-50 to-white border-red-300' },
|
||||
behavior: { dark: 'from-emerald-700/40 to-emerald-900/40 border-emerald-400/50', light: 'from-emerald-50 to-white border-emerald-300' },
|
||||
driver: { dark: 'from-emerald-800/40 to-teal-900/40 border-teal-400/50', light: 'from-teal-50 to-white border-teal-300' },
|
||||
narrative: { dark: 'from-amber-700/40 to-amber-900/40 border-amber-400/50', light: 'from-amber-50 to-white border-amber-300' },
|
||||
};
|
||||
|
||||
const VARIANT_ICON: Record<LayoutNode['variant'], React.ComponentType<{ className?: string }>> = {
|
||||
agent: Bot,
|
||||
purpose: Target,
|
||||
actor: UserCheck,
|
||||
behavior: TrendingUp,
|
||||
driver: Zap,
|
||||
narrative: ScrollText,
|
||||
};
|
||||
|
||||
// Colored icon wrapper per variant (dark / light)
|
||||
const VARIANT_ICON_CLS: Record<LayoutNode['variant'], { dark: string; light: string }> = {
|
||||
agent: { dark: 'bg-slate-600/60 text-slate-200', light: 'bg-slate-100 text-slate-600' },
|
||||
purpose: { dark: 'bg-violet-500/25 text-violet-200', light: 'bg-orange-100 text-orange-600' },
|
||||
actor: { dark: 'bg-cyan-500/25 text-cyan-200', light: 'bg-red-100 text-red-600' },
|
||||
behavior: { dark: 'bg-emerald-500/25 text-emerald-200', light: 'bg-emerald-100 text-emerald-700' },
|
||||
driver: { dark: 'bg-teal-500/25 text-teal-200', light: 'bg-teal-100 text-teal-700' },
|
||||
narrative: { dark: 'bg-amber-500/25 text-amber-200', light: 'bg-amber-100 text-amber-600' },
|
||||
};
|
||||
|
||||
const SCALE_MIN = 0.3;
|
||||
const SCALE_MAX = 2.5;
|
||||
|
||||
const buildExpertRootDetail = (expert: ExpertCard): string => {
|
||||
const segments = [expert.title, expert.detail].filter((item): item is string => Boolean(item && item.trim()));
|
||||
return segments.join(' · ') || '已确认专家';
|
||||
};
|
||||
|
||||
const CompactNode: React.FC<{
|
||||
node: LayoutNode;
|
||||
isDark: boolean;
|
||||
appearing: boolean;
|
||||
collapsible: boolean;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse?: (nodeId: string) => void;
|
||||
}> = ({ node, isDark, appearing, collapsible, collapsed, onToggleCollapse }) => {
|
||||
const Icon = VARIANT_ICON[node.variant];
|
||||
const grad = VARIANT_GRADIENT[node.variant][isDark ? 'dark' : 'light'];
|
||||
const canToggle = collapsible && Boolean(onToggleCollapse);
|
||||
const handleClick = (e: React.MouseEvent<SVGGElement>) => {
|
||||
if (!canToggle) return;
|
||||
e.stopPropagation();
|
||||
onToggleCollapse(node.id);
|
||||
};
|
||||
const handleMouseDown = (e: React.MouseEvent<SVGGElement>) => {
|
||||
if (canToggle) e.stopPropagation();
|
||||
};
|
||||
return (
|
||||
<g
|
||||
className={appearing ? 'sixstep-node-pop' : ''}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
style={canToggle ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
{appearing && (
|
||||
<circle
|
||||
cx={node.x + node.width / 2}
|
||||
cy={node.y + node.height / 2}
|
||||
r={0}
|
||||
stroke={isDark ? '#22d3ee' : '#ef4444'}
|
||||
className="sixstep-ring-burst"
|
||||
/>
|
||||
)}
|
||||
<foreignObject x={node.x} y={node.y} width={node.width} height={node.height}>
|
||||
<div className={`relative w-full h-full rounded-2xl border bg-gradient-to-br ${grad} backdrop-blur-sm flex items-center justify-center gap-1.5 px-2 shadow-sm`}>
|
||||
{collapsible && (
|
||||
<div
|
||||
className={`absolute right-1.5 top-1.5 w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-bold ${
|
||||
isDark ? 'bg-slate-800/75 text-slate-200' : 'bg-white text-slate-600 border border-slate-200'
|
||||
}`}
|
||||
title={collapsed ? '展开子节点' : '收起子节点'}
|
||||
>
|
||||
{collapsed ? '+' : '-'}
|
||||
</div>
|
||||
)}
|
||||
<div className={`w-7 h-7 rounded-lg flex items-center justify-center ${VARIANT_ICON_CLS[node.variant][isDark ? 'dark' : 'light']}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<span className={`text-sm font-bold tracking-wider ${isDark ? 'text-white' : 'text-slate-800'}`}>
|
||||
{VARIANT_ABBREV[node.variant]}
|
||||
</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolbarButton: React.FC<{ title: string; onClick: () => void; isDark: boolean; children: React.ReactNode }> = ({ title, onClick, isDark, children }) => (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className={`sixstep-toolbar-btn ${
|
||||
isDark ? 'text-slate-300 hover:bg-slate-700/70 hover:text-white' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const SixStepFlowSandbox: React.FC<SixStepFlowSandboxProps> = ({ message, width, onResizeStart, onClose, onOpenFullscreen }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const purposes = message?.stages?.purpose?.cards ?? [];
|
||||
const actors = message?.stages?.actor?.cards ?? [];
|
||||
const behaviors = message?.stages?.behavior?.cards ?? [];
|
||||
const narratives = message?.stages?.narrative?.cards ?? [];
|
||||
const selectedExpert = message?.selectedExpert;
|
||||
|
||||
const layout = useMemo(
|
||||
() => buildLayout(purposes, actors, behaviors, narratives, {
|
||||
...COMPACT_OPTIONS,
|
||||
rootTitle: selectedExpert?.name,
|
||||
rootDetail: selectedExpert ? buildExpertRootDetail(selectedExpert) : undefined,
|
||||
}),
|
||||
[purposes, actors, behaviors, narratives, selectedExpert],
|
||||
);
|
||||
|
||||
const currentSub = message?.currentSubStep;
|
||||
const activePair = currentSub ? CURRENT_PAIR_BY_STAGE[currentSub] : null;
|
||||
const statusTitle = currentSub ? STATUS_TITLE[currentSub] : '推演结果可视化';
|
||||
const generating = Boolean(currentSub);
|
||||
|
||||
const [transform, setTransform] = useState({ tx: 0, ty: 0, scale: 1 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const panStart = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
|
||||
|
||||
// Track newly appearing nodes for animation
|
||||
const seenIdsRef = useRef<Set<string>>(new Set());
|
||||
const [appearingIds, setAppearingIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const nodeMap = useMemo(() => {
|
||||
return new Map(layout.nodes.map((node) => [node.id, node]));
|
||||
}, [layout.nodes]);
|
||||
|
||||
const visibleNodeSet = useMemo(() => {
|
||||
const visible = new Set<string>();
|
||||
layout.nodes.forEach((node) => {
|
||||
let parentId = node.parent;
|
||||
let hidden = false;
|
||||
while (parentId) {
|
||||
if (collapsedNodeIds.has(parentId)) {
|
||||
hidden = true;
|
||||
break;
|
||||
}
|
||||
parentId = nodeMap.get(parentId)?.parent;
|
||||
}
|
||||
if (!hidden) visible.add(node.id);
|
||||
});
|
||||
return visible;
|
||||
}, [collapsedNodeIds, layout.nodes, nodeMap]);
|
||||
|
||||
const visibleNodes = useMemo(
|
||||
() => layout.nodes.filter((node) => visibleNodeSet.has(node.id)),
|
||||
[layout.nodes, visibleNodeSet],
|
||||
);
|
||||
|
||||
const visibleEdges = useMemo(
|
||||
() => layout.edges.filter((edge) => visibleNodeSet.has(edge.fromId) && visibleNodeSet.has(edge.toId)),
|
||||
[layout.edges, visibleNodeSet],
|
||||
);
|
||||
|
||||
const toggleNodeCollapse = (nodeId: string) => {
|
||||
const targetNode = nodeMap.get(nodeId);
|
||||
if (!targetNode || targetNode.children.length === 0) return;
|
||||
setCollapsedNodeIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) next.delete(nodeId);
|
||||
else next.add(nodeId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const validIds = new Set(
|
||||
layout.nodes.filter((node) => node.children.length > 0).map((node) => node.id),
|
||||
);
|
||||
setCollapsedNodeIds((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((id) => {
|
||||
if (validIds.has(id)) next.add(id);
|
||||
else changed = true;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [layout.nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const newIds: string[] = [];
|
||||
visibleNodes.forEach((n) => {
|
||||
if (!seenIdsRef.current.has(n.id)) {
|
||||
seenIdsRef.current.add(n.id);
|
||||
newIds.push(n.id);
|
||||
}
|
||||
});
|
||||
if (newIds.length > 0) {
|
||||
setAppearingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
newIds.forEach((id) => next.add(id));
|
||||
return next;
|
||||
});
|
||||
const timer = window.setTimeout(() => {
|
||||
setAppearingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
newIds.forEach((id) => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
}, 700);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [visibleNodes]);
|
||||
|
||||
// Native wheel listener with passive:false so preventDefault actually blocks browser zoom
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
const delta = -e.deltaY * 0.0015;
|
||||
setTransform((prev) => {
|
||||
const nextScale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, prev.scale * (1 + delta)));
|
||||
const ratio = nextScale / prev.scale;
|
||||
return { tx: mx - (mx - prev.tx) * ratio, ty: my - (my - prev.ty) * ratio, scale: nextScale };
|
||||
});
|
||||
};
|
||||
container.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => container.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
const handlePanStart = (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
panStart.current = { x: e.clientX, y: e.clientY, tx: transform.tx, ty: transform.ty };
|
||||
setIsPanning(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPanning) return;
|
||||
const move = (e: MouseEvent) => {
|
||||
const start = panStart.current;
|
||||
if (!start) return;
|
||||
setTransform((prev) => ({ ...prev, tx: start.tx + (e.clientX - start.x), ty: start.ty + (e.clientY - start.y) }));
|
||||
};
|
||||
const up = () => {
|
||||
panStart.current = null;
|
||||
setIsPanning(false);
|
||||
};
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', move);
|
||||
window.removeEventListener('mouseup', up);
|
||||
};
|
||||
}, [isPanning]);
|
||||
|
||||
const fitToView = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container || layout.width <= 0 || layout.height <= 0) {
|
||||
setTransform({ tx: 0, ty: 0, scale: 1 });
|
||||
return;
|
||||
}
|
||||
const rect = container.getBoundingClientRect();
|
||||
const scale = Math.min((rect.width - 40) / layout.width, (rect.height - 40) / layout.height, 1);
|
||||
const tx = (rect.width - layout.width * scale) / 2;
|
||||
const ty = (rect.height - layout.height * scale) / 2;
|
||||
setTransform({ tx, ty, scale });
|
||||
};
|
||||
|
||||
const centerView = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
setTransform((prev) => ({
|
||||
...prev,
|
||||
tx: (rect.width - layout.width * prev.scale) / 2,
|
||||
ty: (rect.height - layout.height * prev.scale) / 2,
|
||||
}));
|
||||
};
|
||||
|
||||
const zoomIn = () => setTransform((prev) => ({ ...prev, scale: Math.min(SCALE_MAX, prev.scale * 1.2) }));
|
||||
const zoomOut = () => setTransform((prev) => ({ ...prev, scale: Math.max(SCALE_MIN, prev.scale / 1.2) }));
|
||||
const reset = () => setTransform({ tx: 0, ty: 0, scale: 1 });
|
||||
|
||||
// Auto-fit on first render with content
|
||||
const fitAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!fitAppliedRef.current && layout.nodes.length > 1) {
|
||||
fitToView();
|
||||
fitAppliedRef.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [layout.nodes.length]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative h-full flex flex-col border-l ${
|
||||
isDark ? 'border-slate-700/60 bg-slate-900/60' : 'border-red-100 bg-white'
|
||||
}`}
|
||||
style={{ width }}
|
||||
>
|
||||
<div className="sixstep-resize-handle" onMouseDown={onResizeStart} />
|
||||
|
||||
<div className={`flex items-center justify-between gap-3 px-4 py-3 border-b ${
|
||||
isDark ? 'border-slate-700/60 bg-slate-900/40' : 'border-red-100 bg-gradient-to-r from-red-50/60 to-orange-50/40'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={`w-8 h-8 rounded-2xl flex items-center justify-center ${
|
||||
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
|
||||
} ${generating ? 'sixstep-pulse' : ''}`}>
|
||||
<Compass className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className={`text-sm font-semibold truncate ${
|
||||
generating ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-white' : 'text-gray-900')
|
||||
}`}>
|
||||
{statusTitle}
|
||||
</div>
|
||||
<div className={`text-[11px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
简化视图 · 节点缩写显示
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
isDark ? 'text-slate-400 hover:text-white hover:bg-slate-700/60' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-100'
|
||||
}`}
|
||||
title="关闭沙箱"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center gap-1 px-3 py-2 border-b ${
|
||||
isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'
|
||||
}`}>
|
||||
<ToolbarButton title="放大" onClick={zoomIn} isDark={isDark}><Plus className="w-4 h-4" /></ToolbarButton>
|
||||
<ToolbarButton title="缩小" onClick={zoomOut} isDark={isDark}><Minus className="w-4 h-4" /></ToolbarButton>
|
||||
<ToolbarButton title="恢复 1:1" onClick={reset} isDark={isDark}><RotateCcw className="w-4 h-4" /></ToolbarButton>
|
||||
<ToolbarButton title="定位到画布中央" onClick={centerView} isDark={isDark}><Crosshair className="w-4 h-4" /></ToolbarButton>
|
||||
<span className={`mx-2 h-5 w-px ${isDark ? 'bg-slate-700' : 'bg-gray-200'}`} />
|
||||
<span className={`text-[11px] tabular-nums ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{Math.round(transform.scale * 100)}%
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<ToolbarButton title="全屏查看完整流程图" onClick={onOpenFullscreen} isDark={isDark}>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center gap-3 px-3 py-1.5 border-b text-[11px] flex-wrap ${
|
||||
isDark ? 'border-slate-700/60 text-slate-400 bg-slate-900/30' : 'border-red-100 text-gray-500 bg-white'
|
||||
}`}>
|
||||
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
|
||||
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
|
||||
const active = activePair === pair;
|
||||
return (
|
||||
<span key={pair} className="inline-flex items-center gap-1.5">
|
||||
<span className={`inline-block w-5 h-0.5 rounded-full ${active ? 'sixstep-ant-line' : ''}`} style={{ background: color, stroke: color }} />
|
||||
<span className={active ? (isDark ? 'text-cyan-200' : 'text-red-600') : ''}>{EDGE_LABEL[pair]}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`flex-1 overflow-hidden relative ${isPanning ? 'sixstep-canvas-grabbing' : 'sixstep-canvas-grab'}`}
|
||||
onMouseDown={handlePanStart}
|
||||
>
|
||||
{visibleNodes.length <= 1 ? (
|
||||
<div className={`h-full flex flex-col items-center justify-center text-center ${
|
||||
isDark ? 'text-slate-500' : 'text-gray-400'
|
||||
}`}>
|
||||
<Megaphone className="w-10 h-10 opacity-40 mb-3" />
|
||||
<div className={`text-sm font-medium ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>等待推演开始</div>
|
||||
<div className="text-xs mt-1">推演产出将以流程图形式呈现</div>
|
||||
</div>
|
||||
) : (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="block select-none"
|
||||
>
|
||||
<defs>
|
||||
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
|
||||
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
|
||||
return (
|
||||
<marker
|
||||
key={pair}
|
||||
id={`sixstep-sandbox-arrow-${pair}`}
|
||||
viewBox="0 0 8 8"
|
||||
refX="7"
|
||||
refY="4"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 8 4 L 0 8 z" fill={color} />
|
||||
</marker>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
|
||||
<g transform={`translate(${transform.tx}, ${transform.ty}) scale(${transform.scale})`}>
|
||||
{visibleEdges.map((edge) => {
|
||||
const from = nodeMap.get(edge.fromId);
|
||||
const to = nodeMap.get(edge.toId);
|
||||
if (!from || !to) return null;
|
||||
const color = EDGE_COLOR[edge.pair][isDark ? 'dark' : 'light'];
|
||||
const isActive = activePair === edge.pair;
|
||||
return (
|
||||
<path
|
||||
key={edge.id}
|
||||
d={buildEdgePath(from, to)}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={isActive ? 2.2 : 1.6}
|
||||
strokeOpacity={isActive ? 1 : 0.7}
|
||||
className={isActive ? 'sixstep-ant-line' : ''}
|
||||
markerEnd={`url(#sixstep-sandbox-arrow-${edge.pair})`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{visibleNodes.map((node) => (
|
||||
<CompactNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
isDark={isDark}
|
||||
appearing={appearingIds.has(node.id)}
|
||||
collapsible={node.children.length > 0}
|
||||
collapsed={collapsedNodeIds.has(node.id)}
|
||||
onToggleCollapse={toggleNodeCollapse}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
<div className={`absolute bottom-3 left-3 px-2 py-1 rounded-md text-[10px] backdrop-blur-md ${
|
||||
isDark ? 'bg-slate-800/70 text-slate-400 border border-slate-700/50' : 'bg-white/90 text-gray-500 border border-gray-200'
|
||||
}`}>
|
||||
{VARIANT_LABEL.purpose}=md · {VARIANT_LABEL.actor}=xwt · {VARIANT_LABEL.behavior}=yqxw · {VARIANT_LABEL.driver}=qdys · {VARIANT_LABEL.narrative}=xs
|
||||
</div>
|
||||
<div className={`absolute bottom-3 right-3 px-2 py-1 rounded-md text-[10px] backdrop-blur-md ${
|
||||
isDark ? 'bg-slate-800/70 text-slate-400 border border-slate-700/50' : 'bg-white/90 text-gray-500 border border-gray-200'
|
||||
}`}>
|
||||
点击父节点可收起/展开子节点
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SixStepFlowSandbox;
|
||||
@@ -0,0 +1,314 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Database,
|
||||
FileCheck2,
|
||||
Loader2,
|
||||
Megaphone,
|
||||
Sparkles,
|
||||
Target,
|
||||
Users,
|
||||
Workflow,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import type { MainStepKey, StepStatus, SubStepKey } from './types';
|
||||
|
||||
interface SixStepProgressProps {
|
||||
mainStatus: Record<MainStepKey, StepStatus>;
|
||||
subStatus: Record<SubStepKey, StepStatus>;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
interface StepItem<T extends string> {
|
||||
key: T;
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: { dark: string; light: string };
|
||||
}
|
||||
|
||||
const MAIN_STEPS: StepItem<MainStepKey>[] = [
|
||||
{ key: 'info', title: '信息准备', desc: '梳理输入素材与上下文', icon: Database, color: { dark: 'text-sky-300 bg-sky-500/15 border-sky-400/40', light: 'text-sky-600 bg-sky-50 border-sky-200' } },
|
||||
{ key: 'sixstep', title: '6 步法推演', desc: '依次完成四个阶段推演', icon: Workflow, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-violet-50 border-violet-200' } },
|
||||
{ key: 'final', title: '最终结果生成', desc: '汇总各阶段产出,输出结论', icon: FileCheck2, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
|
||||
];
|
||||
|
||||
const SUB_STEPS: StepItem<SubStepKey>[] = [
|
||||
{ key: 'purpose', title: '目的生成', desc: '识别核心目的方向', icon: Target, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-orange-50 border-orange-200' } },
|
||||
{ key: 'actor', title: '行为体生成', desc: '匹配行为体角色', icon: Users, color: { dark: 'text-cyan-300 bg-cyan-500/15 border-cyan-400/40', light: 'text-red-600 bg-red-50 border-red-200' } },
|
||||
{ key: 'behavior', title: '预期行为与驱动因素', desc: '推演行为与动因', icon: Zap, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
|
||||
{ key: 'narrative', title: '叙事生成', desc: '构建叙事框架', icon: Megaphone,color: { dark: 'text-amber-300 bg-amber-500/15 border-amber-400/40', light: 'text-amber-600 bg-amber-50 border-amber-200' } },
|
||||
];
|
||||
|
||||
function dotColor(status: StepStatus, isDark: boolean) {
|
||||
if (status === 'done') return isDark ? 'bg-emerald-400' : 'bg-emerald-500';
|
||||
if (status === 'running') return isDark ? 'bg-cyan-400 sixstep-pulse' : 'bg-red-500 sixstep-pulse';
|
||||
return isDark ? 'bg-slate-600' : 'bg-gray-300';
|
||||
}
|
||||
|
||||
function StatusIcon({ status, isDark }: { status: StepStatus; isDark: boolean }) {
|
||||
if (status === 'done') return <CheckCircle2 className={`w-4 h-4 ${isDark ? 'text-emerald-300' : 'text-emerald-500'}`} />;
|
||||
if (status === 'running') return <Loader2 className={`w-4 h-4 sixstep-spin-soft ${isDark ? 'text-cyan-300' : 'text-red-500'}`} />;
|
||||
return <Circle className={`w-3.5 h-3.5 ${isDark ? 'text-slate-500' : 'text-gray-300'}`} />;
|
||||
}
|
||||
|
||||
function cardBorder(status: StepStatus, isDark: boolean) {
|
||||
if (status === 'running') return isDark
|
||||
? 'border-cyan-400/40 bg-cyan-500/5 shadow-[0_0_0_1px_rgba(34,211,238,0.15)]'
|
||||
: 'border-red-300 bg-red-50/60 shadow-sm';
|
||||
return isDark ? 'border-slate-700/60 bg-slate-800/40' : 'border-gray-200 bg-white';
|
||||
}
|
||||
|
||||
function connectorColor(status: StepStatus, isDark: boolean) {
|
||||
return status === 'done'
|
||||
? isDark ? 'bg-emerald-400/40' : 'bg-emerald-300'
|
||||
: isDark ? 'bg-slate-700/60' : 'bg-gray-200';
|
||||
}
|
||||
|
||||
// ── Collapsed view ────────────────────────────────────────────────────────────
|
||||
const CollapsedView: React.FC<{
|
||||
mainStatus: Record<MainStepKey, StepStatus>;
|
||||
subStatus: Record<SubStepKey, StepStatus>;
|
||||
isDark: boolean;
|
||||
onExpand: () => void;
|
||||
}> = ({ mainStatus, subStatus, isDark, onExpand }) => {
|
||||
const sixstepUnlocked = mainStatus.sixstep !== 'pending';
|
||||
return (
|
||||
<div className={`h-full flex flex-col items-center py-3 gap-3 border-r ${
|
||||
isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/60'
|
||||
}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
title="展开进度面板"
|
||||
className={`w-9 h-9 rounded-xl flex items-center justify-center transition-colors ${
|
||||
isDark ? 'text-slate-400 hover:text-white hover:bg-slate-700/60' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 flex-1">
|
||||
{MAIN_STEPS.map((step) => {
|
||||
const status = mainStatus[step.key];
|
||||
const Icon = step.icon;
|
||||
const clr = step.color[isDark ? 'dark' : 'light'];
|
||||
return (
|
||||
<div key={step.key} className="relative flex flex-col items-center gap-1" title={step.title}>
|
||||
<div className={`w-9 h-9 rounded-xl border flex items-center justify-center ${clr} ${
|
||||
status === 'running' ? 'sixstep-pulse' : ''
|
||||
}`}>
|
||||
{status === 'running'
|
||||
? <Loader2 className="w-4 h-4 sixstep-spin-soft" />
|
||||
: <Icon className="w-4 h-4" />
|
||||
}
|
||||
</div>
|
||||
<span className={`w-2 h-2 rounded-full ${dotColor(status, isDark)}`} />
|
||||
|
||||
{step.key === 'sixstep' && sixstepUnlocked && (
|
||||
<div className="flex flex-col items-center gap-1 mt-1">
|
||||
{SUB_STEPS.map((sub) => {
|
||||
const ss = subStatus[sub.key];
|
||||
const SubIcon = sub.icon;
|
||||
const sc = sub.color[isDark ? 'dark' : 'light'];
|
||||
return (
|
||||
<div key={sub.key} title={sub.title} className={`w-7 h-7 rounded-lg border flex items-center justify-center ${sc} ${
|
||||
ss === 'running' ? 'sixstep-pulse' : ''
|
||||
}`}>
|
||||
{ss === 'running'
|
||||
? <Loader2 className="w-3 h-3 sixstep-spin-soft" />
|
||||
: <SubIcon className="w-3 h-3" />
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Expanded view ─────────────────────────────────────────────────────────────
|
||||
const SixStepProgress: React.FC<SixStepProgressProps> = ({
|
||||
mainStatus,
|
||||
subStatus,
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const sixstepUnlocked = mainStatus.sixstep !== 'pending';
|
||||
const sixstepRunning = mainStatus.sixstep === 'running';
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<CollapsedView
|
||||
mainStatus={mainStatus}
|
||||
subStatus={subStatus}
|
||||
isDark={isDark}
|
||||
onExpand={() => onToggleCollapse?.()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-full flex flex-col overflow-hidden border-r ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/60'}`}>
|
||||
{/* Header */}
|
||||
<div className={`px-4 py-3 border-b flex items-center gap-2 ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
|
||||
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center shrink-0 ${
|
||||
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
|
||||
}`}>
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>生成进度</div>
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>实时跟踪推演阶段</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
title="收起进度面板"
|
||||
className={`w-8 h-8 rounded-xl flex items-center justify-center transition-colors shrink-0 ${
|
||||
isDark ? 'text-slate-400 hover:text-white hover:bg-slate-700/60' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
|
||||
{MAIN_STEPS.map((step, idx) => {
|
||||
const status = mainStatus[step.key];
|
||||
const Icon = step.icon;
|
||||
const clr = step.color[isDark ? 'dark' : 'light'];
|
||||
const showSubBlock = step.key === 'sixstep' && sixstepUnlocked;
|
||||
|
||||
return (
|
||||
<div key={step.key} className="relative">
|
||||
{idx < MAIN_STEPS.length - 1 && (
|
||||
<div className={`absolute left-[22px] top-12 bottom-[-12px] w-px ${connectorColor(status, isDark)}`} />
|
||||
)}
|
||||
<div className={`relative rounded-2xl border p-3 transition-all ${cardBorder(status, isDark)}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-11 h-11 rounded-2xl border flex items-center justify-center shrink-0 transition-colors ${clr} ${
|
||||
status === 'running' ? 'sixstep-pulse' : ''
|
||||
}`}>
|
||||
{status === 'running'
|
||||
? <Loader2 className="w-5 h-5 sixstep-spin-soft" />
|
||||
: <Icon className="w-5 h-5" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className={`text-sm font-semibold truncate ${
|
||||
status === 'running'
|
||||
? isDark ? 'text-white' : 'text-gray-900'
|
||||
: status === 'done'
|
||||
? isDark ? 'text-slate-100' : 'text-gray-800'
|
||||
: isDark ? 'text-slate-300' : 'text-gray-600'
|
||||
}`}>{step.title}</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] flex items-center gap-1 ${
|
||||
status === 'done'
|
||||
? isDark ? 'bg-emerald-500/20 text-emerald-300 border border-emerald-400/30' : 'bg-emerald-50 text-emerald-600 border border-emerald-200'
|
||||
: status === 'running'
|
||||
? isDark ? 'bg-cyan-500/20 text-cyan-200 border border-cyan-400/40' : 'bg-red-50 text-red-600 border border-red-200'
|
||||
: isDark ? 'bg-slate-700/40 text-slate-400 border border-slate-600/40' : 'bg-gray-100 text-gray-500 border border-gray-200'
|
||||
}`}>
|
||||
<StatusIcon status={status} isDark={isDark} />
|
||||
<span>{status === 'done' ? '已完成' : status === 'running' ? '生成中' : '待开始'}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-xs mt-1 ${
|
||||
status === 'running'
|
||||
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
|
||||
: isDark ? 'text-slate-400' : 'text-gray-500'
|
||||
}`}>{step.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6步法子阶段 */}
|
||||
{showSubBlock && (
|
||||
<div className="mt-3 pl-1">
|
||||
<div className={`text-[10px] font-medium tracking-wider uppercase mb-2 ${
|
||||
isDark ? 'text-slate-500' : 'text-gray-400'
|
||||
}`}>
|
||||
6 步法 · 子阶段
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{SUB_STEPS.map((sub, subIdx) => {
|
||||
const ss = subStatus[sub.key];
|
||||
const SubIcon = sub.icon;
|
||||
const sc = sub.color[isDark ? 'dark' : 'light'];
|
||||
return (
|
||||
<div key={sub.key} className="relative pl-4 sixstep-fade-up">
|
||||
{subIdx < SUB_STEPS.length - 1 && (
|
||||
<div className={`absolute left-[7px] top-7 bottom-[-8px] w-px ${connectorColor(ss, isDark)}`} />
|
||||
)}
|
||||
<div className={`absolute left-0 top-3 w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center ${
|
||||
ss === 'running'
|
||||
? isDark ? 'bg-cyan-500/30 border-cyan-300' : 'bg-red-100 border-red-400'
|
||||
: ss === 'done'
|
||||
? isDark ? 'bg-emerald-500/30 border-emerald-300' : 'bg-emerald-100 border-emerald-400'
|
||||
: isDark ? 'bg-slate-900 border-slate-600' : 'bg-white border-gray-300'
|
||||
}`} />
|
||||
<div className={`flex items-start gap-2 rounded-xl border p-2 ${
|
||||
ss === 'running'
|
||||
? isDark ? 'border-cyan-400/40 bg-cyan-500/5' : 'border-red-300 bg-red-50/70'
|
||||
: isDark ? 'border-slate-700/50 bg-slate-800/40' : 'border-gray-200 bg-white'
|
||||
}`}>
|
||||
<div className={`w-7 h-7 rounded-lg border flex items-center justify-center shrink-0 ${sc} ${
|
||||
ss === 'running' ? 'sixstep-pulse' : ''
|
||||
}`}>
|
||||
{ss === 'running'
|
||||
? <Loader2 className="w-3.5 h-3.5 sixstep-spin-soft" />
|
||||
: <SubIcon className="w-3.5 h-3.5" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className={`text-xs font-semibold truncate ${
|
||||
ss === 'done' ? isDark ? 'text-slate-100' : 'text-gray-800'
|
||||
: ss === 'running' ? isDark ? 'text-white' : 'text-gray-900'
|
||||
: isDark ? 'text-slate-300' : 'text-gray-600'
|
||||
}`}>{sub.title}</div>
|
||||
<StatusIcon status={ss} isDark={isDark} />
|
||||
</div>
|
||||
<div className={`text-[11px] mt-0.5 ${
|
||||
ss === 'running'
|
||||
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
|
||||
: isDark ? 'text-slate-500' : 'text-gray-400'
|
||||
}`}>{sub.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{sixstepRunning && (
|
||||
<div className={`mt-2 text-[11px] flex items-center gap-1.5 ${isDark ? 'text-cyan-300' : 'text-red-500'}`}>
|
||||
<Loader2 className="w-3 h-3 sixstep-spin-soft" />
|
||||
<span className={isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}>推演正在进行...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SixStepProgress;
|
||||
@@ -0,0 +1,196 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Database,
|
||||
FileCheck2,
|
||||
Loader2,
|
||||
Megaphone,
|
||||
Target,
|
||||
Users,
|
||||
Workflow,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import type { MainStepKey, StepStatus, SubStepKey } from './types';
|
||||
|
||||
interface Props {
|
||||
mainStatus: Record<MainStepKey, StepStatus>;
|
||||
subStatus: Record<SubStepKey, StepStatus>;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
interface StepDef<T extends string> {
|
||||
key: T;
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: { dark: string; light: string };
|
||||
}
|
||||
|
||||
const MAIN_STEPS: StepDef<MainStepKey>[] = [
|
||||
{ key: 'info', title: '信息准备', desc: '梳理输入素材与上下文', icon: Database, color: { dark: 'text-sky-300 bg-sky-500/15 border-sky-400/40', light: 'text-sky-600 bg-sky-50 border-sky-200' } },
|
||||
{ key: 'sixstep', title: '6 步法推演', desc: '依次完成四个阶段推演', icon: Workflow, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-violet-50 border-violet-200' } },
|
||||
{ key: 'final', title: '最终结果生成', desc: '汇总各阶段产出,输出结论', icon: FileCheck2, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
|
||||
];
|
||||
|
||||
const SUB_STEPS: StepDef<SubStepKey>[] = [
|
||||
{ key: 'purpose', title: '目的生成', desc: '识别核心目的方向', icon: Target, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-orange-50 border-orange-200' } },
|
||||
{ key: 'actor', title: '行为体生成', desc: '匹配行为体角色', icon: Users, color: { dark: 'text-cyan-300 bg-cyan-500/15 border-cyan-400/40', light: 'text-red-600 bg-red-50 border-red-200' } },
|
||||
{ key: 'behavior', title: '预期行为与驱动因素', desc: '推演行为与动因', icon: Zap, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
|
||||
{ key: 'narrative', title: '叙事生成', desc: '构建叙事框架', icon: Megaphone, color: { dark: 'text-amber-300 bg-amber-500/15 border-amber-400/40', light: 'text-amber-600 bg-amber-50 border-amber-200' } },
|
||||
];
|
||||
|
||||
function statusLabel(s: StepStatus) {
|
||||
return s === 'done' ? '已完成' : s === 'running' ? '生成中' : '待开始';
|
||||
}
|
||||
|
||||
function nodeStateClass(status: StepStatus, isDark: boolean) {
|
||||
if (status === 'running') {
|
||||
return `${isDark ? 'ring-2 ring-cyan-400/30' : 'ring-2 ring-red-200'} sixstep-pulse`;
|
||||
}
|
||||
if (status === 'done') {
|
||||
return isDark ? 'ring-1 ring-emerald-400/40' : 'ring-1 ring-emerald-300';
|
||||
}
|
||||
return isDark ? 'opacity-55 saturate-50' : 'opacity-70 saturate-50';
|
||||
}
|
||||
|
||||
function connectorClass(status: StepStatus, isDark: boolean) {
|
||||
if (status === 'done') return isDark ? 'bg-emerald-400/60' : 'bg-emerald-400';
|
||||
if (status === 'running') return isDark ? 'bg-cyan-400/50' : 'bg-red-300';
|
||||
return isDark ? 'bg-slate-700/60' : 'bg-gray-200';
|
||||
}
|
||||
|
||||
interface NodeProps {
|
||||
status: StepStatus;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
size?: 'md' | 'sm';
|
||||
title: string;
|
||||
desc: string;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const StepNode: React.FC<NodeProps> = ({ status, Icon, color, size = 'md', title, desc, isDark }) => {
|
||||
const wrapSize = size === 'md' ? 'w-9 h-9' : 'w-7 h-7';
|
||||
const iconSize = size === 'md' ? 'w-4 h-4' : 'w-3.5 h-3.5';
|
||||
const badgeSize = size === 'md' ? 'w-4 h-4' : 'w-3.5 h-3.5';
|
||||
const badgeIcon = size === 'md' ? 'w-3 h-3' : 'w-2.5 h-2.5';
|
||||
|
||||
return (
|
||||
<div className="relative group shrink-0">
|
||||
<div
|
||||
className={`${wrapSize} rounded-xl border flex items-center justify-center transition-all ${color} ${nodeStateClass(status, isDark)}`}
|
||||
>
|
||||
{status === 'running'
|
||||
? <Loader2 className={`${iconSize} sixstep-spin-soft`} />
|
||||
: <Icon className={iconSize} />
|
||||
}
|
||||
</div>
|
||||
|
||||
{status === 'done' && (
|
||||
<div
|
||||
className={`absolute -bottom-0.5 -right-0.5 ${badgeSize} rounded-full flex items-center justify-center shadow-sm ${
|
||||
isDark ? 'bg-emerald-500 text-slate-900' : 'bg-emerald-500 text-white'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className={badgeIcon} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2.5 py-1.5 rounded-lg whitespace-nowrap pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-50 shadow-lg border ${
|
||||
isDark
|
||||
? 'bg-slate-900 text-slate-100 border-slate-700'
|
||||
: 'bg-white text-gray-800 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-semibold">{title}</div>
|
||||
<div
|
||||
className={`text-[11px] ${
|
||||
status === 'running'
|
||||
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
|
||||
: isDark ? 'text-slate-400' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{desc}
|
||||
</div>
|
||||
<div
|
||||
className={`text-[10px] mt-0.5 flex items-center gap-1 ${
|
||||
status === 'done'
|
||||
? isDark ? 'text-emerald-300' : 'text-emerald-600'
|
||||
: status === 'running'
|
||||
? isDark ? 'text-cyan-300' : 'text-red-500'
|
||||
: isDark ? 'text-slate-500' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{status === 'running' && <Loader2 className="w-2.5 h-2.5 sixstep-spin-soft" />}
|
||||
<span>{statusLabel(status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SixStepProgressHorizontal: React.FC<Props> = ({ mainStatus, subStatus, isDark }) => {
|
||||
const sixstepUnlocked = mainStatus.sixstep !== 'pending';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-nowrap py-1">
|
||||
{MAIN_STEPS.map((step, idx) => {
|
||||
const status = mainStatus[step.key];
|
||||
const clr = step.color[isDark ? 'dark' : 'light'];
|
||||
const isSixStep = step.key === 'sixstep';
|
||||
const prevStatus = idx > 0 ? mainStatus[MAIN_STEPS[idx - 1].key] : 'pending';
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div className={`h-[2px] w-5 rounded shrink-0 transition-colors ${connectorClass(prevStatus, isDark)}`} />
|
||||
)}
|
||||
<StepNode
|
||||
status={status}
|
||||
Icon={step.icon}
|
||||
color={clr}
|
||||
title={step.title}
|
||||
desc={step.desc}
|
||||
isDark={isDark}
|
||||
/>
|
||||
{isSixStep && sixstepUnlocked && (
|
||||
<>
|
||||
<div className={`h-[2px] w-3 rounded shrink-0 transition-colors ${connectorClass(status, isDark)}`} />
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-xl border shrink-0 ${
|
||||
isDark ? 'bg-slate-900/40 border-slate-700/60' : 'bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{SUB_STEPS.map((sub, sidx) => {
|
||||
const ss = subStatus[sub.key];
|
||||
const sc = sub.color[isDark ? 'dark' : 'light'];
|
||||
const prevSub = sidx > 0 ? subStatus[SUB_STEPS[sidx - 1].key] : 'pending';
|
||||
return (
|
||||
<React.Fragment key={sub.key}>
|
||||
{sidx > 0 && (
|
||||
<div className={`h-[2px] w-2 rounded shrink-0 transition-colors ${connectorClass(prevSub, isDark)}`} />
|
||||
)}
|
||||
<StepNode
|
||||
status={ss}
|
||||
Icon={sub.icon}
|
||||
color={sc}
|
||||
size="sm"
|
||||
title={sub.title}
|
||||
desc={sub.desc}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SixStepProgressHorizontal;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, XCircle } from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import type { StreamErrorItem } from './types';
|
||||
|
||||
interface StreamErrorNoticeProps {
|
||||
errors: StreamErrorItem[];
|
||||
}
|
||||
|
||||
const StreamErrorNotice: React.FC<StreamErrorNoticeProps> = ({ errors }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!errors || errors.length === 0) return null;
|
||||
|
||||
const hasErrors = errors.some((e) => e.level === 'error');
|
||||
const errorCount = errors.filter((e) => e.level === 'error').length;
|
||||
const warnCount = errors.filter((e) => e.level === 'warning').length;
|
||||
|
||||
let summaryText: string;
|
||||
if (hasErrors && warnCount > 0) {
|
||||
summaryText = `${errorCount} 个步骤失败,${warnCount} 个能力调用降级`;
|
||||
} else if (hasErrors) {
|
||||
summaryText = `${errorCount} 个步骤执行失败`;
|
||||
} else {
|
||||
summaryText = `${warnCount} 个能力调用失败,系统已继续执行`;
|
||||
}
|
||||
|
||||
const borderClass = hasErrors
|
||||
? isDark ? 'border-red-500/40 bg-red-500/10 text-red-200' : 'border-red-200 bg-red-50 text-red-700'
|
||||
: isDark ? 'border-amber-500/40 bg-amber-500/10 text-amber-200' : 'border-amber-200 bg-amber-50 text-amber-700';
|
||||
|
||||
const dividerClass = hasErrors
|
||||
? isDark ? 'border-red-500/20' : 'border-red-200'
|
||||
: isDark ? 'border-amber-500/20' : 'border-amber-200';
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border text-xs ${borderClass}`}>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{hasErrors
|
||||
? <XCircle className="w-3.5 h-3.5 shrink-0" />
|
||||
: <AlertTriangle className="w-3.5 h-3.5 shrink-0" />}
|
||||
<span className="flex-1 font-medium">{summaryText}</span>
|
||||
{expanded
|
||||
? <ChevronUp className="w-3.5 h-3.5 shrink-0 opacity-50" />
|
||||
: <ChevronDown className="w-3.5 h-3.5 shrink-0 opacity-50" />}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className={`border-t px-3 py-2 space-y-2.5 ${dividerClass}`}>
|
||||
{errors.map((err) => (
|
||||
<div key={err.errorId}>
|
||||
<div className="flex items-start gap-1.5">
|
||||
{err.level === 'error'
|
||||
? <XCircle className="w-3 h-3 mt-0.5 shrink-0" />
|
||||
: <AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />}
|
||||
<span className="leading-snug">{err.message}</span>
|
||||
</div>
|
||||
<div className={`mt-0.5 pl-[18px] font-mono text-[10px] opacity-40`}>
|
||||
错误 ID:{err.errorId}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamErrorNotice;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,826 @@
|
||||
# 6 步法推演问答页面 — 开发说明文档
|
||||
|
||||
> 所在目录:`src/components/angentchat/`(主页面 `SixStepChatPage.tsx`)
|
||||
> 路由地址:`/qa-center/sixstep-chat`
|
||||
> 菜单入口:问答中心 → **6bf**(侧栏文案见 `src/components/Sidebar.tsx`)
|
||||
> 同模块相关路由:`/qa-center/sixstep-agents`、`/qa-center/sixstep-agent-edit`
|
||||
> **最后更新(摘要)**:2026-05-12 — 流式 **`error_log`** / **`errors[]`** 与 `StreamErrorNotice`(后端 skill 失败摘要可含异常片段);`buildLayout` 根节点 **`rootTitle` / `rootDetail`**(已选专家);**`SixStepFlowFullscreen`** 父节点 **收起/展开子树**(过渡动画、缩略图随可见节点);**`/observability`** 独立全站路由。
|
||||
|
||||
---
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
「6bf 推演问答」由 `SixStepChatPage` 对接后端 **SixStep MCP Backend**。主链路通过 **`POST /api/orchestrate/stream`** 统一流式返回:后端先做 **意图识别**(`intent` 事件),再分支为 **查找专家**(`expert_*`)、**6 步法编排**(信息准备 → 目的 → 行为体 → 预期行为与驱动因素 → 叙事 → 最终结果)、**追问**(`follow_up`,`delta` 流式 Markdown)或 **澄清**(`clarify`)。支持 **结构化卡片** 与 **Markdown** 输出切换;结构化模式下可打开 **流程图沙箱 / 全屏流程图**(`SixStepFlowSandbox` / `SixStepFlowFullscreen`),展示编排返回的实体关系。
|
||||
|
||||
单 MCP 调试仍可直接调用 **`POST /api/agents/{key}/invoke`**(例如 `find_expert`、`create_expert`);6bf 主页面以编排流为主,专家查找也可由流内 `intent` 分支触发。对接细节见同目录 **`INTENT_INTEGRATION.md`**。
|
||||
|
||||
页面布局为 **顶栏 + 下方主体**:顶栏含页面标题、`SixStepProgressHorizontal`(横向主/子步骤进度)、以及结构化·Markdown、聊天记录、流程图沙箱等控制;主体为 **可收起的会话列表轨**(`SixStepChatHistory`)、**中间对话区与输入**(`SixStepConversation` + `ChatInputComposer`,含专家匹配与 MCP 选择弹窗)、以及 **可选的右侧沙箱**(宽度可拖拽,可关闭)。全屏流程图仍以 `fixed` 遮罩覆盖视口,独立于上述布局。
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 顶栏:标题 · SixStepProgressHorizontal · 输出格式 · 聊天记录 · 流程图 │
|
||||
├───┬────────────────────────────────────────────────────┬─────────┤
|
||||
│会话│ SixStepConversation(多轮结构化阶段) │ 流程图 │
|
||||
│列表│ + ChatInputComposer │ 沙箱 │
|
||||
│轨 │ │(可关闭) │
|
||||
└───┴────────────────────────────────────────────────────┴─────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、目录结构与各文件职责
|
||||
|
||||
```
|
||||
src/components/angentchat/
|
||||
│
|
||||
├── types.ts # 数据模型类型(含 phase / intent / rawSteps 等)
|
||||
├── mockData.ts # 本地模拟流式控制器(与 api 侧事件对齐,便于离线演示)
|
||||
├── api.ts # runIntentOrchestrateStream、runSixStepDeduction、runExpertSearch/Create
|
||||
├── flowLayout.ts # SVG 流程图布局
|
||||
├── styles.css # 动画与工具类
|
||||
│
|
||||
├── SixStepChatPage.tsx # 推演主页面(状态中枢)
|
||||
├── SixStepProgressHorizontal.tsx# 顶栏横向主/子步骤进度(主页面使用)
|
||||
├── SixStepProgress.tsx # 纵向进度面板(组件保留;布局以横向为准)
|
||||
├── SixStepChatHistory.tsx # 会话列表轨(展开/收起)
|
||||
├── SixStepConversation.tsx # 对话区(阶段卡片 + 专家匹配)
|
||||
├── SixStepCards.tsx # 阶段卡片原子组件
|
||||
├── ExpertMatchCards.tsx # 专家匹配卡片
|
||||
├── McpSelectModal.tsx # MCP 多选确认弹窗
|
||||
├── StreamErrorNotice.tsx # 助手消息底部:汇总 SSE error_log / 网络类错误的可折叠提示条
|
||||
├── SixStepFlowSandbox.tsx # 右侧流程图沙箱
|
||||
├── SixStepFlowFullscreen.tsx # 全屏流程图
|
||||
├── AgentCardList.tsx # 路由 sixstep-agents:MCP 卡片列表
|
||||
├── AgentEditPage.tsx # 路由 sixstep-agent-edit:单 MCP 调试编辑
|
||||
├── AgentChatMessageList.tsx # 智能体对话消息列表(编辑页等)
|
||||
├── INTENT_INTEGRATION.md # 意图流与前后端字段说明(对接备忘)
|
||||
└── doc.md # 本文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、数据模型(`types.ts`)
|
||||
|
||||
所有类型均从此文件导出,是各组件之间传递数据的唯一契约。
|
||||
|
||||
### 3.1 基础枚举
|
||||
|
||||
| 类型 | 取值 | 说明 |
|
||||
|------|------|------|
|
||||
| `MainStepKey` | `'info' \| 'sixstep' \| 'final'` | 三个主步骤的键名 |
|
||||
| `SubStepKey` | `'purpose' \| 'actor' \| 'behavior' \| 'narrative'` | 6步法的四个子阶段键名 |
|
||||
| `StepStatus` | `'pending' \| 'running' \| 'done'` | 步骤状态,驱动进度面板和动画 |
|
||||
| `SixStepPhase` | `'idle'` \| `'intent-detecting'` \| `'expert-match'` \| `'expert-confirm'` \| `'generate'` \| `'paused'` \| `'completed'` \| `'clarifying'` \| `'follow-up'` | 助手消息所处交互阶段(与编排流、停止、追问等配合) |
|
||||
| `SelectableMcpKey` | `'purpose' \| 'actor' \| 'behavior_factor' \| 'narrative'` | MCP 弹窗可选子链,会随请求传给后端 `selected_mcps` |
|
||||
|
||||
`BackendStepResult`:与后端 `step_end` 原始结构对齐,用于 **`continue_analysis`** 时把 `last_steps` 回传编排接口;`SixStepMessage.rawSteps` 在流式过程中累积,`resumedFrom` 对应 **`resume`** 事件中的继续起点。
|
||||
|
||||
### 3.2 卡片数据结构
|
||||
|
||||
每类卡片都有独立接口,并通过外键(`purposeId`、`actorId`、`driverId`)建立父子关联,构成树形层级关系。
|
||||
|
||||
```
|
||||
PurposeCard (目的)
|
||||
└─ ActorCard.purposeId → PurposeCard.id
|
||||
└─ BehaviorDriverCard.actorId → ActorCard.id
|
||||
└─ NarrativeCard.driverId → BehaviorDriverCard.id
|
||||
```
|
||||
|
||||
| 接口 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| `PurposeCard` | `id, index, title, reason` | 目的卡片,包含标题和推演理由 |
|
||||
| `ActorCard` | `id, index, purposeId, title, content` | 行为体卡片,`purposeId` 关联所属目的 |
|
||||
| `BehaviorDriverCard` | `id, index, actorId, expectedBehavior, driver` | 预期行为 + 驱动因素二合一卡片,`actorId` 关联所属行为体 |
|
||||
| `NarrativeCard` | `id, index, driverId, title, reason` | 叙事卡片,`driverId` 关联所属驱动因素(`BehaviorDriverCard.id`) |
|
||||
|
||||
### 3.3 阶段数据结构
|
||||
|
||||
每个子阶段有独立的 `*StageData` 接口:
|
||||
|
||||
```typescript
|
||||
interface PurposeStageData {
|
||||
thinking: string; // 模型思考过程文本(流式填充)
|
||||
cards: PurposeCard[]; // 已生成的卡片列表(逐张追加)
|
||||
complete: boolean; // 该阶段是否完全结束
|
||||
}
|
||||
// ActorStageData / BehaviorStageData / NarrativeStageData 结构类似
|
||||
```
|
||||
|
||||
### 3.4 消息总线:`SixStepMessage`
|
||||
|
||||
这是整个页面最核心的数据结构,一条助手消息包含所有推演状态:
|
||||
|
||||
```typescript
|
||||
interface SixStepMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content?: string;
|
||||
timestamp: Date;
|
||||
outputFormat?: 'structured' | 'markdown';
|
||||
markdownContent?: string;
|
||||
stages?: SixStepStages;
|
||||
finalAnswer?: string;
|
||||
mainStepStatus?: Record<MainStepKey, StepStatus>;
|
||||
subStepStatus?: Record<SubStepKey, StepStatus>;
|
||||
currentSubStep?: SubStepKey;
|
||||
currentMainStep?: MainStepKey;
|
||||
phase?: SixStepPhase;
|
||||
expertMatch?: ExpertMatchData;
|
||||
selectedExpert?: ExpertCard;
|
||||
selectedMcps?: SelectableMcpKey[];
|
||||
intent?: { name: string; confidence?: number; reason?: string };
|
||||
clarifyQuestion?: string;
|
||||
rawSteps?: BackendStepResult[];
|
||||
resumedFrom?: string;
|
||||
effectiveUserInput?: string;
|
||||
errors?: StreamErrorItem[];
|
||||
}
|
||||
```
|
||||
|
||||
**`errors` / `StreamErrorItem`**:`runIntentOrchestrateStream` 收到 SSE **`error_log`** 时追加;致命失败仍会走 **`error`** 事件并映射为同类结构。`SixStepConversation` 中 **`StreamErrorNotice`** 读取 `message.errors` 展示摘要(可折叠列表)。
|
||||
|
||||
**关键设计**:`mainStepStatus` / `subStepStatus` 驱动顶栏 `SixStepProgressHorizontal`;`stages` 驱动中间对话渲染;`currentSubStep` 驱动流程图沙箱蚂蚁线。`phase` / `intent` / `rawSteps` 与编排流、暂停继续、追问分支一致。所有信息集中在一个对象内,组件通过 props 按需取用。
|
||||
|
||||
---
|
||||
|
||||
## 四、模拟数据生成器(`mockData.ts`)
|
||||
|
||||
> **线上主路径**:`SixStepChatPage` 使用 **`runIntentOrchestrateStream`** 消费真实 `/api/orchestrate/stream`。`mockData.ts` 中的 **`runSixStepDeduction`** 仍保留,用于本地演示或与 `api.ts` 中纯编排解析逻辑对齐;新功能(意图、专家 SSE、`resume` 等)以 `runIntentOrchestrateStream` 为准。
|
||||
|
||||
### 4.1 种子数据
|
||||
|
||||
文件顶部定义了四组静态种子数组:
|
||||
|
||||
| 变量 | 长度 | 用途 |
|
||||
|------|------|------|
|
||||
| `PURPOSE_SEED` | 3 | 目的卡片内容模板 |
|
||||
| `ACTOR_SEED` | 7 | 行为体卡片内容模板 |
|
||||
| `BEHAVIOR_SEED` | 8 | 预期行为与驱动因素卡片内容模板 |
|
||||
| `NARRATIVE_SEED` | 8 | 叙事卡片内容模板 |
|
||||
|
||||
使用时循环取模(`(counter - 1) % SEED.length`),确保不越界。
|
||||
|
||||
### 4.2 卡片生成规则
|
||||
|
||||
| 阶段 | 数量规则 |
|
||||
|------|---------|
|
||||
| 目的 | 固定 3 张(取自 `PURPOSE_SEED` 全量) |
|
||||
| 行为体 | 每个目的对应的数量:第 0 个目的 2 张,其余 1 张,共 4 张 |
|
||||
| 预期行为/驱动 | 每个行为体:偶数索引 2 张,奇数索引 1 张,共 6 张 |
|
||||
| 叙事 | 每个预期行为:偶数索引 2 张,奇数索引 1 张,共 9 张 |
|
||||
|
||||
最终流程图节点总数:`1(智能体)+ 3 + 4 + 6×2(预期行为+驱动因素)+ 9 = 29 节点`。
|
||||
|
||||
### 4.3 流式控制器:`runSixStepDeduction`
|
||||
|
||||
```typescript
|
||||
export function runSixStepDeduction(
|
||||
message: SixStepMessage, // 直接在此对象上原地修改字段
|
||||
options: {
|
||||
onUpdate: (msg: SixStepMessage) => void; // 每次有新内容时回调
|
||||
onComplete?: () => void; // 全部完成时回调
|
||||
}
|
||||
): SixStepMockController
|
||||
```
|
||||
|
||||
返回的 `SixStepMockController` 包含:
|
||||
- `message`: 正在被修改的消息对象引用
|
||||
- `stop()`: 立即取消所有待执行的 `setTimeout`,中止推演
|
||||
- `promise`: 推演完成后 resolve 的 Promise(stop 后不再 resolve)
|
||||
|
||||
**内部时序**(所有延迟均可通过顶部常量调整):
|
||||
|
||||
```
|
||||
常量 默认值 说明
|
||||
TYPE_INTERVAL 28ms 每次打字输出 4 个字符后的等待间隔
|
||||
STAGE_GAP 380ms 阶段切换之间的等待时长
|
||||
```
|
||||
|
||||
执行顺序(伪代码):
|
||||
|
||||
```
|
||||
setMain('info', 'running') → emit
|
||||
wait(800ms) ← 模拟信息准备耗时
|
||||
setMain('info', 'done')
|
||||
setMain('sixstep', 'running') → emit
|
||||
wait(STAGE_GAP)
|
||||
|
||||
setSub('purpose', 'running') → emit
|
||||
typeInto(PURPOSE_THINKING) ← 逐字打出思考过程
|
||||
append cards 逐张(wait 280ms/张)
|
||||
setSub('purpose', 'done') → emit
|
||||
wait(STAGE_GAP)
|
||||
|
||||
setSub('actor', 'running') → emit
|
||||
typeInto(ACTOR_THINKING)
|
||||
append cards 逐张(wait 220ms/张)
|
||||
setSub('actor', 'done') → emit
|
||||
wait(STAGE_GAP)
|
||||
|
||||
setSub('behavior', 'running') → emit
|
||||
typeInto(BEHAVIOR_THINKING)
|
||||
append cards 逐张(wait 200ms/张)
|
||||
setSub('behavior', 'done') → emit
|
||||
wait(STAGE_GAP)
|
||||
|
||||
setSub('narrative', 'running') → emit
|
||||
typeInto(NARRATIVE_THINKING)
|
||||
append cards 逐张(wait 200ms/张)
|
||||
setSub('narrative', 'done')
|
||||
setMain('sixstep', 'done')
|
||||
setMain('final', 'running') → emit
|
||||
wait(STAGE_GAP)
|
||||
|
||||
typeInto(FINAL_ANSWER) ← 逐字打出最终结论
|
||||
setMain('final', 'done') → emit
|
||||
onComplete()
|
||||
```
|
||||
|
||||
**`emit` 机制**:每次调用 `onUpdate({ ...message, stages: { ...message.stages } })`,将消息对象浅拷贝后传给父组件,触发 React re-render。
|
||||
|
||||
---
|
||||
|
||||
## 五、流程图布局工具(`flowLayout.ts`)
|
||||
|
||||
此文件被沙箱和全屏组件共同引用,避免重复计算。
|
||||
|
||||
### 5.1 核心函数:`buildLayout`
|
||||
|
||||
```typescript
|
||||
function buildLayout(
|
||||
purposes: PurposeCard[],
|
||||
actors: ActorCard[],
|
||||
behaviors: BehaviorDriverCard[],
|
||||
narratives: NarrativeCard[],
|
||||
options?: BuildLayoutOptions // 可覆盖节点尺寸和间距,以及根节点文案
|
||||
): FlowLayout
|
||||
```
|
||||
|
||||
**`BuildLayoutOptions` 扩展**:除 `nodeWidth`、`nodeHeight`、`levelGap`、`nodeGap`、`paddingX`、`paddingY` 外,可选 **`rootTitle`**、**`rootDetail`**。用于将层级 0 的 `agent-root` 从默认「智能体 / 协同推演…」改为 **当前已选专家**(`SixStepFlowSandbox` / `SixStepFlowFullscreen` 在存在 `message.selectedExpert` 时传入专家姓名与简介摘要)。
|
||||
|
||||
返回 `FlowLayout`:
|
||||
```typescript
|
||||
interface FlowLayout {
|
||||
nodes: LayoutNode[]; // 已计算好坐标的节点列表
|
||||
edges: LayoutEdge[]; // 已确定连接关系和颜色对的边列表
|
||||
width: number; // 整体画布宽度(用于 SVG viewBox)
|
||||
height: number; // 整体画布高度
|
||||
}
|
||||
```
|
||||
|
||||
**布局算法**:改良的"叶节点优先后序遍历":
|
||||
|
||||
1. 从 agent 根节点开始,递归 DFS 处理子树
|
||||
2. 叶节点(无子节点的节点)按从左到右顺序分配 x 坐标,每次递增 `nodeWidth + nodeGap`
|
||||
3. 非叶节点的 x = (最左子节点 x + 最右子节点 x) / 2,确保父节点居中于子节点上方
|
||||
4. 所有节点的 y = `paddingY + level × (nodeHeight + levelGap)`,level 即树的深度
|
||||
|
||||
节点层级(`level`)对应关系:
|
||||
|
||||
| level | variant | 说明 |
|
||||
|-------|---------|------|
|
||||
| 0 | `agent` | 智能体根节点 |
|
||||
| 1 | `purpose` | 目的 |
|
||||
| 2 | `actor` | 行为体 |
|
||||
| 3 | `behavior` | 预期行为 |
|
||||
| 4 | `driver` | 驱动因素 |
|
||||
| 5 | `narrative` | 叙事 |
|
||||
|
||||
注意:`BehaviorDriverCard` 一张卡片会生成两个节点(`behavior` + `driver`),id 分别为 `${card.id}-behavior` 和 `${card.id}-driver`。
|
||||
|
||||
### 5.2 共享常量
|
||||
|
||||
| 常量 | 说明 |
|
||||
|------|------|
|
||||
| `EDGE_COLOR` | 按边类型分别定义深色/浅色主题颜色(5 组) |
|
||||
| `EDGE_LABEL` | 每种边类型的中文图例说明 |
|
||||
| `CURRENT_PAIR_BY_STAGE` | `SubStepKey → LayoutEdge['pair']` 映射,决定哪组边在哪个阶段展示蚂蚁线 |
|
||||
| `STATUS_TITLE` | `SubStepKey → string` 映射,沙箱/全屏顶部标题文字 |
|
||||
| `VARIANT_ABBREV` | 节点类型 → 简化缩写(沙箱专用,如 `purpose → 'md'`) |
|
||||
| `VARIANT_LABEL` | 节点类型 → 完整中文标签 |
|
||||
|
||||
### 5.3 辅助函数:`buildEdgePath`
|
||||
|
||||
生成 SVG 三次贝塞尔曲线路径字符串:
|
||||
|
||||
```
|
||||
M x1 y1 C x1 my, x2 my, x2 y2
|
||||
```
|
||||
|
||||
控制点均取中间 y 坐标(`(y1+y2)/2`),形成平滑 S 型连线,不与节点重叠。
|
||||
|
||||
---
|
||||
|
||||
## 六、CSS 动画(`styles.css`)
|
||||
|
||||
| 类名 | 动画 | 用途 |
|
||||
|------|------|------|
|
||||
| `.sixstep-ant-line` | `stroke-dasharray: 6 6` + `dashoffset` 循环 | SVG 边上的蚂蚁线流动效果(当阶段正在生成时) |
|
||||
| `.sixstep-pulse` | box-shadow 呼吸光晕 | 进度面板当前步骤图标、沙箱标题图标 |
|
||||
| `.sixstep-shimmer-text` / `...-light` | 渐变横扫文字 | 进度面板"生成中"文字、沙箱标题生成中状态 |
|
||||
| `.sixstep-spin-soft` | 360° 匀速旋转 | loading 图标 |
|
||||
| `.sixstep-fade-up` | 从下方淡入 | 子阶段卡片逐步出现 |
|
||||
| `.sixstep-node-pop` | scale 弹出 + 高光 | 流程图新增节点的弹出动画(持续 600ms) |
|
||||
| `.sixstep-ring-burst` | SVG circle `r` 从 0 扩散到 60,同时 opacity→0 | 节点出现时的扩散光环效果(持续 700ms) |
|
||||
| `.sixstep-canvas-grab` / `.sixstep-canvas-grabbing` | `cursor: grab/grabbing` | 流程图画布拖拽手型 |
|
||||
| `.sixstep-toolbar-btn` | 基础尺寸 + hover 过渡 | 工具栏图标按钮 |
|
||||
| `.sixstep-minimap-rect` | 静态样式 | 缩略图视口矩形指示框 |
|
||||
| `.sixstep-resize-handle` | 竖线拖拽手柄 | 沙箱左边缘的宽度调节拖拽条 |
|
||||
|
||||
---
|
||||
|
||||
## 七、主页面(`SixStepChatPage.tsx`)
|
||||
|
||||
### 7.1 职责
|
||||
|
||||
主页面是整个功能的**状态中枢**,负责:
|
||||
|
||||
- 多会话列表与 **`/api/conversations`** 持久化(`filter_key` / `filter_value` 标识 6bf 页)。
|
||||
- 创建并管理 **`runIntentOrchestrateStream`**(编排 + 意图)与 **`runExpertSearch` / `runExpertCreate`**(仍可用于「换一批专家」「创建专家」等直连 invoke)的控制器生命周期。
|
||||
- 将状态与专家相关回调分发给 `SixStepConversation`、`McpSelectModal`、`SixStepFlowSandbox` 等。
|
||||
- 管理沙箱宽度拖拽。
|
||||
|
||||
### 7.2 状态一览(节选)
|
||||
|
||||
| 状态 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `conversations` / `activeConvId` | 多会话 | 当前选中会话及其 `messages` |
|
||||
| `input` | `string` | 输入框文本 |
|
||||
| `isGenerating` | `boolean` | 是否正在请求流式接口 |
|
||||
| `outputFormat` | `'structured' \| 'markdown'` | 传给后端的 `output_format` |
|
||||
| `sandboxOpen` / `sandboxWidth` / `fullscreenOpen` | UI | 流程图沙箱与全屏 |
|
||||
| `historyOpen` / `loading` | UI / 加载 | 聊天记录侧栏与首屏拉取 |
|
||||
| `activeAssistantMsgId` | `string \| null` | 当前高亮/流式更新的助手消息 |
|
||||
| `mcpModal` | `{ open, messageId, expert }` | MCP 多选弹窗状态 |
|
||||
|
||||
### 7.3 Ref 一览
|
||||
|
||||
| Ref | 类型 | 用途 |
|
||||
|-----|------|------|
|
||||
| `controllerRef` | `SixStepMockController \| null` | `runIntentOrchestrateStream` 的 `stop()` |
|
||||
| `expertControllerRef` | `ExpertRunController \| null` | 专家搜索/创建流的 `stop()` |
|
||||
| `conversationsRef` | 会话数组 | 持久化前读取最新消息 |
|
||||
| `containerRef` / `resizeRef` | DOM / 拖拽 | 沙箱宽度拖拽 |
|
||||
|
||||
### 7.4 核心逻辑
|
||||
|
||||
**`handleSend`**
|
||||
|
||||
1. 追加用户消息;创建初始助手消息(`buildInitialAssistantMessage`,`phase` 随流程推进)。
|
||||
2. 调用 **`runIntentOrchestrateStream`**,请求体为 **`IntentOrchestrateRequest`**:`user_input`、`output_format`、`intent_mode`(默认 `auto`)、`current_phase`(含上一条助手为 **`paused`** 时传 `paused`)、`selected_expert`、`selected_mcps`、`expert_candidates`、**`last_steps`(来自上一条助手 `rawSteps`)**、`conversation_history` 等。
|
||||
- 暂停继续时会继承上一条助手的 `rawSteps` 与 `selectedMcps`(`pausedSelectedMcps`),并显式回传 `selected_mcps` / `last_steps`,避免恢复时丢失步骤选择与历史上下文。
|
||||
3. `onUpdate` 内更新当前会话中对应 `id` 的助手消息;结束后 `persistConversation`。
|
||||
|
||||
**专家与 MCP**
|
||||
|
||||
- 用户确认专家后 **`handleMcpModalConfirm`**:写入 `selectedExpert` / `selectedMcps`,并可用 **`intent_mode: 'force_orchestrate'`** 立即启动分析(无需用户再次输入)。
|
||||
- **`handleRefetchExpert`**:可用 `force_find_expert` 并排除已有候选。
|
||||
|
||||
**`handleStop`**
|
||||
|
||||
- `AbortController` 中止流;将未完成步骤从 `running` 收回;助手 **`phase` 置为 `paused`**,便于下次发送走 **`continue_analysis`** / `rerun_from_step` / `revise_input_continue` / `change_steps_continue` / `edit_stage_continue`。
|
||||
|
||||
**流内关键事件补充**
|
||||
|
||||
- `resume`:恢复继续分析时,会先把已有 `stages` 中的 `purpose/actor/behavior` 卡片回填到转换缓存,确保后续 `step_end` 的父子映射准确。
|
||||
- `rerun`:前端会按 `affected_steps` 清空受影响阶段(`stages`、`subStepStatus`、`rawSteps` 与局部卡片缓存),再接收重跑后的 `step_*`。
|
||||
- `change_steps`:按 `removed_steps` 清理被移除阶段与 `rawSteps`,保留未移除阶段并继续执行 `steps_to_run`。
|
||||
- `edit_apply`:先更新被编辑卡片内容,再按 `affected_steps` 清理后续阶段并触发重跑。
|
||||
- `intent`:包含 `slots`(如 `step`、`new_input`),用于更细粒度前端状态更新。
|
||||
|
||||
**沙箱宽度拖拽**:逻辑同前(`mousemove` / `mouseup`,保证对话区最小宽度)。
|
||||
|
||||
**活跃助手消息派生**:
|
||||
|
||||
```typescript
|
||||
const activeAssistantId = /* activeAssistantMsgId 或最后一条 assistant */;
|
||||
const activeAssistant = messages.find((m) => m.id === activeAssistantId);
|
||||
const liveMainStatus = activeAssistant?.mainStepStatus ?? DEFAULT_MAIN_STATUS;
|
||||
const liveSubStatus = activeAssistant?.subStepStatus ?? DEFAULT_SUB_STATUS;
|
||||
```
|
||||
|
||||
`liveMainStatus` / `liveSubStatus` 传给 **`SixStepProgressHorizontal`**;`activeAssistant` 传给沙箱与全屏。
|
||||
|
||||
### 7.5 Props 传递图
|
||||
|
||||
```
|
||||
SixStepChatPage
|
||||
│
|
||||
├─► SixStepChatHistory # 会话列表(与顶栏「聊天记录」联动)
|
||||
├─► SixStepProgressHorizontal # mainStatus / subStatus / isDark
|
||||
├─► SixStepConversation
|
||||
│ messages, isGenerating, activeMessageId,
|
||||
│ expertHandlers(选专家 / 换一批 / 创建 / 确认)
|
||||
├─► ChatInputComposer # onSend → handleSend, onStop → handleStop
|
||||
├─► SixStepFlowSandbox # message={activeAssistant}
|
||||
├─► SixStepFlowFullscreen
|
||||
└─► McpSelectModal # 专家确认后选 MCP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、左侧进度面板(`SixStepProgress.tsx`)
|
||||
|
||||
> **主页面顶栏**使用的是 **`SixStepProgressHorizontal.tsx`**(横向主/子步骤);本节描述纵向 `SixStepProgress` 的 Props 与交互,组件仍保留供其他布局复用。
|
||||
|
||||
### 8.1 Props
|
||||
|
||||
| Prop | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `mainStatus` | `Record<MainStepKey, StepStatus>` | ✓ | 三主步骤状态 |
|
||||
| `subStatus` | `Record<SubStepKey, StepStatus>` | ✓ | 四子阶段状态 |
|
||||
| `collapsed` | `boolean` | 可选 | 是否折叠,默认 `false` |
|
||||
| `onToggleCollapse` | `() => void` | 可选 | 展开/收起回调 |
|
||||
|
||||
### 8.2 展开视图(`collapsed = false`)
|
||||
|
||||
从上到下展示三个主步骤卡片,每个卡片包含:
|
||||
- 彩色图标(每个步骤有独立配色,running 时呼吸脉冲)
|
||||
- 标题 + 进度徽标(`已完成 / 生成中 / 待开始`)
|
||||
- 副文本描述(running 时展示 shimmer 动画)
|
||||
- 步骤之间用竖线连接(完成后变绿色)
|
||||
|
||||
当 `sixstep` 阶段进入 running 或 done,在其卡片内部展开四个子阶段列表(`SUB_STEPS`),每个子阶段包含:
|
||||
- 彩色图标(与流程图对应色)
|
||||
- 标题 + 状态图标
|
||||
- 时间线左侧的节点圆点 + 连接竖线
|
||||
|
||||
右上角收起按钮(`ChevronLeft`)触发 `onToggleCollapse`。
|
||||
|
||||
### 8.3 折叠视图(`collapsed = true`)
|
||||
|
||||
渲染轻量的 `CollapsedView` 子组件:
|
||||
- 宽度由父容器通过 `w-14` 控制(56px),有 `transition-all duration-300` 过渡动画
|
||||
- 顶部 `ChevronRight` 按钮用于展开
|
||||
- 竖向排列三个主步骤彩色图标 + 状态圆点
|
||||
- 若 `sixstep` 已解锁,在其下方额外展示四个小尺寸子阶段图标(`w-7 h-7`)
|
||||
- 所有元素均有 `title` 属性,鼠标悬停显示步骤名称
|
||||
|
||||
### 8.4 配色方案
|
||||
|
||||
步骤颜色定义在 `MAIN_STEPS` 和 `SUB_STEPS` 数组的 `color` 字段中:
|
||||
|
||||
| 步骤 | 深色 | 浅色 |
|
||||
|------|------|------|
|
||||
| 信息准备 | sky-300 / sky-500 | sky-600 / sky-50 |
|
||||
| 6步法推演 | violet-300 / violet-500 | violet-600 / violet-50 |
|
||||
| 最终结果 | emerald-300 / emerald-500 | emerald-600 / emerald-50 |
|
||||
| 目的 | violet-300 | orange |
|
||||
| 行为体 | cyan-300 | red |
|
||||
| 预期行为/驱动 | emerald-300 | emerald |
|
||||
| 叙事 | amber-300 | amber |
|
||||
|
||||
---
|
||||
|
||||
## 九、对话列表(`SixStepConversation.tsx`)
|
||||
|
||||
### 9.1 Props
|
||||
|
||||
| Prop | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `messages` | `SixStepMessage[]` | 全部消息 |
|
||||
| `isGenerating` | `boolean` | 是否正在生成 |
|
||||
| `activeMessageId` | `string \| null` | 当前流式聚焦的助手消息 id |
|
||||
| `expertHandlers` | 可选对象 | `onSelectExpert` / `onRefetchExpert` / `onCreateExpert` / `onConfirmExpert` |
|
||||
|
||||
### 9.2 消息渲染策略
|
||||
|
||||
每条消息根据 `role` 字段分叉:
|
||||
|
||||
- `role === 'user'` → 渲染 `UserMessage`:右对齐气泡 + 用户头像
|
||||
- `role === 'assistant'` → 渲染 `AssistantMessage`:左对齐 + 机器人头像(生成中时呼吸脉冲 + 星光图标)
|
||||
|
||||
消息列表容器 `ref` 监听内容变化,每次 `messages` 或 `isGenerating` 变化后自动滚动到底部。
|
||||
|
||||
### 9.3 助手消息 `AssistantMessage`
|
||||
|
||||
内部读取 `message.stages` 和 `message.mainStepStatus`、`message.subStepStatus`,根据是否存在各阶段数据来条件渲染对应的阶段块。底部(及部分分支末尾)挂载 **`StreamErrorNotice`**,展示 **`message.errors`**(来自 SSE `error_log` 等)。
|
||||
|
||||
助手消息结构示意:
|
||||
|
||||
```
|
||||
AssistantMessage
|
||||
│
|
||||
├─ 信息准备中(仅当 info=running 且无任何 stage)→ shimmer 提示
|
||||
│
|
||||
├─ PurposeStage (当 stages.purpose 存在时)
|
||||
│ ├─ InlineCollapsible (以 StageHeader 为标题)
|
||||
│ │ ├─ ThinkingBlock (思考过程)
|
||||
│ │ └─ InlineCollapsible (目的卡片列表)
|
||||
│ │ └─ PurposeCardItem × N
|
||||
│
|
||||
├─ ActorStage (当 stages.actor 存在时)
|
||||
│ ├─ InlineCollapsible
|
||||
│ │ ├─ ThinkingBlock
|
||||
│ │ └─ InlineCollapsible (行为体卡片列表)
|
||||
│ │ └─ 按 purpose 分组
|
||||
│ │ ├─ ParentGroupHeader (所属目的)
|
||||
│ │ └─ ActorCardItem × N
|
||||
│
|
||||
├─ BehaviorStage (当 stages.behavior 存在时)
|
||||
│ ├─ InlineCollapsible
|
||||
│ │ ├─ ThinkingBlock
|
||||
│ │ └─ InlineCollapsible (预期行为/驱动因素卡片列表)
|
||||
│ │ └─ 按 actor 分组
|
||||
│ │ ├─ ParentGroupHeader (所属行为体)
|
||||
│ │ └─ BehaviorCardItem × N(2列网格)
|
||||
│
|
||||
├─ NarrativeStage (当 stages.narrative 存在时)
|
||||
│ ├─ InlineCollapsible
|
||||
│ │ ├─ ThinkingBlock
|
||||
│ │ └─ InlineCollapsible (叙事卡片列表)
|
||||
│ │ └─ 按 driver 分组
|
||||
│ │ ├─ ParentGroupHeader (所属驱动因素)
|
||||
│ │ └─ NarrativeCardItem × N
|
||||
│
|
||||
└─ FinalAnswer (当 finalAnswer 存在或 final=running 时)
|
||||
│
|
||||
└─ StreamErrorNotice (当 message.errors 非空)
|
||||
```
|
||||
|
||||
**分组计算**:`ActorStage`、`BehaviorStage`、`NarrativeStage` 都在内部用 `useMemo` 计算分组,将卡片按父 ID 聚合到 Map,再按父实体列表顺序输出。
|
||||
|
||||
---
|
||||
|
||||
## 十、卡片原子组件(`SixStepCards.tsx`)
|
||||
|
||||
此文件作为"组件库",为对话列表提供所有可复用的卡片和容器组件。
|
||||
|
||||
### 10.1 `InlineCollapsible`
|
||||
|
||||
通用折叠面板,思考过程区块、卡片列表区块均复用此组件。
|
||||
|
||||
| Prop | 说明 |
|
||||
|------|------|
|
||||
| `title` | 标题区 ReactNode |
|
||||
| `children` | 折叠内容 |
|
||||
| `defaultOpen` | 默认展开状态(默认 `true`) |
|
||||
| `variant` | `'default'`(标准边框背景)或 `'subtle'`(更淡)|
|
||||
|
||||
展开/收起使用 `grid-template-rows` 动画(`0fr → 1fr`),配合 `opacity` 过渡,无需 JS 计算高度。
|
||||
|
||||
### 10.2 各卡片组件
|
||||
|
||||
| 组件 | 卡片类型 | 布局特点 |
|
||||
|------|---------|---------|
|
||||
| `PurposeCardItem` | 目的 | 紫/橙渐变背景,索引徽标 + Target 图标 + 标题 + 理由 |
|
||||
| `ActorCardItem` | 行为体 | 青/红渐变背景,UserCheck 图标 + 标题 + 内容 |
|
||||
| `BehaviorCardItem` | 预期行为/驱动因素 | 翠绿渐变背景,上下两部分:TrendingUp 图标+预期行为 / Zap 图标+驱动因素 |
|
||||
| `NarrativeCardItem` | 叙事 | 琥珀渐变背景,ScrollText 图标 + 标题 + 理由 |
|
||||
|
||||
所有卡片均有 `.sixstep-fade-up` 进入动画,`hover` 时边框加深。
|
||||
|
||||
### 10.3 `ParentGroupHeader`
|
||||
|
||||
显示某类卡片所属父实体名称的标签行,用于行为体、预期行为/驱动、叙事列表的分组标题。
|
||||
|
||||
| Prop | 说明 |
|
||||
|------|------|
|
||||
| `label` | 关系说明(如"所属目的") |
|
||||
| `parentTitle` | 父实体名称 |
|
||||
| `count` | 该组卡片数量 |
|
||||
| `variant` | `'actor' \| 'behavior' \| 'narrative'`,决定图标和配色 |
|
||||
|
||||
### 10.4 `ThinkingBlock`
|
||||
|
||||
展示思考过程文本的区块,`loading` 为 `true` 时标题行展示 "生成中" shimmer 文字。
|
||||
|
||||
### 10.5 `StageHeader`
|
||||
|
||||
折叠面板标题,包含阶段图标、阶段标题、卡片数量徽标、状态徽标(已完成/生成中)。`loading = true` 时图标呼吸脉冲,副文本展示 shimmer 动画。
|
||||
|
||||
---
|
||||
|
||||
## 十一、流程图沙箱(`SixStepFlowSandbox.tsx`)
|
||||
|
||||
### 11.1 Props
|
||||
|
||||
| Prop | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `message` | `SixStepMessage \| undefined` | 活跃助手消息,用于提取各阶段卡片数据 |
|
||||
| `width` | `number` | 当前沙箱宽度(px),由主页面拖拽控制 |
|
||||
| `onResizeStart` | `(e: React.MouseEvent) => void` | 拖拽手柄 mousedown 回调 |
|
||||
| `onClose` | `() => void` | 关闭沙箱回调 |
|
||||
| `onOpenFullscreen` | `() => void` | 打开全屏回调 |
|
||||
|
||||
### 11.2 布局参数(`COMPACT_OPTIONS`)
|
||||
|
||||
沙箱使用缩小版布局,节点小于全屏版:
|
||||
|
||||
```typescript
|
||||
const COMPACT_OPTIONS = {
|
||||
nodeWidth: 120, nodeHeight: 60,
|
||||
levelGap: 80, nodeGap: 24,
|
||||
paddingX: 32, paddingY: 28,
|
||||
};
|
||||
```
|
||||
|
||||
### 11.3 简化节点(`CompactNode`)
|
||||
|
||||
每个节点只显示**彩色图标 + 阶段缩写**(`VARIANT_ABBREV`):
|
||||
|
||||
| 层级 | 缩写 |
|
||||
|------|------|
|
||||
| 智能体(或根标题改为已选专家名时仍为 variant `agent`) | AI |
|
||||
| 目的 | md |
|
||||
| 行为体 | xwt |
|
||||
| 预期行为 | yqxw |
|
||||
| 驱动因素 | qdys |
|
||||
| 叙事 | xs |
|
||||
|
||||
底部固定图例说明对应关系。
|
||||
|
||||
### 11.4 新增节点动画
|
||||
|
||||
通过 `seenIdsRef`(`Set<string>`)记录已渲染过的节点 ID。每次 `layout.nodes` 变化时,找出新增的 ID,将其加入 `appearingIds` 状态,700ms 后自动移除。
|
||||
|
||||
`CompactNode` 接收 `appearing` prop,为 `true` 时:
|
||||
1. 为 `<g>` 添加 `.sixstep-node-pop` CSS 类(scale 弹出)
|
||||
2. 在节点中心绘制一个 SVG `<circle>` 并添加 `.sixstep-ring-burst` CSS 类(扩散光环)
|
||||
|
||||
### 11.5 交互能力
|
||||
|
||||
**平移(Pan)**:
|
||||
- `mousedown` 记录起始坐标和初始 `tx/ty` 到 `panStart.current`
|
||||
- 通过 `useEffect` 监听 `isPanning` 状态,动态注册/注销 `window` 的 `mousemove` 和 `mouseup`
|
||||
- `mousemove` 计算偏移量 `(e.clientX - startX, e.clientY - startY)` 叠加到初始平移值
|
||||
|
||||
**缩放(Zoom)**:
|
||||
- 通过 `useEffect` 向 `containerRef` 注册原生 `wheel` 事件(`{ passive: false }`),确保 `e.preventDefault()` 生效,不触发浏览器页面缩放
|
||||
- 以鼠标位置为缩放中心:`nextTx = mx - (mx - prev.tx) * (nextScale / prev.scale)`
|
||||
- 缩放范围:0.3x ~ 2.5x
|
||||
|
||||
**工具栏**:
|
||||
| 按钮 | 功能 |
|
||||
|------|------|
|
||||
| `+` | 按 1.2 倍放大 |
|
||||
| `-` | 按 1.2 倍缩小 |
|
||||
| ↺ | 恢复 1:1(translate 0,0 scale 1) |
|
||||
| ⊕ | 定位画布中央(保持当前缩放比,调整平移使图形居中) |
|
||||
| ⤢ | 触发 `onOpenFullscreen` |
|
||||
|
||||
首次有内容时自动执行 `fitToView`(等比缩放 + 居中),由 `fitAppliedRef` 控制只执行一次。
|
||||
|
||||
### 11.6 连线说明
|
||||
|
||||
SVG 中的 `<defs>` 为每种边类型各定义一个箭头 marker(id 加 `sixstep-sandbox-` 前缀,与全屏版 marker 不冲突)。当 `activePair === edge.pair` 时,该边添加 `.sixstep-ant-line` CSS 类展示蚂蚁线动画。
|
||||
|
||||
### 11.7 调整宽度
|
||||
|
||||
左侧有一个 `position: absolute` 的 `.sixstep-resize-handle` 竖线拖拽条,`mousedown` 触发 `onResizeStart`(由主页面处理具体逻辑)。
|
||||
|
||||
---
|
||||
|
||||
## 十二、全屏流程图视图(`SixStepFlowFullscreen.tsx`)
|
||||
|
||||
### 12.1 Props
|
||||
|
||||
| Prop | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `message` | `SixStepMessage \| undefined` | 与沙箱相同的活跃助手消息 |
|
||||
| `onClose` | `() => void` | 关闭全屏回调 |
|
||||
|
||||
### 12.2 与沙箱的区别
|
||||
|
||||
| 特性 | 沙箱 | 全屏 |
|
||||
|------|------|------|
|
||||
| 布局参数 | 紧凑(节点 120×60) | 标准(节点 200×84) |
|
||||
| 节点内容 | 缩写 + 图标 | 完整标题 + 详情 + 图标 |
|
||||
| 节点出现动画 | 有(pop + ring-burst) | 无 |
|
||||
| 子树收起/展开 | 无 | 有(点击**有子节点**的父节点;右下角「-」收起 / 「+」展开;画布左下角有操作提示) |
|
||||
| 缩略图 | 无 | 有(右下角;仅绘制当前可见节点) |
|
||||
| 下载 | 无 | 有(导出 SVG) |
|
||||
| Esc 关闭 | 无 | 有 |
|
||||
| 层级 | flex 子元素 | `fixed inset-0 z-50`(全视口遮罩) |
|
||||
| 缩放范围 | 0.3x ~ 2.5x | 0.2x ~ 3x |
|
||||
|
||||
### 12.3 详细节点(`DetailNode`)
|
||||
|
||||
使用 `foreignObject` 嵌入 HTML,展示:
|
||||
- 彩色图标包裹 + 大写类型标签(`VARIANT_LABEL`)
|
||||
- 节点标题(`line-clamp-1`)
|
||||
- 详情内容(`line-clamp-2`)
|
||||
|
||||
深色/浅色主题的渐变背景、图标颜色均通过 `VARIANT_TOKEN` 查表获取。
|
||||
|
||||
**收起子树**:当 `flowLayout` 中节点带 **`children`** 时,卡片右上角显示 **`+` / `-`**,点击节点(或该角标)切换 **`collapsedNodeIds`**:自该节点向下的后代节点与相关边不再纳入 **`targetVisibleNodeSet`**,并以约 **240ms** 的透明度过渡(进入/离开集合合并渲染,避免闪烁)。重新生成布局时会剔除已不存在的节点 id,避免残留收起状态。根节点若被选为专家展示,仍可按树结构收起其下子链。
|
||||
|
||||
### 12.4 缩略图(`Minimap`)
|
||||
|
||||
固定 220×150px,渲染在右下角。
|
||||
|
||||
内部逻辑:
|
||||
1. 计算整体布局与 minimap 尺寸的缩放比 `ratio = Math.min((W-10)/layoutW, (H-10)/layoutH)`
|
||||
2. 按 `ratio` 缩小绘制每个节点矩形(使用 `MINIMAP_VARIANT_FILL` 颜色,无文字)
|
||||
3. 根据当前 `transform`(`tx, ty, scale`)和视口尺寸,计算可见区域在世界坐标的矩形,再乘 `ratio` 映射到 minimap 坐标系,绘制蓝色描边矩形(`.sixstep-minimap-rect`)
|
||||
4. 点击 minimap:将点击的 minimap 坐标逆映射到世界坐标,调用 `onJump` 将视口移动到以该点为中心
|
||||
|
||||
### 12.5 SVG 下载
|
||||
|
||||
点击下载按钮时:
|
||||
1. 克隆 `svgRef.current`(当前全屏 SVG 元素)
|
||||
2. 设置 `width/height` 为实际布局尺寸,移除 transform
|
||||
3. 用 `XMLSerializer` 序列化为字符串
|
||||
4. 创建 `Blob` 和临时 `<a>` 标签,触发下载
|
||||
5. 文件名格式:`6步法推演-YYYY-MM-DD-HH-mm-ss.svg`
|
||||
|
||||
> ⚠️ 注意:由于节点内容在 `foreignObject` 中使用 HTML + CSS 类名,下载的 SVG 打开时若外部查看器不支持 CSS,样式会丢失。可在未来改为纯 SVG 文本节点渲染以提升兼容性。
|
||||
|
||||
---
|
||||
|
||||
## 十三、整体数据流图
|
||||
|
||||
```
|
||||
用户输入(回车)
|
||||
│
|
||||
▼
|
||||
SixStepChatPage.handleSend()
|
||||
│
|
||||
├─► 当前会话追加 UserMessage + 初始 AssistantMessage
|
||||
│
|
||||
└─► runIntentOrchestrateStream(assistant, { onUpdate, onComplete }, IntentOrchestrateRequest)
|
||||
│
|
||||
│ readSSE('/api/orchestrate/stream', body)
|
||||
│
|
||||
│ intent / expert_* / clarify / resume / rerun / change_steps / edit_apply
|
||||
│ start / step_* / flow / done / delta(追问或 markdown)
|
||||
│ 每次 onUpdate → 更新会话中该 assistant 的 SixStepMessage
|
||||
│
|
||||
▼ React re-render
|
||||
│
|
||||
├─► SixStepProgressHorizontal(liveMainStatus / liveSubStatus)
|
||||
├─► SixStepConversation(messages、expertHandlers、intent/clarify 等)
|
||||
├─► SixStepFlowSandbox / Fullscreen(stages → buildLayout)
|
||||
└─► persistConversation(SQLite)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十四、路由注册
|
||||
|
||||
**`src/App.tsx`**:
|
||||
```tsx
|
||||
// 路由定义
|
||||
<Route path="sixstep-chat" element={<SixStepChatPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
|
||||
// 导航路径映射(handleNavigate 函数)
|
||||
else if ([..., 'sixstep-chat'].includes(page)) {
|
||||
path = `/qa-center/${page}`;
|
||||
}
|
||||
```
|
||||
|
||||
**可观测性页面**(调用耗时 / 错误日志筛选):全站路由 **`/observability`**,侧栏顶级「可观测性」,对接后端 **`GET /api/observability/calls`**、**`/errors`**、**`/options`** 等(详见 `backend/doc.md`)。与 6bf 页面独立,不在 `/qa-center` 前缀下。
|
||||
|
||||
**`src/components/Sidebar.tsx`**:
|
||||
```tsx
|
||||
// 菜单配置(问答中心 children)
|
||||
{ id: 'sixstep-chat', label: '6bf', icon: Workflow }
|
||||
|
||||
// qaPages 集合(控制问答中心父菜单高亮)
|
||||
const qaPages = new Set([..., 'sixstep-chat']);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十五、流式错误与 `error_log`(`api.ts`)
|
||||
|
||||
编排 SSE 除 **`error`** 外,可能下发 **`error_log`**(步骤失败摘要、skill 降级等)。`runIntentOrchestrateStream` 将其归一为 **`StreamErrorItem`** 并 **`push` 到 `message.errors`**;网络异常也会写入类似结构。后端在可执行 **skill 失败已跳过**时,**`message` 文案**可能附带 **异常原因前若干字符**(与 `backend/app/mcp/base.py` 截断策略一致),便于在气泡内区分超时与其它错误。
|
||||
|
||||
**`StreamErrorNotice`**:仅在对应助手气泡内展示,支持折叠展开、错误/警告计数摘要;不参与 `showAnalysisContent` 隐藏逻辑(专家阶段亦可显示)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 十六、扩展指南
|
||||
|
||||
### 接入编排与意图流
|
||||
|
||||
1. 主入口为 **`runIntentOrchestrateStream`**(`api.ts`),与后端 **`/api/orchestrate/stream`** 事件一一对应;扩展新意图时通常同时改 **`backend/app/intent/detector.py`** 与 **`runIntentOrchestrateStream`** 的分支解析。
|
||||
- 当前除 `continue_analysis` 外,还支持 `rerun_from_step`、`revise_input_continue`、`change_steps_continue`、`edit_stage_continue` 分支。
|
||||
2. 保持 **`onUpdate(SixStepMessage)`** 契约:`SixStepChatPage` 只需合并助手消息字段。
|
||||
3. 纯编排、无意图分支的调试可继续用 **`runSixStepDeduction`**(同文件内,仅 `start` / `step_*` / `flow` / `done`)。
|
||||
|
||||
详见 **`INTENT_INTEGRATION.md`** 与仓库 **`docs/xvqiu.md`**。
|
||||
|
||||
### 某子步(如行为体、叙事)体感很慢时
|
||||
|
||||
前端 **`step_end`** 事件里会带上该步的 **`duration_ms`**(整步总耗时),便于在 UI 或调试时对比各步。
|
||||
|
||||
更细拆分(skill 耗时 vs 拼 prompt 扩展 vs LLM)在**后端**完成:每次 `POST /api/agents/{key}/invoke` 及编排内对 `MCPAgent.invoke` 的调用,服务端会打 **`mcp_timing`** / **`mcp_skill_timing`** 日志(见 **`backend/doc.md`**「MCP 调用接口」与 **`backend/DEVELOPMENT.md`** §5.2)。排查时在运行 `python -m app.main` 的终端查看即可;无需改前端请求体。
|
||||
|
||||
### 替换流程图引擎(如 G6 / React Flow)
|
||||
|
||||
仅需替换 `SixStepFlowSandbox.tsx` 和 `SixStepFlowFullscreen.tsx` 中的 SVG 渲染部分:
|
||||
- `buildLayout()` 的 `LayoutNode` 数组可直接转换为 G6 节点
|
||||
- `LayoutEdge` 数组可直接转换为 G6 边
|
||||
- `CURRENT_PAIR_BY_STAGE` 和 `EDGE_COLOR` 仍适用
|
||||
|
||||
### 新增推演子阶段
|
||||
|
||||
1. 在 `types.ts` 的 `SubStepKey` 中新增键名
|
||||
2. 在 `SixStepStages` 中新增对应 `*StageData` 字段
|
||||
3. 在 `mockData.ts` 的 `runSixStepDeduction` 中新增生成逻辑(若仍需要离线演示)
|
||||
4. 在 `SixStepConversation.tsx` 的 `AssistantMessage` 中新增阶段渲染块
|
||||
5. 在 `SixStepProgress.tsx` / **`SixStepProgressHorizontal.tsx`** 的 `SUB_STEPS` 中新增步骤项
|
||||
6. 若需要流程图新层级,在 `flowLayout.ts` 的 `buildLayout` 中新增节点构建逻辑
|
||||
@@ -0,0 +1,266 @@
|
||||
import type {
|
||||
ActorCard,
|
||||
BehaviorDriverCard,
|
||||
NarrativeCard,
|
||||
PurposeCard,
|
||||
SubStepKey,
|
||||
} from './types';
|
||||
|
||||
export interface LayoutNode {
|
||||
id: string;
|
||||
level: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
variant: 'agent' | 'purpose' | 'actor' | 'behavior' | 'driver' | 'narrative';
|
||||
title: string;
|
||||
detail: string;
|
||||
children: string[];
|
||||
parent?: string;
|
||||
}
|
||||
|
||||
export interface LayoutEdge {
|
||||
id: string;
|
||||
fromId: string;
|
||||
toId: string;
|
||||
pair: 'agent-purpose' | 'purpose-actor' | 'actor-behavior' | 'behavior-driver' | 'driver-narrative';
|
||||
}
|
||||
|
||||
export interface FlowLayout {
|
||||
nodes: LayoutNode[];
|
||||
edges: LayoutEdge[];
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface BuildLayoutOptions {
|
||||
nodeWidth?: number;
|
||||
nodeHeight?: number;
|
||||
levelGap?: number;
|
||||
nodeGap?: number;
|
||||
paddingX?: number;
|
||||
paddingY?: number;
|
||||
rootTitle?: string;
|
||||
rootDetail?: string;
|
||||
}
|
||||
|
||||
export const CURRENT_PAIR_BY_STAGE: Record<SubStepKey, LayoutEdge['pair'] | null> = {
|
||||
purpose: 'agent-purpose',
|
||||
actor: 'purpose-actor',
|
||||
behavior: 'actor-behavior',
|
||||
narrative: 'driver-narrative',
|
||||
};
|
||||
|
||||
export const STATUS_TITLE: Record<SubStepKey, string> = {
|
||||
purpose: '正在生成目的中',
|
||||
actor: '正在生成行为体中',
|
||||
behavior: '正在生成预期行为和驱动因素中',
|
||||
narrative: '正在生成叙事中',
|
||||
};
|
||||
|
||||
export const EDGE_COLOR: Record<LayoutEdge['pair'], { dark: string; light: string }> = {
|
||||
'agent-purpose': { dark: '#a78bfa', light: '#fb923c' },
|
||||
'purpose-actor': { dark: '#22d3ee', light: '#ef4444' },
|
||||
'actor-behavior': { dark: '#34d399', light: '#10b981' },
|
||||
'behavior-driver': { dark: '#5eead4', light: '#0d9488' },
|
||||
'driver-narrative': { dark: '#fbbf24', light: '#f59e0b' },
|
||||
};
|
||||
|
||||
export const EDGE_LABEL: Record<LayoutEdge['pair'], string> = {
|
||||
'agent-purpose': '智能体→目的',
|
||||
'purpose-actor': '目的→行为体',
|
||||
'actor-behavior': '行为体→预期行为',
|
||||
'behavior-driver': '预期行为→驱动因素',
|
||||
'driver-narrative': '驱动因素→叙事',
|
||||
};
|
||||
|
||||
export function buildEdgePath(from: LayoutNode, to: LayoutNode): string {
|
||||
const x1 = from.x + from.width / 2;
|
||||
const y1 = from.y + from.height;
|
||||
const x2 = to.x + to.width / 2;
|
||||
const y2 = to.y;
|
||||
const my = (y1 + y2) / 2;
|
||||
return `M ${x1} ${y1} C ${x1} ${my}, ${x2} ${my}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
export function buildLayout(
|
||||
purposes: PurposeCard[],
|
||||
actors: ActorCard[],
|
||||
behaviors: BehaviorDriverCard[],
|
||||
narratives: NarrativeCard[],
|
||||
options: BuildLayoutOptions = {},
|
||||
): FlowLayout {
|
||||
const NODE_WIDTH = options.nodeWidth ?? 200;
|
||||
const NODE_HEIGHT = options.nodeHeight ?? 84;
|
||||
const LEVEL_GAP = options.levelGap ?? 130;
|
||||
const NODE_GAP = options.nodeGap ?? 28;
|
||||
const PADDING_X = options.paddingX ?? 48;
|
||||
const PADDING_Y = options.paddingY ?? 40;
|
||||
const rootTitle = options.rootTitle?.trim() || '智能体';
|
||||
const rootDetail = options.rootDetail?.trim() || '协同推演 6 步法生成内容';
|
||||
|
||||
const nodes: LayoutNode[] = [];
|
||||
const nodeMap = new Map<string, LayoutNode>();
|
||||
const push = (node: LayoutNode) => {
|
||||
nodes.push(node);
|
||||
nodeMap.set(node.id, node);
|
||||
};
|
||||
|
||||
const agent: LayoutNode = {
|
||||
id: 'agent-root',
|
||||
level: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
variant: 'agent',
|
||||
title: rootTitle,
|
||||
detail: rootDetail,
|
||||
children: purposes.map((p) => p.id),
|
||||
};
|
||||
push(agent);
|
||||
|
||||
purposes.forEach((p) => {
|
||||
push({
|
||||
id: p.id,
|
||||
level: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
variant: 'purpose',
|
||||
title: p.title,
|
||||
detail: p.reason,
|
||||
children: actors.filter((a) => a.purposeId === p.id).map((a) => a.id),
|
||||
parent: 'agent-root',
|
||||
});
|
||||
});
|
||||
|
||||
actors.forEach((a) => {
|
||||
push({
|
||||
id: a.id,
|
||||
level: 2,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
variant: 'actor',
|
||||
title: a.title,
|
||||
detail: a.content,
|
||||
children: behaviors.filter((b) => b.actorId === a.id).map((b) => `${b.id}-behavior`),
|
||||
parent: a.purposeId,
|
||||
});
|
||||
});
|
||||
|
||||
behaviors.forEach((b) => {
|
||||
const behaviorId = `${b.id}-behavior`;
|
||||
const driverId = `${b.id}-driver`;
|
||||
push({
|
||||
id: behaviorId,
|
||||
level: 3,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
variant: 'behavior',
|
||||
title: '预期行为',
|
||||
detail: b.expectedBehavior,
|
||||
children: [driverId],
|
||||
parent: b.actorId,
|
||||
});
|
||||
push({
|
||||
id: driverId,
|
||||
level: 4,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
variant: 'driver',
|
||||
title: '驱动因素',
|
||||
detail: b.driver,
|
||||
children: narratives.filter((n) => n.driverId === b.id).map((n) => n.id),
|
||||
parent: behaviorId,
|
||||
});
|
||||
});
|
||||
|
||||
narratives.forEach((n) => {
|
||||
push({
|
||||
id: n.id,
|
||||
level: 5,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
variant: 'narrative',
|
||||
title: n.title,
|
||||
detail: n.reason,
|
||||
children: [],
|
||||
parent: `${n.driverId}-driver`,
|
||||
});
|
||||
});
|
||||
|
||||
const leafCounter = { v: 0 };
|
||||
const place = (node: LayoutNode) => {
|
||||
if (node.children.length === 0) {
|
||||
node.x = PADDING_X + leafCounter.v * (NODE_WIDTH + NODE_GAP);
|
||||
leafCounter.v += 1;
|
||||
return;
|
||||
}
|
||||
node.children.forEach((cid) => {
|
||||
const child = nodeMap.get(cid);
|
||||
if (child) place(child);
|
||||
});
|
||||
const xs = node.children.map((cid) => nodeMap.get(cid)?.x ?? 0).filter((v) => v >= 0);
|
||||
if (xs.length > 0) {
|
||||
node.x = (Math.min(...xs) + Math.max(...xs)) / 2;
|
||||
} else {
|
||||
node.x = PADDING_X;
|
||||
}
|
||||
};
|
||||
place(agent);
|
||||
|
||||
nodes.forEach((node) => {
|
||||
node.y = PADDING_Y + node.level * (NODE_HEIGHT + LEVEL_GAP);
|
||||
});
|
||||
|
||||
const edges: LayoutEdge[] = [];
|
||||
nodes.forEach((node) => {
|
||||
node.children.forEach((cid) => {
|
||||
const child = nodeMap.get(cid);
|
||||
if (!child) return;
|
||||
let pair: LayoutEdge['pair'];
|
||||
if (node.variant === 'agent') pair = 'agent-purpose';
|
||||
else if (node.variant === 'purpose') pair = 'purpose-actor';
|
||||
else if (node.variant === 'actor') pair = 'actor-behavior';
|
||||
else if (node.variant === 'behavior') pair = 'behavior-driver';
|
||||
else pair = 'driver-narrative';
|
||||
edges.push({ id: `${node.id}->${cid}`, fromId: node.id, toId: cid, pair });
|
||||
});
|
||||
});
|
||||
|
||||
const allLevels = Math.max(...nodes.map((n) => n.level));
|
||||
const maxX = Math.max(...nodes.map((n) => n.x + n.width));
|
||||
const totalWidth = maxX + PADDING_X;
|
||||
const totalHeight = PADDING_Y * 2 + (allLevels + 1) * NODE_HEIGHT + allLevels * LEVEL_GAP;
|
||||
|
||||
return { nodes, edges, width: totalWidth, height: totalHeight };
|
||||
}
|
||||
|
||||
export const VARIANT_ABBREV: Record<LayoutNode['variant'], string> = {
|
||||
agent: 'AI',
|
||||
purpose: 'md',
|
||||
actor: 'xwt',
|
||||
behavior: 'yqxw',
|
||||
driver: 'qdys',
|
||||
narrative: 'xs',
|
||||
};
|
||||
|
||||
export const VARIANT_LABEL: Record<LayoutNode['variant'], string> = {
|
||||
agent: '智能体',
|
||||
purpose: '目的',
|
||||
actor: '行为体',
|
||||
behavior: '预期行为',
|
||||
driver: '驱动因素',
|
||||
narrative: '叙事',
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
import type {
|
||||
ActorCard,
|
||||
BehaviorDriverCard,
|
||||
MainStepKey,
|
||||
NarrativeCard,
|
||||
PurposeCard,
|
||||
SixStepMessage,
|
||||
StepStatus,
|
||||
SubStepKey,
|
||||
} from './types';
|
||||
|
||||
const PURPOSE_SEED: { title: string; reason: string }[] = [
|
||||
{ title: '建立品牌权威认知', reason: '通过持续的专业内容输出,塑造行业领导者形象,使核心受众在面对同类品牌时优先想起本品牌,并带来长期信任度' },
|
||||
{ title: '激活核心用户决策路径', reason: '梳理用户从兴趣触达到决策转化的关键触点,缩短决策链路、提升转化效率' },
|
||||
{ title: '强化情感价值连接', reason: '通过故事化叙事激发情绪共鸣,沉淀难以被替代的品牌情感资产' },
|
||||
];
|
||||
|
||||
const ACTOR_SEED: { title: string; content: string }[] = [
|
||||
{ title: '行业意见领袖(KOL)', content: '在专业垂直领域有持续影响力,可在第一时间撬动核心圈层并带动二次扩散' },
|
||||
{ title: '品牌官方账号矩阵', content: '统一对外传递品牌主张与最新动态,承担信息发布、用户互动与口碑响应' },
|
||||
{ title: '深度内容创作者', content: '产出长图文、视频解读等内容形态,承担引发讨论与口碑沉淀的角色' },
|
||||
{ title: '行业垂直媒体', content: '具备专业背书能力,承担议题设置与权威背书职责' },
|
||||
{ title: '核心用户共创社区', content: '由忠诚用户构成,通过 UGC 形式输出真实使用反馈与场景示范' },
|
||||
{ title: '行业分析机构', content: '通过研究报告与数据支撑品牌主张,加强观点的可信度' },
|
||||
{ title: '品牌代言关键人', content: '与品牌价值观高度契合的代言人或合作方,承担情感投射与信任放大' },
|
||||
];
|
||||
|
||||
const BEHAVIOR_SEED: { expectedBehavior: string; driver: string }[] = [
|
||||
{ expectedBehavior: '主动转发并附带专业点评', driver: '内容契合自身专业立场,带来圈层认同与社交资本' },
|
||||
{ expectedBehavior: '在专业社区发起延伸讨论', driver: '议题具备争议或洞察价值,能引发同行参与并扩散' },
|
||||
{ expectedBehavior: '将品牌内容沉淀为参考资料', driver: '内容具备结构化、可复用的方法论价值,可在工作中长期引用' },
|
||||
{ expectedBehavior: '主动在直播间或路演中引用品牌观点', driver: '观点具备可解释性,能帮助行为体强化自身专业可信度' },
|
||||
{ expectedBehavior: '产出二次内容(长图文 / 视频)', driver: '原始素材具备可改编空间,能匹配自身受众语境' },
|
||||
{ expectedBehavior: '组织线下闭门交流会', driver: '议题对核心受众具备稀缺性,符合社群运营节奏' },
|
||||
{ expectedBehavior: '在调研报告中将品牌列为典型案例', driver: '品牌案例具备数据支撑、典型且可被结构化引用' },
|
||||
{ expectedBehavior: '在公开演讲中引用品牌叙事框架', driver: '叙事框架具备方法论高度,可被借用以提升自身演讲含金量' },
|
||||
];
|
||||
|
||||
const NARRATIVE_SEED: { title: string; reason: string }[] = [
|
||||
{ title: '从噪音中识别真实信号', reason: '聚焦行业过度信息的痛点,主张以理性框架回归本质' },
|
||||
{ title: '让专业判断回到决策中心', reason: '强调专业判断在复杂环境下的不可替代性' },
|
||||
{ title: '以长期主义穿越周期', reason: '倡导关注长期价值积累,而非短期爆点' },
|
||||
{ title: '把复杂问题讲简单', reason: '通过结构化表达让专业内容更易被传播与理解' },
|
||||
{ title: '让每一次选择都有迹可循', reason: '强调决策可追溯、可复盘,建立专业信任' },
|
||||
{ title: '与时间做朋友', reason: '把品牌成长锚定在长期视角,淡化短期波动焦虑' },
|
||||
{ title: '相信专业的力量', reason: '通过行为体共同的专业身份认同强化品牌主张' },
|
||||
{ title: '把方法论变成可复用的工具', reason: '将抽象主张沉淀为工具化资产,方便受众反复引用' },
|
||||
];
|
||||
|
||||
const PURPOSE_THINKING = `根据当前问题语境,需要先厘清品牌战略推演的核心目的:
|
||||
|
||||
1. 综合输入素材,识别可能的目标方向;
|
||||
2. 评估不同目的之间的优先级与互补性;
|
||||
3. 选择最契合当前阶段的目的组合,作为后续推演基础。`;
|
||||
|
||||
const ACTOR_THINKING = `围绕已生成的目的,开始识别支撑该目的所需的关键行为体:
|
||||
|
||||
- 需要明确每个行为体的角色定位
|
||||
- 评估其对目的的贡献度与覆盖范围
|
||||
- 避免行为体之间能力重叠或缺位`;
|
||||
|
||||
const BEHAVIOR_THINKING = `针对每个行为体,推演其在场景中应当展现的预期行为:
|
||||
|
||||
- 行为需要可观察、可衡量
|
||||
- 同时识别推动该行为发生的关键驱动因素
|
||||
- 行为与驱动因素需保持因果一致性`;
|
||||
|
||||
const NARRATIVE_THINKING = `基于驱动因素,构建用于触发行为的叙事框架:
|
||||
|
||||
- 叙事需要紧扣驱动因素的情绪或理性触点
|
||||
- 兼顾品牌主张与受众心智匹配度
|
||||
- 形成可被复用的叙事模板`;
|
||||
|
||||
export interface SixStepMockController {
|
||||
message: SixStepMessage;
|
||||
stop: () => void;
|
||||
promise: Promise<void>;
|
||||
}
|
||||
|
||||
const TYPE_INTERVAL = 28;
|
||||
const STAGE_GAP = 380;
|
||||
|
||||
function buildPurposeCards(): PurposeCard[] {
|
||||
return PURPOSE_SEED.map((seed, idx) => ({
|
||||
id: `purpose-${idx + 1}`,
|
||||
index: idx + 1,
|
||||
title: seed.title,
|
||||
reason: seed.reason,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildActorCards(purposes: PurposeCard[]): ActorCard[] {
|
||||
const cards: ActorCard[] = [];
|
||||
let counter = 0;
|
||||
purposes.forEach((purpose, pIdx) => {
|
||||
const count = pIdx === 0 ? 2 : 1;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
counter += 1;
|
||||
const seed = ACTOR_SEED[(counter - 1) % ACTOR_SEED.length];
|
||||
cards.push({
|
||||
id: `actor-${counter}`,
|
||||
index: counter,
|
||||
purposeId: purpose.id,
|
||||
title: seed.title,
|
||||
content: seed.content,
|
||||
});
|
||||
}
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
|
||||
function buildBehaviorCards(actors: ActorCard[]): BehaviorDriverCard[] {
|
||||
const cards: BehaviorDriverCard[] = [];
|
||||
let counter = 0;
|
||||
actors.forEach((actor, aIdx) => {
|
||||
const count = aIdx % 2 === 0 ? 2 : 1;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
counter += 1;
|
||||
const seed = BEHAVIOR_SEED[(counter - 1) % BEHAVIOR_SEED.length];
|
||||
cards.push({
|
||||
id: `behavior-${counter}`,
|
||||
index: counter,
|
||||
actorId: actor.id,
|
||||
expectedBehavior: seed.expectedBehavior,
|
||||
driver: seed.driver,
|
||||
});
|
||||
}
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
|
||||
function buildNarrativeCards(behaviors: BehaviorDriverCard[]): NarrativeCard[] {
|
||||
const cards: NarrativeCard[] = [];
|
||||
let counter = 0;
|
||||
behaviors.forEach((behavior, bIdx) => {
|
||||
const count = bIdx % 2 === 0 ? 2 : 1;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
counter += 1;
|
||||
const seed = NARRATIVE_SEED[(counter - 1) % NARRATIVE_SEED.length];
|
||||
cards.push({
|
||||
id: `narrative-${counter}`,
|
||||
index: counter,
|
||||
driverId: behavior.id,
|
||||
title: seed.title,
|
||||
reason: seed.reason,
|
||||
});
|
||||
}
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
|
||||
function buildFinalAnswer(): string {
|
||||
return `综合本次 6 步法推演结果,可以得到如下战略要点:
|
||||
|
||||
1. **目的**:以品牌权威认知为锚点,在专业人群中建立可信表达。
|
||||
2. **行为体**:依靠 KOL、官方账号矩阵、深度创作者三类协同发声。
|
||||
3. **预期行为与驱动因素**:通过专业内容激发主动传播与延伸讨论,形成圈层共振。
|
||||
4. **叙事**:以"在噪音中识别真实信号"为核心叙事线,串联整个传播节奏。
|
||||
|
||||
后续可基于该推演继续生成传播节奏、渠道分工与内容资产清单。`;
|
||||
}
|
||||
|
||||
export function buildInitialAssistantMessage(): SixStepMessage {
|
||||
return {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
stages: {},
|
||||
finalAnswer: '',
|
||||
mainStepStatus: { info: 'pending', sixstep: 'pending', final: 'pending' },
|
||||
subStepStatus: { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' },
|
||||
};
|
||||
}
|
||||
|
||||
interface RunOptions {
|
||||
onUpdate: (message: SixStepMessage) => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟 6 步法推演的全过程:信息准备 → 6 步法(4 个阶段) → 最终结果。
|
||||
*
|
||||
* 通过分阶段写入 thinking 文本和卡片,模拟真实流式生成效果。
|
||||
*/
|
||||
export function runSixStepDeduction(message: SixStepMessage, options: RunOptions): SixStepMockController {
|
||||
let cancelled = false;
|
||||
const timers: number[] = [];
|
||||
|
||||
const wait = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const id = window.setTimeout(() => {
|
||||
resolve();
|
||||
}, ms);
|
||||
timers.push(id);
|
||||
});
|
||||
|
||||
const setMain = (key: MainStepKey, status: StepStatus) => {
|
||||
message.mainStepStatus = { ...(message.mainStepStatus ?? { info: 'pending', sixstep: 'pending', final: 'pending' }), [key]: status };
|
||||
if (status === 'running') {
|
||||
message.currentMainStep = key;
|
||||
}
|
||||
};
|
||||
|
||||
const setSub = (key: SubStepKey, status: StepStatus) => {
|
||||
message.subStepStatus = {
|
||||
...(message.subStepStatus ?? { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' }),
|
||||
[key]: status,
|
||||
};
|
||||
if (status === 'running') {
|
||||
message.currentSubStep = key;
|
||||
}
|
||||
};
|
||||
|
||||
const emit = () => {
|
||||
if (cancelled) return;
|
||||
options.onUpdate({ ...message, stages: { ...message.stages } });
|
||||
};
|
||||
|
||||
const typeInto = async (text: string, write: (chunk: string) => void) => {
|
||||
const step = 4;
|
||||
for (let i = 0; i <= text.length; i += step) {
|
||||
if (cancelled) return;
|
||||
write(text.slice(0, i));
|
||||
emit();
|
||||
await wait(TYPE_INTERVAL);
|
||||
}
|
||||
if (!cancelled) {
|
||||
write(text);
|
||||
emit();
|
||||
}
|
||||
};
|
||||
|
||||
const promise = (async () => {
|
||||
setMain('info', 'running');
|
||||
emit();
|
||||
await wait(800);
|
||||
if (cancelled) return;
|
||||
setMain('info', 'done');
|
||||
setMain('sixstep', 'running');
|
||||
emit();
|
||||
await wait(STAGE_GAP);
|
||||
|
||||
setSub('purpose', 'running');
|
||||
message.stages = { ...message.stages, purpose: { thinking: '', cards: [], complete: false } };
|
||||
emit();
|
||||
await typeInto(PURPOSE_THINKING, (chunk) => {
|
||||
message.stages = { ...message.stages, purpose: { ...(message.stages?.purpose as any), thinking: chunk } };
|
||||
});
|
||||
if (cancelled) return;
|
||||
const purposes = buildPurposeCards();
|
||||
for (const card of purposes) {
|
||||
if (cancelled) return;
|
||||
const existing = message.stages?.purpose?.cards ?? [];
|
||||
message.stages = { ...message.stages, purpose: { ...(message.stages?.purpose as any), cards: [...existing, card] } };
|
||||
emit();
|
||||
await wait(280);
|
||||
}
|
||||
message.stages = { ...message.stages, purpose: { ...(message.stages?.purpose as any), complete: true } };
|
||||
setSub('purpose', 'done');
|
||||
emit();
|
||||
await wait(STAGE_GAP);
|
||||
|
||||
setSub('actor', 'running');
|
||||
message.stages = { ...message.stages, actor: { thinking: '', cards: [], complete: false } };
|
||||
emit();
|
||||
await typeInto(ACTOR_THINKING, (chunk) => {
|
||||
message.stages = { ...message.stages, actor: { ...(message.stages?.actor as any), thinking: chunk } };
|
||||
});
|
||||
if (cancelled) return;
|
||||
const actors = buildActorCards(purposes);
|
||||
for (const card of actors) {
|
||||
if (cancelled) return;
|
||||
const existing = message.stages?.actor?.cards ?? [];
|
||||
message.stages = { ...message.stages, actor: { ...(message.stages?.actor as any), cards: [...existing, card] } };
|
||||
emit();
|
||||
await wait(220);
|
||||
}
|
||||
message.stages = { ...message.stages, actor: { ...(message.stages?.actor as any), complete: true } };
|
||||
setSub('actor', 'done');
|
||||
emit();
|
||||
await wait(STAGE_GAP);
|
||||
|
||||
setSub('behavior', 'running');
|
||||
message.stages = { ...message.stages, behavior: { thinking: '', cards: [], complete: false } };
|
||||
emit();
|
||||
await typeInto(BEHAVIOR_THINKING, (chunk) => {
|
||||
message.stages = { ...message.stages, behavior: { ...(message.stages?.behavior as any), thinking: chunk } };
|
||||
});
|
||||
if (cancelled) return;
|
||||
const behaviors = buildBehaviorCards(actors);
|
||||
for (const card of behaviors) {
|
||||
if (cancelled) return;
|
||||
const existing = message.stages?.behavior?.cards ?? [];
|
||||
message.stages = { ...message.stages, behavior: { ...(message.stages?.behavior as any), cards: [...existing, card] } };
|
||||
emit();
|
||||
await wait(200);
|
||||
}
|
||||
message.stages = { ...message.stages, behavior: { ...(message.stages?.behavior as any), complete: true } };
|
||||
setSub('behavior', 'done');
|
||||
emit();
|
||||
await wait(STAGE_GAP);
|
||||
|
||||
setSub('narrative', 'running');
|
||||
message.stages = { ...message.stages, narrative: { thinking: '', cards: [], complete: false } };
|
||||
emit();
|
||||
await typeInto(NARRATIVE_THINKING, (chunk) => {
|
||||
message.stages = { ...message.stages, narrative: { ...(message.stages?.narrative as any), thinking: chunk } };
|
||||
});
|
||||
if (cancelled) return;
|
||||
const narratives = buildNarrativeCards(behaviors);
|
||||
for (const card of narratives) {
|
||||
if (cancelled) return;
|
||||
const existing = message.stages?.narrative?.cards ?? [];
|
||||
message.stages = { ...message.stages, narrative: { ...(message.stages?.narrative as any), cards: [...existing, card] } };
|
||||
emit();
|
||||
await wait(200);
|
||||
}
|
||||
message.stages = { ...message.stages, narrative: { ...(message.stages?.narrative as any), complete: true } };
|
||||
setSub('narrative', 'done');
|
||||
setMain('sixstep', 'done');
|
||||
setMain('final', 'running');
|
||||
emit();
|
||||
await wait(STAGE_GAP);
|
||||
|
||||
if (cancelled) return;
|
||||
await typeInto(buildFinalAnswer(), (chunk) => {
|
||||
message.finalAnswer = chunk;
|
||||
});
|
||||
if (cancelled) return;
|
||||
setMain('final', 'done');
|
||||
message.currentMainStep = undefined;
|
||||
message.currentSubStep = undefined;
|
||||
emit();
|
||||
options.onComplete?.();
|
||||
})();
|
||||
|
||||
return {
|
||||
message,
|
||||
stop: () => {
|
||||
cancelled = true;
|
||||
timers.forEach((t) => window.clearTimeout(t));
|
||||
},
|
||||
promise,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
@keyframes sixstep-ant-march {
|
||||
to {
|
||||
stroke-dashoffset: -24;
|
||||
}
|
||||
}
|
||||
|
||||
.sixstep-ant-line {
|
||||
stroke-dasharray: 6 6;
|
||||
animation: sixstep-ant-march 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sixstep-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.55);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(56, 189, 248, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.sixstep-pulse {
|
||||
animation: sixstep-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes sixstep-shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sixstep-shimmer-text {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(148, 163, 184, 0.55) 0%,
|
||||
rgba(248, 250, 252, 1) 45%,
|
||||
rgba(148, 163, 184, 0.55) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: sixstep-shimmer 2s linear infinite;
|
||||
}
|
||||
|
||||
.sixstep-shimmer-text-light {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(220, 38, 38, 0.45) 0%,
|
||||
rgba(190, 18, 60, 0.95) 45%,
|
||||
rgba(220, 38, 38, 0.45) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: sixstep-shimmer 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sixstep-spin-soft {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sixstep-spin-soft {
|
||||
animation: sixstep-spin-soft 1.4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sixstep-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sixstep-fade-up {
|
||||
animation: sixstep-fade-up 0.32s ease-out both;
|
||||
}
|
||||
|
||||
.sixstep-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sixstep-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 2px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.4);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.sixstep-resize-handle:hover::after {
|
||||
background: rgba(56, 189, 248, 0.8);
|
||||
}
|
||||
|
||||
@keyframes sixstep-node-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.4);
|
||||
filter: drop-shadow(0 0 0 rgba(56, 189, 248, 0));
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: scale(1.18);
|
||||
filter: drop-shadow(0 0 12px rgba(56, 189, 248, 0.8));
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: drop-shadow(0 0 0 rgba(56, 189, 248, 0));
|
||||
}
|
||||
}
|
||||
|
||||
.sixstep-node-pop {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: sixstep-node-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
@keyframes sixstep-ring-burst {
|
||||
0% {
|
||||
opacity: 0.85;
|
||||
r: 0;
|
||||
stroke-width: 4;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
r: 60;
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.sixstep-ring-burst {
|
||||
animation: sixstep-ring-burst 0.7s ease-out forwards;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.sixstep-canvas-grab {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.sixstep-canvas-grabbing {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sixstep-toolbar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.sixstep-minimap-rect {
|
||||
fill: rgba(56, 189, 248, 0.18);
|
||||
stroke: rgba(56, 189, 248, 0.9);
|
||||
stroke-width: 1;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
export type MainStepKey = 'info' | 'sixstep' | 'final';
|
||||
export type SubStepKey = 'purpose' | 'actor' | 'behavior' | 'narrative';
|
||||
|
||||
export type StepStatus = 'pending' | 'running' | 'done';
|
||||
|
||||
export interface PurposeCard {
|
||||
id: string;
|
||||
index: number;
|
||||
title: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ActorCard {
|
||||
id: string;
|
||||
index: number;
|
||||
purposeId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface BehaviorDriverCard {
|
||||
id: string;
|
||||
index: number;
|
||||
actorId: string;
|
||||
expectedBehavior: string;
|
||||
driver: string;
|
||||
}
|
||||
|
||||
export interface NarrativeCard {
|
||||
id: string;
|
||||
index: number;
|
||||
driverId: string;
|
||||
title: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PurposeStageData {
|
||||
thinking: string;
|
||||
cards: PurposeCard[];
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface ActorStageData {
|
||||
thinking: string;
|
||||
cards: ActorCard[];
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface BehaviorStageData {
|
||||
thinking: string;
|
||||
cards: BehaviorDriverCard[];
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface NarrativeStageData {
|
||||
thinking: string;
|
||||
cards: NarrativeCard[];
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface InfoStageData {
|
||||
content: string;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface SixStepStages {
|
||||
info?: InfoStageData;
|
||||
purpose?: PurposeStageData;
|
||||
actor?: ActorStageData;
|
||||
behavior?: BehaviorStageData;
|
||||
narrative?: NarrativeStageData;
|
||||
}
|
||||
|
||||
export type SelectableMcpKey = 'purpose' | 'actor' | 'behavior_factor' | 'narrative';
|
||||
|
||||
export interface ExpertCard {
|
||||
id: string;
|
||||
backendId?: string;
|
||||
name: string;
|
||||
detail: string;
|
||||
avatarUrl?: string;
|
||||
title?: string;
|
||||
tags?: string[];
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
export interface ExpertMatchData {
|
||||
status: 'idle' | 'searching' | 'creating' | 'ready' | 'confirmed' | 'error';
|
||||
thinking: string;
|
||||
experts: ExpertCard[];
|
||||
selectedExpertId?: string;
|
||||
selectedMcps?: SelectableMcpKey[];
|
||||
errorMessage?: string;
|
||||
searchedAt?: number;
|
||||
}
|
||||
|
||||
export type SixStepPhase =
|
||||
| 'idle'
|
||||
| 'intent-detecting'
|
||||
| 'expert-match'
|
||||
| 'expert-confirm'
|
||||
| 'generate'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
| 'clarifying'
|
||||
| 'follow-up';
|
||||
|
||||
/** SSE error_log / error 事件归一后的错误记录,积累在 SixStepMessage.errors 中。 */
|
||||
export interface StreamErrorItem {
|
||||
errorId: string;
|
||||
taskId?: string;
|
||||
level: 'warning' | 'error';
|
||||
source: 'orchestrator' | 'mcp' | 'skill' | 'llm' | 'network';
|
||||
mcpKey?: string;
|
||||
stepName?: string;
|
||||
skillKey?: string;
|
||||
skillName?: string;
|
||||
message: string;
|
||||
recoverable?: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** 后端 step_end 的原始字段,用于 continue_analysis 时回传 last_steps */
|
||||
export interface BackendStepResult {
|
||||
key: string;
|
||||
name?: string;
|
||||
status: string;
|
||||
thinking?: string;
|
||||
structured_outputs?: Record<string, unknown>[];
|
||||
markdown?: string;
|
||||
references?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface SixStepMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content?: string;
|
||||
timestamp: Date;
|
||||
outputFormat?: 'structured' | 'markdown';
|
||||
markdownContent?: string;
|
||||
stages?: SixStepStages;
|
||||
finalAnswer?: string;
|
||||
mainStepStatus?: Record<MainStepKey, StepStatus>;
|
||||
subStepStatus?: Record<SubStepKey, StepStatus>;
|
||||
currentSubStep?: SubStepKey;
|
||||
currentMainStep?: MainStepKey;
|
||||
phase?: SixStepPhase;
|
||||
expertMatch?: ExpertMatchData;
|
||||
selectedExpert?: ExpertCard;
|
||||
selectedMcps?: SelectableMcpKey[];
|
||||
intent?: { name: string; confidence?: number; reason?: string };
|
||||
clarifyQuestion?: string;
|
||||
/** 后端原始步骤数据,paused 后继续时作为 last_steps 传回后端 */
|
||||
rawSteps?: BackendStepResult[];
|
||||
/** 本轮是从哪个步骤继续的(resume/rerun 事件携带),供 UI 显示"继续自…" */
|
||||
resumedFrom?: string;
|
||||
/** 实际用于分析的问题(revise_input_continue 后更新为新问题) */
|
||||
effectiveUserInput?: string;
|
||||
/** 本轮累积的错误/警告列表(error_log 与 error 事件均追加于此) */
|
||||
errors?: StreamErrorItem[];
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,9 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export default function QACenterPage() {
|
||||
return (
|
||||
<div>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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'}`}>
|
||||
正在接收 <think> 流式思考内容
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Outlet, Navigate } from 'react-router-dom';
|
||||
|
||||
export default function QALayout() {
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -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
@@ -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}`)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import ResultManagement from './ResultManagement';
|
||||
import { PageType } from '@/components/Sidebar';
|
||||
|
||||
interface ResultDqProps {
|
||||
onNavigate: (page: PageType, taskId?: string) => void;
|
||||
}
|
||||
|
||||
export default function ResultDq({ onNavigate }: ResultDqProps) {
|
||||
return <ResultManagement onNavigate={onNavigate} initialCategory="dq-research" />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import ResultManagement from './ResultManagement';
|
||||
import { PageType } from '@/components/Sidebar';
|
||||
|
||||
interface ResultEffectPgProps {
|
||||
onNavigate: (page: PageType, taskId?: string) => void;
|
||||
}
|
||||
|
||||
export default function ResultEffectPg({ onNavigate }: ResultEffectPgProps) {
|
||||
return <ResultManagement onNavigate={onNavigate} initialCategory="effect-pg" />;
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
FileText,
|
||||
Search,
|
||||
Download,
|
||||
Edit3,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
Target,
|
||||
Brain,
|
||||
TrendingUp,
|
||||
Compass,
|
||||
Database,
|
||||
MoreHorizontal,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { PageType } from '@/components/Sidebar';
|
||||
|
||||
type ResultCategory = 'planning' | 'mnty' | 'effect-pg' | 'xk' | 'dq-research' | 'other';
|
||||
|
||||
interface ResultTask {
|
||||
id: string;
|
||||
title: string;
|
||||
category: ResultCategory;
|
||||
subCategory: string;
|
||||
status: 'completed' | 'pending' | 'draft';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
reportType: 'strategy' | 'consult' | 'analysis' | 'summary';
|
||||
agentCount: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ResultManagementProps {
|
||||
onNavigate: (page: PageType, taskId?: string) => void;
|
||||
initialCategory: ResultCategory;
|
||||
}
|
||||
|
||||
// 任务类别配置
|
||||
const categoryConfig: Record<ResultCategory, { label: string; icon: React.ElementType; color: string; subCategories: string[] }> = {
|
||||
'planning': {
|
||||
label: '规划任务',
|
||||
icon: Target,
|
||||
color: 'blue',
|
||||
subCategories: ['目标分析', '品牌策略', '内容规划', '渠道规划', '叙事策略']
|
||||
},
|
||||
'mnty': {
|
||||
label: 'MNTY任务',
|
||||
icon: Brain,
|
||||
color: 'purple',
|
||||
subCategories: ['机制研究', '方案设计', 'ZSTY分析']
|
||||
},
|
||||
'effect-pg': {
|
||||
label: '效果PG任务',
|
||||
icon: TrendingUp,
|
||||
color: 'amber',
|
||||
subCategories: ['行为PG', '效果评估', '信息PG', '账号PG']
|
||||
},
|
||||
'xk': {
|
||||
label: 'XK任务',
|
||||
icon: Compass,
|
||||
color: 'cyan',
|
||||
subCategories: ['任务JC', '任务调度']
|
||||
},
|
||||
'dq-research': {
|
||||
label: 'DQ研究类任务',
|
||||
icon: Database,
|
||||
color: 'green',
|
||||
subCategories: ['受众分析', '知识库研究', '趋势研究']
|
||||
},
|
||||
'other': {
|
||||
label: '其他类任务',
|
||||
icon: MoreHorizontal,
|
||||
color: 'slate',
|
||||
subCategories: ['通用分析', '临时任务', '其他']
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟任务数据
|
||||
const mockTasks: ResultTask[] = [
|
||||
{
|
||||
id: 'task-001',
|
||||
title: '五四青年节宣传活动策略',
|
||||
category: 'planning',
|
||||
subCategory: '叙事策略',
|
||||
status: 'completed',
|
||||
createdAt: '2026-03-19 10:30',
|
||||
updatedAt: '2026-03-19 15:30',
|
||||
reportType: 'strategy',
|
||||
agentCount: 6,
|
||||
description: '针对五四青年节的品牌传播策略方案'
|
||||
},
|
||||
{
|
||||
id: 'task-002',
|
||||
title: '品牌年轻化受众分析',
|
||||
category: 'dq-research',
|
||||
subCategory: '受众分析',
|
||||
status: 'completed',
|
||||
createdAt: '2026-03-18 14:20',
|
||||
updatedAt: '2026-03-18 16:45',
|
||||
reportType: 'analysis',
|
||||
agentCount: 3,
|
||||
description: '分析Z世代用户的消费行为和品牌偏好'
|
||||
},
|
||||
{
|
||||
id: 'task-003',
|
||||
title: 'UGC激励机制设计',
|
||||
category: 'effect-pg',
|
||||
subCategory: '效果评估',
|
||||
status: 'completed',
|
||||
createdAt: '2026-03-17 09:00',
|
||||
updatedAt: '2026-03-17 11:20',
|
||||
reportType: 'strategy',
|
||||
agentCount: 4,
|
||||
description: '设计用户生成内容激励机制方案'
|
||||
},
|
||||
{
|
||||
id: 'task-004',
|
||||
title: '短视频平台效果预测',
|
||||
category: 'mnty',
|
||||
subCategory: '方案设计',
|
||||
status: 'pending',
|
||||
createdAt: '2026-03-20 08:00',
|
||||
updatedAt: '2026-03-20 08:00',
|
||||
reportType: 'consult',
|
||||
agentCount: 5,
|
||||
description: '预测不同短视频平台的传播效果'
|
||||
},
|
||||
{
|
||||
id: 'task-005',
|
||||
title: '活动传播风险评估',
|
||||
category: 'planning',
|
||||
subCategory: '目标分析',
|
||||
status: 'draft',
|
||||
createdAt: '2026-03-20 10:00',
|
||||
updatedAt: '2026-03-20 10:30',
|
||||
reportType: 'analysis',
|
||||
agentCount: 2,
|
||||
description: '评估五四活动可能面临的传播风险'
|
||||
},
|
||||
{
|
||||
id: 'task-006',
|
||||
title: '竞品营销策略研究',
|
||||
category: 'dq-research',
|
||||
subCategory: '趋势研究',
|
||||
status: 'completed',
|
||||
createdAt: '2026-03-16 13:00',
|
||||
updatedAt: '2026-03-16 18:00',
|
||||
reportType: 'analysis',
|
||||
agentCount: 3,
|
||||
description: '分析主要竞品的营销策略和效果'
|
||||
},
|
||||
];
|
||||
|
||||
const ResultManagement: React.FC<ResultManagementProps> = ({ onNavigate, initialCategory }) => {
|
||||
const { theme } = useTheme();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// 主题颜色类
|
||||
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
|
||||
const cardBorder = isDark ? 'border-blue-800/30' : 'border-gray-200';
|
||||
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
||||
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
|
||||
const textMuted = isDark ? 'text-slate-500' : 'text-gray-500';
|
||||
const btnSecondary = isDark ? 'bg-slate-700/50 hover:bg-slate-600/50 border-blue-800/30 text-slate-300' : 'bg-gray-100 hover:bg-gray-200 border-gray-300 text-gray-700';
|
||||
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-gray-50';
|
||||
const inputBorder = isDark ? 'border-blue-800/30' : 'border-gray-300';
|
||||
|
||||
// 状态
|
||||
const selectedCategory = initialCategory;
|
||||
const selectedSubCategory = searchParams.get('subCategory');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'completed' | 'pending' | 'draft'>('all');
|
||||
|
||||
// 过滤任务
|
||||
const filteredTasks = mockTasks.filter(task => {
|
||||
const matchesCategory = task.category === selectedCategory;
|
||||
const matchesSubCategory = !selectedSubCategory || task.subCategory === selectedSubCategory;
|
||||
const matchesSearch = !searchQuery ||
|
||||
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
task.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || task.status === statusFilter;
|
||||
return matchesCategory && matchesSubCategory && matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
// 获取状态样式
|
||||
const getStatusStyle = (status: ResultTask['status']) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return {
|
||||
bg: isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-700',
|
||||
icon: CheckCircle,
|
||||
label: '已完成'
|
||||
};
|
||||
case 'pending':
|
||||
return {
|
||||
bg: isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-700',
|
||||
icon: Clock,
|
||||
label: '进行中'
|
||||
};
|
||||
case 'draft':
|
||||
return {
|
||||
bg: isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-gray-100 text-gray-700',
|
||||
icon: Edit3,
|
||||
label: '草稿'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 获取报告类型样式
|
||||
const getReportTypeStyle = (type: ResultTask['reportType']) => {
|
||||
const styles = {
|
||||
'strategy': { color: 'blue', label: '策略报告' },
|
||||
'consult': { color: 'purple', label: '会商报告' },
|
||||
'analysis': { color: 'cyan', label: '分析报告' },
|
||||
'summary': { color: 'green', label: '总结报告' }
|
||||
};
|
||||
return styles[type];
|
||||
};
|
||||
|
||||
// 查看任务详情
|
||||
const handleViewDetail = (taskId: string) => {
|
||||
onNavigate('result-detail', taskId);
|
||||
};
|
||||
|
||||
const currentCategoryConfig = categoryConfig[selectedCategory];
|
||||
const CategoryIcon = currentCategoryConfig.icon;
|
||||
|
||||
return (
|
||||
<div className="h-full flex">
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl ${cardBorder} rounded-xl p-4 mb-4`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl bg-${currentCategoryConfig.color}-500/20 flex items-center justify-center`}>
|
||||
<CategoryIcon className={`w-5 h-5 text-${currentCategoryConfig.color}-400`} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className={`${textPrimary} font-semibold`}>
|
||||
{selectedSubCategory || currentCategoryConfig.label}
|
||||
</h2>
|
||||
<p className={`${textMuted} text-xs`}>
|
||||
共 {filteredTasks.length} 个结果
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className={`flex items-center gap-2 px-3 py-2 rounded-lg ${btnSecondary} text-sm`}>
|
||||
<Download className="w-4 h-4" />
|
||||
批量导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex-1 relative ${inputBg} rounded-lg`}>
|
||||
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${textMuted}`} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索任务标题或描述..."
|
||||
className={`w-full pl-10 pr-4 py-2.5 rounded-lg text-sm bg-transparent focus:outline-none ${
|
||||
isDark ? 'text-white placeholder-slate-500' : 'text-gray-800 placeholder-gray-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
|
||||
className={`px-3 py-2.5 rounded-lg text-sm ${inputBg} ${inputBorder} focus:outline-none ${
|
||||
isDark ? 'text-slate-300' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="pending">进行中</option>
|
||||
<option value="draft">草稿</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<div className={`flex-1 ${cardBg} backdrop-blur-xl ${cardBorder} rounded-xl overflow-hidden`}>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center">
|
||||
<FileText className={`w-16 h-16 ${textMuted} mb-4`} />
|
||||
<p className={`${textSecondary} text-lg`}>暂无结果</p>
|
||||
<p className={`${textMuted} text-sm mt-1`}>在对应任务中生成结果后,将在此显示</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto custom-scrollbar">
|
||||
<table className="w-full">
|
||||
<thead className={`sticky top-0 ${isDark ? 'bg-slate-800/80' : 'bg-gray-50'}`}>
|
||||
<tr className={`text-xs ${textMuted} border-b ${cardBorder}`}>
|
||||
<th className="text-left font-medium px-4 py-3">任务名称</th>
|
||||
<th className="text-left font-medium px-4 py-3">报告类型</th>
|
||||
<th className="text-left font-medium px-4 py-3">智能体数</th>
|
||||
<th className="text-left font-medium px-4 py-3">状态</th>
|
||||
<th className="text-left font-medium px-4 py-3">更新时间</th>
|
||||
<th className="text-left font-medium px-4 py-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTasks.map((task) => {
|
||||
const statusInfo = getStatusStyle(task.status);
|
||||
const StatusIcon = statusInfo.icon;
|
||||
const reportTypeInfo = getReportTypeStyle(task.reportType);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={task.id}
|
||||
className={`border-b cursor-pointer hover:${
|
||||
isDark ? 'bg-slate-700/30' : 'bg-gray-50'
|
||||
} transition-colors ${cardBorder}`}
|
||||
onClick={() => handleViewDetail(task.id)}
|
||||
>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg bg-${reportTypeInfo.color}-500/20 flex items-center justify-center flex-shrink-0`}>
|
||||
<FileText className={`w-5 h-5 text-${reportTypeInfo.color}-400`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className={`${textPrimary} text-sm font-medium truncate`}>{task.title}</p>
|
||||
<p className={`${textMuted} text-xs truncate`}>{task.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
isDark ? `bg-${reportTypeInfo.color}-500/20 text-${reportTypeInfo.color}-400` : `bg-${reportTypeInfo.color}-100 text-${reportTypeInfo.color}-700`
|
||||
}`}>
|
||||
{reportTypeInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span className={`${textSecondary} text-sm`}>{task.agentCount}</span>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-medium ${statusInfo.bg}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span className={`${textMuted} text-sm`}>{task.updatedAt}</span>
|
||||
</td>
|
||||
<td className="px-4 py-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleViewDetail(task.id)}
|
||||
className={`p-1.5 rounded-lg hover:${isDark ? 'bg-slate-700' : 'bg-gray-100'} transition-colors`}
|
||||
title="查看详情"
|
||||
>
|
||||
<FileText className={`w-4 h-4 ${textSecondary}`} />
|
||||
</button>
|
||||
<button
|
||||
className={`p-1.5 rounded-lg hover:${isDark ? 'bg-slate-700' : 'bg-gray-100'} transition-colors`}
|
||||
title="编辑"
|
||||
>
|
||||
<Edit3 className={`w-4 h-4 ${textSecondary}`} />
|
||||
</button>
|
||||
<button
|
||||
className={`p-1.5 rounded-lg hover:${isDark ? 'bg-slate-700' : 'bg-gray-100'} transition-colors`}
|
||||
title="下载"
|
||||
>
|
||||
<Download className={`w-4 h-4 ${textSecondary}`} />
|
||||
</button>
|
||||
<button
|
||||
className={`p-1.5 rounded-lg hover:${isDark ? 'bg-slate-700' : 'bg-gray-100'} transition-colors`}
|
||||
title="呈报"
|
||||
>
|
||||
<Send className={`w-4 h-4 ${textSecondary}`} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自定义滚动条样式 */}
|
||||
<style>{`
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: ${isDark ? 'rgba(148, 163, 184, 0.3)' : 'rgba(0, 0, 0, 0.15)'};
|
||||
border-radius: 3px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: ${isDark ? 'rgba(148, 163, 184, 0.5)' : 'rgba(0, 0, 0, 0.25)'};
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResultManagement;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export default function ResultManagementLayout() {
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import ResultManagement from './ResultManagement';
|
||||
import { PageType } from '@/components/Sidebar';
|
||||
|
||||
interface ResultMntyProps {
|
||||
onNavigate: (page: PageType, taskId?: string) => void;
|
||||
}
|
||||
|
||||
export default function ResultMnty({ onNavigate }: ResultMntyProps) {
|
||||
return <ResultManagement onNavigate={onNavigate} initialCategory="mnty" />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import ResultManagement from './ResultManagement';
|
||||
import { PageType } from '@/components/Sidebar';
|
||||
|
||||
interface ResultOtherProps {
|
||||
onNavigate: (page: PageType, taskId?: string) => void;
|
||||
}
|
||||
|
||||
export default function ResultOther({ onNavigate }: ResultOtherProps) {
|
||||
return <ResultManagement onNavigate={onNavigate} initialCategory="other" />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import ResultManagement from './ResultManagement';
|
||||
import { PageType } from '@/components/Sidebar';
|
||||
|
||||
interface ResultPlanningProps {
|
||||
onNavigate: (page: PageType, taskId?: string) => void;
|
||||
}
|
||||
|
||||
export default function ResultPlanning({ onNavigate }: ResultPlanningProps) {
|
||||
return <ResultManagement onNavigate={onNavigate} initialCategory="planning" />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import ResultManagement from './ResultManagement';
|
||||
import { PageType } from '@/components/Sidebar';
|
||||
|
||||
interface ResultXkProps {
|
||||
onNavigate: (page: PageType, taskId?: string) => void;
|
||||
}
|
||||
|
||||
export default function ResultXk({ onNavigate }: ResultXkProps) {
|
||||
return <ResultManagement onNavigate={onNavigate} initialCategory="xk" />;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Edit, PlayCircle, Trash2 } from "lucide-react";
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { Popconfirm } from "tdesign-react";
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
import { format } from "date-fns";
|
||||
import logoSvg from "@/assets/icon/轻应用logo-DB.svg";
|
||||
interface analysisListItem {
|
||||
id: number;
|
||||
categoryName: string;
|
||||
category: string;
|
||||
appName: string;
|
||||
appDesc: string;
|
||||
appIcon: string;
|
||||
jumpUrl: string;
|
||||
appType: number;
|
||||
}
|
||||
|
||||
const service = requestCrudService;
|
||||
const zncmUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||
export const AppCard: React.FC<{
|
||||
app: analysisListItem;
|
||||
isDark: boolean;
|
||||
hoverColor: string;
|
||||
btnClass: string;
|
||||
delDataChange: (item: analysisListItem) => void;
|
||||
editShowChange: (item: analysisListItem) => void;
|
||||
}> = ({ app, isDark, hoverColor, btnClass, delDataChange, editShowChange }) => {
|
||||
const listDataChange = (item: analysisListItem) => {
|
||||
historyCreateSearch(item);
|
||||
// if (item.appType === 1) {
|
||||
// internalDataChange(item);
|
||||
// } else if (item.appType === 2) {
|
||||
// externalDataChange(item);
|
||||
// }
|
||||
};
|
||||
|
||||
const internalDataChange = (item: analysisListItem) => {
|
||||
// 内部接口跳转
|
||||
};
|
||||
|
||||
const externalDataChange = (item: analysisListItem) => {
|
||||
// 外部应用接口跳转
|
||||
window.open(item.jumpUrl);
|
||||
};
|
||||
|
||||
const handleConfirm = (item: analysisListItem) => {
|
||||
// 确认删除逻辑
|
||||
console.log("用户点击了确认");
|
||||
delDataChange(item);
|
||||
return true; // 返回 true 表示确认执行
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// 取消删除逻辑
|
||||
console.log("用户点击了取消");
|
||||
return true; // 返回 true 表示取消
|
||||
};
|
||||
|
||||
const historyCreateSearch = async (item: analysisListItem) => {
|
||||
const time = format(new Date(), "yyyy-MM-dd HH:mm:ss");
|
||||
console.log("time", time);
|
||||
const param = {
|
||||
appId: item.id,
|
||||
appName: item.appName,
|
||||
runTime: time,
|
||||
};
|
||||
const res = await service.post(`${zncmUrl}/app/history/create`, param);
|
||||
console.log("res", res);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border p-5 transition-all relative duration-200 hover:shadow-lg group ${
|
||||
isDark
|
||||
? "bg-slate-800/50 border-slate-700 hover:border-slate-600"
|
||||
: "bg-white border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{/* 头部信息 */}
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="rounded-lg overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
className="w-[47px] h-[47px] object-cover"
|
||||
src={app.appIcon}
|
||||
alt={app.appName}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = logoSvg;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3
|
||||
className={`font-semibold truncate transition-colors ${hoverColor} ${
|
||||
isDark ? "text-white" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{app.appName}
|
||||
</h3>
|
||||
<p
|
||||
className={`mt-1 text-sm line-clamp-2 ${
|
||||
isDark ? "text-slate-400" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{app.appDesc}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute right-[10px] top-[10px] flex items-center cursor-pointer opacity-100 group-hover:opacity-100 transition-opacity">
|
||||
<Edit
|
||||
className="w-4 h-4 text-[gray] mr-[10px]"
|
||||
onClick={() => editShowChange(app)}
|
||||
></Edit>
|
||||
<Popconfirm
|
||||
content="确认删除吗"
|
||||
onConfirm={() => handleConfirm(app)}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<div>
|
||||
<Trash2 className="w-4 h-4 text-[#ce2d39]"></Trash2>
|
||||
</div>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
{/* 输入输出信息 */}
|
||||
<div
|
||||
className={`text-xs space-y-1 mb-4 ${
|
||||
isDark ? "text-slate-500" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium">输入:</span>
|
||||
<span>信息文本/链接/素材</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium">输出:</span>
|
||||
<span>信息特征、重点摘要、分析判断</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮 */}
|
||||
<button
|
||||
className={`w-full py-2.5 rounded-lg font-medium flex items-center justify-center gap-2 transition-all border ${btnClass}`}
|
||||
onClick={() => listDataChange(app)}
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
立即使用
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { actionColumnItem, ActionListItem, TaskListItem, taskStatusList } from '@/pages/TaskManagement';
|
||||
import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizontal, Play, Search, User, Users, Workflow } from 'lucide-react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { PageType } from '../Sidebar';
|
||||
import { format } from 'date-fns';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Col, Empty, Loading, Pagination, Progress, Row } from 'tdesign-react';
|
||||
import { requestCrudService } from '@/service/request-axios-service';
|
||||
import { ActionColumn } from './ActionColumn';
|
||||
|
||||
interface ActionAnalyzesProps {
|
||||
TaskType?: string;
|
||||
title: string;
|
||||
category: string;
|
||||
subCategory: string;
|
||||
onNavigate: (page: PageType) => void;
|
||||
}
|
||||
|
||||
const service = requestCrudService;
|
||||
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category, subCategory, onNavigate }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
// 主题颜色变量
|
||||
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
|
||||
const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-100';
|
||||
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
||||
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
|
||||
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
|
||||
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-white';
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [taskList, setTaskList] = useState<TaskListItem[]>([]); // 任务列表
|
||||
const [taskId, setTaskId] = useState<number>(0); // 任务
|
||||
const [actionColumnList, setActionColumnList] = useState<actionColumnItem[]>([]);
|
||||
const [taskLoading, setTaskLoading] = useState<boolean>(false);
|
||||
const [actionLoading, setActionLoading] = useState<boolean>(false);
|
||||
// 当前页码,默认为第 1 页
|
||||
const [pageNum, setPageNum] = useState<number>(1);
|
||||
// 每页显示数量,默认为 10 条
|
||||
const [pageSize, setPageSize] = useState<number>(10); // 每页显示 10 条
|
||||
const [total, setTotal] = useState<number>(0); // 查询任务总数
|
||||
const state = useRef({
|
||||
pageIndex: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
// 回车处理
|
||||
const handleKeyDown = async (e) => {
|
||||
if ((e.metaKey || e.ctrlKey || e.shiftKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
} else if (e.keyCode === 13) {
|
||||
e.preventDefault();
|
||||
await taskListSearch()
|
||||
} else if (e.key === "/") {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 页码变化时的回调函数
|
||||
* 当用户切换页码时触发,更新页码并重新获取数据
|
||||
* @param value - 新的页码值
|
||||
*/
|
||||
const pageIndexChange = async (value: number) => {
|
||||
// 更新 ref 中存储的页码值
|
||||
// 更新页码状态,触发组件重新渲染
|
||||
setPageNum(value);
|
||||
state.current.pageIndex = value;
|
||||
await taskListSearch();
|
||||
}
|
||||
|
||||
// 任务查询
|
||||
const taskListSearch = async () => {
|
||||
const param = {
|
||||
keyword: '',
|
||||
pageNum: state.current.pageIndex,
|
||||
pageSize: state.current.pageSize
|
||||
}
|
||||
setTaskLoading(true);
|
||||
const res = await service.post(`${zncmUrl}/action/cop-action-tasks`, param);
|
||||
setTaskLoading(false);
|
||||
console.log('任务搜索', res);
|
||||
const result = res.data;
|
||||
result.forEach((item: TaskListItem) => {
|
||||
item.progress = item.endCount / item.sumAll || 0; // 获取已完成和未完成的占比
|
||||
item.taskStatusData = taskStatusList[item.taskStatus] // 根据任务状态码获取相关信息
|
||||
})
|
||||
setTaskList(result || []); // 设置任务列表页
|
||||
const totalRes = await service.post(`${zncmUrl}/action/cop-action-tasks-count`, param); // 查询任务总数
|
||||
console.log('totalRes', totalRes)
|
||||
setTotal(totalRes.data || 0); // 设置总数
|
||||
if (result.length > 0) {
|
||||
setTaskId(result[0].id);
|
||||
await actionListSearch(result[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
// 行动查询
|
||||
const actionListSearch = async (taskId: number) => {
|
||||
const param = {
|
||||
taskId: taskId,
|
||||
}
|
||||
setActionLoading(true);
|
||||
const res = await service.get(`${zncmUrl}/action/cop-task-actions`, param);
|
||||
console.log('行动查询', res);
|
||||
setActionLoading(false);
|
||||
const result = res.data;
|
||||
setActionColumnList([
|
||||
{ columnName: '待分析', icon: '待CEH.svg', actionList: result.collect || [] },
|
||||
{ columnName: 'CEH中', icon: '进行中.svg', actionList: result.collect1 || [] },
|
||||
{ columnName: 'CEH完成', icon: '完成.svg', actionList: result.collect2 || [] },
|
||||
{ columnName: '已终止', icon: '已终止.svg', actionList: result.endDate || [] },
|
||||
]);
|
||||
}
|
||||
|
||||
const taskDataChange = async (taskId: number) => {
|
||||
setTaskId(taskId);
|
||||
await actionListSearch(taskId);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
taskListSearch();
|
||||
}, [])
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Row className="h-full flex flex-col">
|
||||
<Col span={2} className="h-full">
|
||||
<div className={`h-[calc(100vh-100px)] p-[10px] ${isDark ? 'bg-[rgba(3,38,80,0.6)]' : 'bg-white'} shadow-[2px_0px_6px_rgba(0,0,0,0.12)]`} >
|
||||
{/* <div className="flex justify-between">
|
||||
<span>筛选</span>
|
||||
<span className="flex items-center">
|
||||
<span>下发时间排序</span>
|
||||
</span>
|
||||
</div> */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${textMuted}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索任务名称"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e)}
|
||||
className={`w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-colors ${inputBg} ${cardBorder} ${textPrimary} placeholder:${textMuted}`}
|
||||
/>
|
||||
</div>
|
||||
{/* 任务列表 */}
|
||||
<Loading loading={taskLoading}>
|
||||
<div className="h-[calc(100vh-200px)] overflow-y-auto pr-[10px]">
|
||||
{
|
||||
taskList.map((item: TaskListItem, index: number) => (
|
||||
<div key={index} className={`${isDark ? 'bg-[#0062d91a] border-[#55a6fe1a] shadow-[0_3px_4px_#8b8b8b0f] dark-theme' : 'bg-white border-[#f5f5f5] shadow-[2px_0px_6px_rgba(0,0,0,0.08)] task-item-active'} p-[10px] mt-[10px] mb-[10px] rounded-[4px] border ${taskId === item.id && 'task-active'} cursor-pointer`} onClick={() => taskDataChange(item.id)}>
|
||||
<div className="text-[var(--text-color)] text-[17px] mb-[5px] font-[500]">{item.taskName}</div>
|
||||
<div className="text-[#7f7f7f] mb-[5px]">{format(new Date(item.taskStartDate), 'yyyy-MM-dd HH:mm:ss')}</div>
|
||||
<div className="flex items-center mb-[8px]">
|
||||
<img src={`/icon/${item?.taskStatusData?.icon}`} alt="" />
|
||||
<span className="ml-[5px] leading-[16px]">{item?.taskStatusData?.label}</span>
|
||||
</div>
|
||||
<div className="pb-[10px] pt-[10px]">
|
||||
<div className="border-b border-[#e8e8e8]"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-[24px] item-active pl-[10px] pr-[10px] task-tab-color leading-[24px] rounded-[50px] bg-[var(--bg-color)]">
|
||||
完成{item.endCount}/{item.sumAll}
|
||||
</div>
|
||||
<div className="pl-[20px] flex-1">
|
||||
<Progress percentage={item.progress} label={false} color={item?.taskStatusData?.color} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</Loading>
|
||||
{/* 分页 */}
|
||||
<div>
|
||||
<Pagination
|
||||
total={total}
|
||||
size="small"
|
||||
theme="simple"
|
||||
current={pageNum}
|
||||
pageSize={pageSize}
|
||||
showPageSize={false}
|
||||
onCurrentChange={(page) => pageIndexChange(Number(page))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col span={10} className="h-full">
|
||||
<div className="h-full flex flex-col p-[0_10px]">
|
||||
<Loading loading={actionLoading}>
|
||||
<div className="flex grid grid-cols-4 gap-[10px] pl-[10px]">
|
||||
{
|
||||
actionColumnList.map((item: actionColumnItem, index: number) => (
|
||||
<ActionColumn key={index} icon={item.icon} columnIndex={index} columnName={item.columnName} actionCount={item.actionList.length} actionList={item.actionList} title={title} category={category} subCategory={subCategory} />
|
||||
))
|
||||
}
|
||||
{
|
||||
actionColumnList.length === 0 && <Empty/>
|
||||
}
|
||||
</div>
|
||||
</Loading>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { ActionListItem, TaskListItem, taskStatusList } from '@/pages/TaskManagement';
|
||||
import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizontal, Play, Search, User, Users, Workflow } from 'lucide-react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { PageType } from '../Sidebar';
|
||||
import { format } from 'date-fns';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Col, Empty, Pagination, Progress, Row } from 'tdesign-react';
|
||||
import { requestCrudService } from '@/service/request-axios-service';
|
||||
|
||||
interface ActionColumnProps {
|
||||
TaskType?: string;
|
||||
title: string;
|
||||
category: string;
|
||||
subCategory: string;
|
||||
icon: string;
|
||||
columnIndex: number;
|
||||
columnName: string;
|
||||
actionCount: number;
|
||||
actionList: ActionListItem[];
|
||||
}
|
||||
|
||||
const service = requestCrudService;
|
||||
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
export const ActionColumn: React.FC<ActionColumnProps> = ({ title, category, subCategory, icon, columnName, columnIndex, actionCount, actionList }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
// 主题颜色变量
|
||||
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
|
||||
const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-100';
|
||||
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
||||
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
|
||||
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
|
||||
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-white';
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
// 当前页码,默认为第 1 页
|
||||
const [pageNum, setPageNum] = useState<number>(1);
|
||||
// 每页显示数量,默认为 10 条
|
||||
const [pageSize, setPageSize] = useState<number>(10); // 每页显示 10 条
|
||||
const [total, setTotal] = useState<number>(0); // 查询任务总数
|
||||
const state = useRef({
|
||||
pageIndex: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
/**
|
||||
* 页码变化时的回调函数
|
||||
* 当用户切换页码时触发,更新页码并重新获取数据
|
||||
* @param value - 新的页码值
|
||||
*/
|
||||
const pageIndexChange = async (value: number) => {
|
||||
// 更新 ref 中存储的页码值
|
||||
// 更新页码状态,触发组件重新渲染
|
||||
setPageNum(value);
|
||||
state.current.pageIndex = value;
|
||||
}
|
||||
|
||||
const actionRouterChange = (item: ActionListItem) => {
|
||||
if (item.actionStatus !== 0) {
|
||||
window.open(`https://ch1.b.uat.4.cn/web/#/action/item-aiceh/step-analysis-three?id=${item.id}`);
|
||||
} else {
|
||||
window.open(`https://ch1.b.uat.4.cn/web/#/action/item-aiceh/step-action-topic?id=${item.id}&isPassApply=false`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-[calc(100vh-100px)] p-[10px] flex-1 ${isDark? 'dark-action-bg' : `bg-white action-bg${columnIndex} shadow-[2px_0px_6px_rgba(0,0,0,0.12)]`} rounded-[4px]`}>
|
||||
<div className="mb-[10px]">
|
||||
<div className="flex items-center mb-[20px]">
|
||||
<img src={`/icon/${icon}`} className="w-[24px] mr-[10px]" alt="" />
|
||||
<span className="text[#222222] text-[18px] font-bold mr-[20px]">{columnName}</span>
|
||||
<span className="text[#222222] text-[18px] font-bold mr-[20px]">{actionCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[calc(100vh-200px)] overflow-y-auto pr-[10px]">
|
||||
{
|
||||
actionList.map((item: ActionListItem, index: number) => (
|
||||
<div key={index} className={`${isDark? 'dark-action-item' : 'bg-white border border-[#f5f5f5] shadow-[0_1px_16px_#0000000f] action-item'} rounded-md px-4 py-2.5 mb-[10px] hover:boder-[var(--card-border)]`} onClick={() => actionRouterChange(item)}>
|
||||
<div className="line-clamp-4 overflow-hidden mb-[10px]">
|
||||
{
|
||||
item.actionName
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-center mb-[10px]">
|
||||
<span className={`${isDark? 'bg-[#0062d94d] text-[#aaceff]' :'bg-[#f5f5f5]'} leading-[16px] p-[5px_10px] rounded-[2px]`}>
|
||||
{item.level === 1 ? '主要行动' : '次要行动'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className={`${isDark? 'bg-[#0062d94d] text-[#aaceff]' :'bg-[#f5f5f5]'} leading-[16px] p-[5px_10px] rounded-[2px]`}>
|
||||
{item.deptName}
|
||||
</span>
|
||||
<span className="border border-[var(--color-main)] text-[var(--color-main)] cursor-pointer leading-[16px] ml-[10px] p-[5px_10px] rounded-[2px]">
|
||||
{
|
||||
item.actionStatus === 0 ? <span>XD签收</span> : <span>开始CEH</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{
|
||||
actionList.length === 0 && <Empty image={
|
||||
<img
|
||||
src="/icon/empty-list.png"
|
||||
alt="empty"
|
||||
className="w-[135px]"
|
||||
/>
|
||||
} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import "./css/iframe.css";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { PageType } from "@/components/Sidebar";
|
||||
|
||||
interface FanganTyPageProps {
|
||||
title: string;
|
||||
category: string;
|
||||
subCategory: string;
|
||||
onNavigate: (page: PageType) => void;
|
||||
}
|
||||
|
||||
const TYURL = import.meta.env.VITE_AIPI_B2ACTOR_URL || "";
|
||||
|
||||
const FanganTyPage: React.FC<FanganTyPageProps> = ({ onNavigate }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const [iframeUrl, setIframeUrl] = useState("");
|
||||
useEffect(() => {
|
||||
initIframeUrl();
|
||||
}, []);
|
||||
const initIframeUrl = () => {
|
||||
const url = `${TYURL}/fromB/toPage?defaultPage=iframeFaty`;
|
||||
console.log({ iframeUrl: url });
|
||||
setIframeUrl(url);
|
||||
};
|
||||
return (
|
||||
<div className="iframe-container">
|
||||
<iframe src={iframeUrl} className="full-size-iframe"></iframe>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FanganTyPage;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { TaskListItem } from '@/pages/TaskManagement';
|
||||
import { ChevronRight, FileText } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { PageType } from '../Sidebar';
|
||||
import { TaskCard } from './TaskCard';
|
||||
import { Pagination } from 'tdesign-react';
|
||||
|
||||
interface KanbanColumnProps {
|
||||
title: string;
|
||||
count: number;
|
||||
color: string;
|
||||
tasks: TaskListItem[];
|
||||
taskType: string;
|
||||
TaskDataType?: string; // 区分来源类型
|
||||
onNavigate: (page: PageType) => void;
|
||||
pageIndexSearch: (taskType: string, pageIndex: number) => void;
|
||||
handleStartAnalysis: (task: TaskListItem) => void
|
||||
}
|
||||
|
||||
export const KanbanColumn: React.FC<KanbanColumnProps> = ({ title, count, color, tasks,taskType, TaskDataType = 'task' , onNavigate, pageIndexSearch, handleStartAnalysis }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
||||
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
|
||||
// 当前页码,默认为第 1 页
|
||||
const [pageNum, setPageNum] = useState<number>(1);
|
||||
// 每页显示数量,默认为 10 条
|
||||
const [pageSize, setPageSize] = useState<number>(10); // 每页显示 10 条
|
||||
/**
|
||||
* 页码变化时的回调函数
|
||||
* 当用户切换页码时触发,更新页码并重新获取数据
|
||||
* @param value - 新的页码值
|
||||
*/
|
||||
const pageIndexChange = async (value: number) => {
|
||||
// 更新 ref 中存储的页码值
|
||||
// 更新页码状态,触发组件重新渲染
|
||||
console.log('taskType', taskType)
|
||||
setPageNum(value);
|
||||
await pageIndexSearch(taskType, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 每页显示数量变化时的回调函数
|
||||
* 当用户切换每页显示条数时触发,更新每页数量并重新获取数据
|
||||
* @param value - 新的每页显示数量
|
||||
*/
|
||||
const pageSizeChange = async (value: number) => {
|
||||
// 更新 ref 中存储的每页显示数量
|
||||
// 更新每页显示数量状态,触发组件重新渲染
|
||||
setPageSize(value);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${color}`} />
|
||||
<h2 className={`font-semibold text-sm ${textPrimary}`}>{title}</h2>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${isDark ? 'bg-slate-700/50 text-slate-400' : 'bg-red-50 text-red-500'
|
||||
}`}>{count}</span>
|
||||
</div>
|
||||
{/* <button className={`p-1 rounded transition-colors ${isDark ? 'hover:bg-slate-700/50' : 'hover:bg-red-50'
|
||||
}`}>
|
||||
<ChevronRight className={`w-4 h-4 ${textMuted}`} />
|
||||
</button> */}
|
||||
<div>
|
||||
<Pagination
|
||||
total={count}
|
||||
size="small"
|
||||
theme="simple"
|
||||
current={pageNum}
|
||||
pageSize={pageSize}
|
||||
maxPageBtn={3}
|
||||
showPageSize={false}
|
||||
totalContent={false}
|
||||
onCurrentChange={(page) => pageIndexChange(Number(page))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto space-y-3 pr-1 scrollbar-thin">
|
||||
{tasks.length === 0 ? (
|
||||
<div className={`flex flex-col items-center justify-center py-8 ${textMuted}`}>
|
||||
<FileText className="w-8 h-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">暂无任务</p>
|
||||
</div>
|
||||
) : (
|
||||
tasks.map(task => <TaskCard key={task.id} task={task} TaskType={TaskDataType} onNavigate={onNavigate} handleStartAnalysis={handleStartAnalysis}/>)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Eye,
|
||||
Play,
|
||||
Pause,
|
||||
Clock,
|
||||
User,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
Download,
|
||||
Bot,
|
||||
Users,
|
||||
Workflow,
|
||||
X,
|
||||
ArrowRight,
|
||||
Target,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { PageType } from '@/components/Sidebar';
|
||||
// 导入请求服务,用于与后端 API 交互
|
||||
import { requestCrudService } from '@/service/request-axios-service';
|
||||
import { KanbanColumn } from './KanbanColumn';
|
||||
import { TaskListItem } from '@/pages/TaskManagement';
|
||||
import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useChatContext } from '@/contexts/ChatContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface TaskListPageProps {
|
||||
title: string;
|
||||
category: string;
|
||||
subCategory: string;
|
||||
onNavigate: (page: PageType) => void;
|
||||
}
|
||||
|
||||
interface stateItem {
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
task: TaskListItem,
|
||||
}
|
||||
|
||||
type TaskType = 'single' | 'consultation' | 'scene' | 'multi-agent' | 'task-search';
|
||||
type TaskStatus = 'draft' | 'pending' | 'running' | 'paused' | 'blocked' | 'completed';
|
||||
type ColumnType = 'unanalyzed' | 'analyzing' | 'completed';
|
||||
|
||||
const service = requestCrudService;
|
||||
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategory, onNavigate }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const router = useNavigate();
|
||||
// 主题颜色变量
|
||||
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
|
||||
const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-100';
|
||||
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
||||
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
|
||||
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
|
||||
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-white';
|
||||
const { setStartConfig } = useChatContext();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [modeFilter, setModeFilter] = useState<string>('all');
|
||||
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
|
||||
const [selectedTaskForAnalysis, setSelectedTaskForAnalysis] = useState<TaskListItem | null>(null);
|
||||
const [unanalyzedTotal, setUnanalyzedTotal] = useState<number>(0); // 未分析
|
||||
const [runningTotal, setRunningTotal] = useState<number>(0); // 分析中
|
||||
const [completedTotal, setCompletedTotal] = useState<number>(0); // 已完成
|
||||
const [unanalyzedList, setUnanalyzedList] = useState<TaskListItem[]>([]); // 未分析
|
||||
const [runningList, setRunningList] = useState<TaskListItem[]>([]); // 分析中
|
||||
const [completedList, setCompletedList] = useState<TaskListItem[]>([]); // 已完成
|
||||
const [unanalyzedLoading, setUnanalyzedLoading] = useState<boolean>(false); // 未分析
|
||||
const [runningLoading, setRunningLoading] = useState<boolean>(false); // 分析中
|
||||
const [completedLoading, setCompletedLoading] = useState<boolean>(false); // 已完成
|
||||
const [presets] = useState<DateRangePickerProps['presets']>({
|
||||
最近7天: [dayjs().subtract(6, 'day').toDate(), dayjs().toDate()],
|
||||
最近3天: [dayjs().subtract(2, 'day').toDate(), dayjs().toDate()],
|
||||
今天: [dayjs().toDate(), dayjs().toDate()],
|
||||
});
|
||||
const [range, setRange] = useState<DateRangeValue>([]);
|
||||
const state = useRef<stateItem>({
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
task: {} as TaskListItem,
|
||||
})
|
||||
|
||||
// 时间日期选择器查询
|
||||
const setRangeChange = async (val) => {
|
||||
setRange(val);
|
||||
console.log('时间日期', val);
|
||||
state.current.startTime = '';
|
||||
state.current.endTime = '';
|
||||
if (val.length > 0) {
|
||||
state.current.startTime = val[0];
|
||||
state.current.endTime = val[1];
|
||||
}
|
||||
|
||||
await taskDataChange();
|
||||
}
|
||||
|
||||
// 处理开始分析按钮点击
|
||||
const handleStartAnalysis = (task: TaskListItem) => {
|
||||
setSelectedTaskForAnalysis(task);
|
||||
state.current.task = task;
|
||||
setShowAnalysisModal(true);
|
||||
};
|
||||
|
||||
// 处理分析模式选择
|
||||
const handleSelectAnalysisMode = (mode: TaskType) => {
|
||||
setShowAnalysisModal(false);
|
||||
console.log('mode', mode);
|
||||
if (mode === 'single') {
|
||||
onNavigate('task-single');
|
||||
} else if (mode === 'scene') {
|
||||
onNavigate('task-scene');
|
||||
} else if (mode === 'consultation') {
|
||||
console.log('state.current.task', state.current.task)
|
||||
const sceneItem = JSON.parse(state.current.task.scene);
|
||||
if (sceneItem.taskJson) {
|
||||
sceneItem.taskJson = JSON.parse(sceneItem.taskJson);
|
||||
setStartConfig({
|
||||
...sceneItem.taskJson,
|
||||
llm_preset_id: 'BAILING',
|
||||
consistency_mode: 'soft',
|
||||
phase_config: { diverge_rounds: 3 },
|
||||
task_context: state.current.task.overview
|
||||
});
|
||||
router('/qa-center/mope-chat')
|
||||
}
|
||||
console.log('sceneItem', sceneItem);
|
||||
// onNavigate('task-consult-create');
|
||||
// window.open(`http://ch1.b.uat.4.cn/mopeweb/#/mope?taskOverview=${state.current.task.overview}`)
|
||||
} else if (mode === 'multi-agent') {
|
||||
onNavigate('multi-agent-qa');
|
||||
} else if (mode === 'task-search') {
|
||||
window.open(`https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-info-search?id=${state.current.task.id}`)
|
||||
}
|
||||
};
|
||||
|
||||
// 处理卡片点击
|
||||
const taskListSearch = async (taskType: string, pageNum: number = 1, pageSize: number = 10) => {
|
||||
let taskStatus: number[] = []
|
||||
if (taskType === 'unanalyzed') { // 未分析
|
||||
taskStatus = [0, 10];
|
||||
setUnanalyzedLoading(true);
|
||||
} else if (taskType === 'running') { // 分析中
|
||||
taskStatus = [20];
|
||||
setRunningLoading(true);
|
||||
} else if (taskType === 'completed') { // 已完成
|
||||
taskStatus = [25, 30, 40, 60, 70, 80, 90, 100, 110];
|
||||
setCompletedLoading(true);
|
||||
}
|
||||
const param = {
|
||||
content: searchQuery,
|
||||
pageNum: pageNum,
|
||||
pageSize: pageSize,
|
||||
taskStatus: taskStatus,
|
||||
startTime: state.current.startTime,
|
||||
endTime: state.current.endTime,
|
||||
taskDirection: ''
|
||||
}
|
||||
const res = await service.post(`${zncmUrl}/three/cop-three-tasks`, param);
|
||||
console.log('任务列表', res);
|
||||
const result = res.data;
|
||||
result.records.forEach((item: TaskListItem) => {
|
||||
item.column = taskType;
|
||||
if (!item.taskLevel) item.taskLevel = 1;
|
||||
})
|
||||
if (taskType === 'unanalyzed') {
|
||||
setUnanalyzedLoading(false);
|
||||
setUnanalyzedTotal(result.total);
|
||||
setUnanalyzedList(result.records);
|
||||
} else if (taskType === 'running') {
|
||||
setRunningLoading(false);
|
||||
setRunningTotal(result.total);
|
||||
setRunningList(result.records);
|
||||
} else if (taskType === 'completed') {
|
||||
setCompletedLoading(false);
|
||||
setCompletedTotal(result.total);
|
||||
setCompletedList(result.records);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页码变化时的回调函数
|
||||
* 当用户切换页码时触发,更新页码并重新获取数据
|
||||
* @param value - 新的页码值
|
||||
*/
|
||||
const pageIndexChange = async (type: string, pageIndex: number) => {
|
||||
// 更新 ref 中存储的页码值
|
||||
// 更新页码状态,触发组件重新渲染
|
||||
await taskListSearch(type, pageIndex);
|
||||
}
|
||||
|
||||
const taskDataChange = async (pageNum: number = 1, pageSize: number = 10) => {
|
||||
taskListSearch('unanalyzed', pageNum, pageSize);
|
||||
taskListSearch('running', pageNum, pageSize);
|
||||
taskListSearch('completed', pageNum, pageSize);
|
||||
}
|
||||
|
||||
// 回车处理
|
||||
const handleKeyDown = async (e) => {
|
||||
if ((e.metaKey || e.ctrlKey || e.shiftKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
} else if (e.keyCode === 13) {
|
||||
e.preventDefault();
|
||||
await taskDataChange();
|
||||
} else if (e.key === "/") {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
taskDataChange();
|
||||
}, []);
|
||||
|
||||
// 渲染看板列
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className={`rounded-xl p-4 flex-shrink-0 mb-4 ${cardBg} border ${cardBorder}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-red-100 text-red-600'}`}>
|
||||
{category}
|
||||
</span>
|
||||
<span className={`text-xs ${textMuted}`}>/</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-cyan-500/20 text-cyan-400' : 'bg-red-100 text-red-600'}`}>
|
||||
{subCategory}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className={`text-xl font-bold ${textPrimary}`}>任务中心</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('task-create')}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors ${isDark
|
||||
? 'bg-blue-600 hover:bg-blue-500'
|
||||
: 'bg-red-600 hover:bg-red-500 shadow-md'
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
新建任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${textMuted}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索任务名称"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e)}
|
||||
className={`w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-colors ${inputBg} ${cardBorder} ${textPrimary} placeholder:${textMuted}`}
|
||||
/>
|
||||
</div>
|
||||
{/* <select
|
||||
value={modeFilter}
|
||||
onChange={(e) => setModeFilter(e.target.value)}
|
||||
className={`px-4 py-2 border rounded-lg text-sm focus:outline-none cursor-pointer ${inputBg} ${cardBorder} ${textSecondary}`}
|
||||
>
|
||||
<option value="all">全部模式</option>
|
||||
<option value="single">单智能体</option>
|
||||
<option value="multi-agent">多智能体协同</option>
|
||||
<option value="consultation">多智能体会商</option>
|
||||
<option value="scene">场景执行</option>
|
||||
</select> */}
|
||||
{/* <button className={`flex items-center gap-2 px-4 py-2 border rounded-lg text-sm transition-colors ${inputBg} ${cardBorder} ${textSecondary}`}>
|
||||
<Clock className="w-4 h-4" />
|
||||
时间范围
|
||||
</button> */}
|
||||
<DateRangePicker value={range} presets={presets} onChange={(val) => setRangeChange(val)} enableTimePicker={true} clearable />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计概览栏 */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-4 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => { setModeFilter('all'); }}
|
||||
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
|
||||
>
|
||||
<p className={`text-2xl font-bold ${textPrimary}`}>{unanalyzedTotal + runningTotal + completedTotal}</p>
|
||||
<p className={`text-sm ${textSecondary}`}>全部任务</p>
|
||||
</button>
|
||||
<button
|
||||
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
|
||||
>
|
||||
<p className={`text-2xl font-bold ${textPrimary}`}>{unanalyzedTotal}</p>
|
||||
<p className={`text-sm ${textSecondary}`}>未分析</p>
|
||||
</button>
|
||||
<button
|
||||
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
|
||||
>
|
||||
<p className={`text-2xl font-bold ${isDark ? 'text-blue-400' : 'text-red-600'}`}>{runningTotal}</p>
|
||||
<p className={`text-sm ${textSecondary}`}>分析中</p>
|
||||
</button>
|
||||
<button
|
||||
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
|
||||
>
|
||||
<p className={`text-2xl font-bold ${isDark ? 'text-green-400' : 'text-green-600'}`}>{completedTotal}</p>
|
||||
<p className={`text-sm ${textSecondary}`}>已完成</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 看板区 */}
|
||||
<div className="flex-1 grid grid-cols-3 gap-4 min-h-0 overflow-hidden">
|
||||
<Loading loading={unanalyzedLoading}>
|
||||
<KanbanColumn title="未分析" taskType={"unanalyzed"} TaskDataType={'sentiment'} count={unanalyzedTotal} color={isDark ? 'bg-yellow-400' : 'bg-yellow-400'} tasks={unanalyzedList} onNavigate={onNavigate} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
|
||||
</Loading>
|
||||
<Loading loading={runningLoading}>
|
||||
<KanbanColumn title="分析中" taskType="running" TaskDataType={'sentiment'} count={runningTotal} color={isDark ? 'bg-blue-400' : 'bg-red-400'} tasks={runningList} onNavigate={onNavigate} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
|
||||
</Loading>
|
||||
<Loading loading={completedLoading}>
|
||||
<KanbanColumn title="已完成" taskType="completed" TaskDataType={'sentiment'} count={completedTotal} color={isDark ? 'bg-green-400' : 'bg-green-400'} tasks={completedList} onNavigate={onNavigate} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
|
||||
</Loading>
|
||||
</div>
|
||||
|
||||
{/* 选择分析模式弹窗 */}
|
||||
{showAnalysisModal && selectedTaskForAnalysis && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowAnalysisModal(false)}>
|
||||
<div
|
||||
className={`w-full max-w-lg rounded-2xl p-6 ${cardBg} border ${cardBorder} shadow-xl`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className={`text-xl font-bold ${textPrimary}`}>选择分析模式</h2>
|
||||
<p className={`text-sm ${textMuted} mt-1`}>为任务「{selectedTaskForAnalysis.taskName}」选择分析方式</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAnalysisModal(false)}
|
||||
className={`p-2 rounded-lg hover:bg-slate-700/50 ${textSecondary}`}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
|
||||
<button
|
||||
onClick={() => handleSelectAnalysisMode('task-search')}
|
||||
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
|
||||
? 'bg-slate-700/50 border-blue-800/30 hover:border-green-500/50'
|
||||
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-green-500/20' : 'bg-green-100'
|
||||
}`}>
|
||||
<Layers className={`w-6 h-6 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${textPrimary}`}>查看任务详情</h3>
|
||||
<p className={`text-sm ${textSecondary}`}>进入b1系统查看任务详情</p>
|
||||
</div>
|
||||
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
{/* 多智能体会商 */}
|
||||
<button
|
||||
onClick={() => handleSelectAnalysisMode('consultation')}
|
||||
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
|
||||
? 'bg-slate-700/50 border-blue-800/30 hover:border-orange-500/50'
|
||||
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-orange-500/20' : 'bg-red-100'
|
||||
}`}>
|
||||
<Users className={`w-6 h-6 ${isDark ? 'text-orange-400' : 'text-red-500'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${textPrimary}`}>多智能体会商</h3>
|
||||
<p className={`text-sm ${textSecondary}`}>多个AI智能体从不同视角开展讨论并形成共识</p>
|
||||
</div>
|
||||
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SentimentAnalyzes;
|
||||
@@ -0,0 +1,678 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Eye,
|
||||
Play,
|
||||
Pause,
|
||||
Clock,
|
||||
User,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
Download,
|
||||
Bot,
|
||||
Users,
|
||||
Workflow,
|
||||
X,
|
||||
ArrowRight,
|
||||
Target,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { PageType } from '@/components/Sidebar';
|
||||
// 导入请求服务,用于与后端 API 交互
|
||||
import { requestCrudService } from '@/service/request-axios-service';
|
||||
import { KanbanColumn } from './KanbanColumn';
|
||||
import { TaskListItem } from '@/pages/TaskManagement';
|
||||
import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { useChatContext } from '@/contexts/ChatContext';
|
||||
|
||||
interface TaskListPageProps {
|
||||
title: string;
|
||||
category: string;
|
||||
subCategory: string;
|
||||
onNavigate: (page: PageType) => void;
|
||||
}
|
||||
|
||||
type TaskType = 'single' | 'consultation' | 'scene' | 'multi-agent' | 'task-search';
|
||||
type TaskStatus = 'draft' | 'pending' | 'running' | 'paused' | 'blocked' | 'completed';
|
||||
type ColumnType = 'unanalyzed' | 'analyzing' | 'completed';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TaskType;
|
||||
status: TaskStatus;
|
||||
agent?: string;
|
||||
scene?: string;
|
||||
participants?: string[];
|
||||
currentPhase?: string;
|
||||
currentRound?: number;
|
||||
summary?: string;
|
||||
creator: string;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
column: ColumnType;
|
||||
}
|
||||
|
||||
interface stateItem {
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
task: TaskListItem,
|
||||
}
|
||||
|
||||
const service = requestCrudService;
|
||||
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategory, onNavigate }) => {
|
||||
const { theme } = useTheme();
|
||||
const { setStartConfig } = useChatContext();
|
||||
const router = useNavigate();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// 主题颜色变量
|
||||
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
|
||||
const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-100';
|
||||
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
||||
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
|
||||
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
|
||||
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-white';
|
||||
|
||||
// 生成模拟数据
|
||||
const [tasks] = useState<Task[]>([
|
||||
// 未分析列
|
||||
{
|
||||
id: '2026-0320-001',
|
||||
name: '端午节品牌传播方案',
|
||||
type: 'scene',
|
||||
scene: '宣传策略生成',
|
||||
status: 'draft',
|
||||
column: 'unanalyzed',
|
||||
summary: '针对端午节品牌活动制定完整传播策略方案',
|
||||
creator: '李策划',
|
||||
createTime: '2026-03-20 10:00',
|
||||
updateTime: '2026-03-20 10:00',
|
||||
},
|
||||
{
|
||||
id: '2026-0320-002',
|
||||
name: '新品上市用户调研',
|
||||
type: 'single',
|
||||
agent: 'AudienceAgent',
|
||||
status: 'pending',
|
||||
column: 'unanalyzed',
|
||||
summary: '对新品目标用户进行画像分析',
|
||||
creator: '王运营',
|
||||
createTime: '2026-03-20 09:30',
|
||||
updateTime: '2026-03-20 09:30',
|
||||
},
|
||||
// 分析中列
|
||||
{
|
||||
id: '2026-0319-001',
|
||||
name: '五四青年节宣传活动策略',
|
||||
type: 'scene',
|
||||
scene: '宣传策略生成',
|
||||
status: 'running',
|
||||
currentPhase: '行为分析',
|
||||
column: 'analyzing',
|
||||
summary: '正在按场景模板执行目标、受众、行为、叙事等分析...',
|
||||
creator: '张策略',
|
||||
createTime: '2026-03-19 10:30',
|
||||
updateTime: '2026-03-20 09:45',
|
||||
},
|
||||
{
|
||||
id: '2026-0319-002',
|
||||
name: '青年传播方向会商分析',
|
||||
type: 'consultation',
|
||||
participants: ['GoalAgent', 'AudienceAgent', 'NarrativeAgent'],
|
||||
status: 'running',
|
||||
currentRound: 2,
|
||||
column: 'analyzing',
|
||||
summary: '围绕宣传方向、叙事方式和受众切入点开展多视角会商...',
|
||||
creator: '王策划',
|
||||
createTime: '2026-03-19 14:00',
|
||||
updateTime: '2026-03-20 09:00',
|
||||
},
|
||||
{
|
||||
id: '2026-0318-001',
|
||||
name: '年度品牌升级方案推演',
|
||||
type: 'scene',
|
||||
scene: '宣传方案推演',
|
||||
status: 'paused',
|
||||
currentPhase: '叙事分析',
|
||||
column: 'analyzing',
|
||||
summary: '任务已暂停,等待人工介入确认叙事方向',
|
||||
creator: '王策划',
|
||||
createTime: '2026-03-18 14:00',
|
||||
updateTime: '2026-03-20 08:30',
|
||||
},
|
||||
// 已完成列
|
||||
{
|
||||
id: '2026-0318-002',
|
||||
name: '核心受众画像分析',
|
||||
type: 'single',
|
||||
agent: 'AudienceAgent',
|
||||
status: 'completed',
|
||||
column: 'completed',
|
||||
summary: '已完成目标受众识别、关切提炼和阻力分析',
|
||||
creator: '赵运营',
|
||||
createTime: '2026-03-18 09:00',
|
||||
updateTime: '2026-03-18 16:30',
|
||||
},
|
||||
{
|
||||
id: '2026-0319-003',
|
||||
name: '五四青年节宣传策略',
|
||||
type: 'scene',
|
||||
scene: '宣传策略生成',
|
||||
status: 'completed',
|
||||
column: 'completed',
|
||||
summary: '已完成宣传策略方案生成',
|
||||
creator: '张策略',
|
||||
createTime: '2026-03-19 10:00',
|
||||
updateTime: '2026-03-19 18:00',
|
||||
},
|
||||
{
|
||||
id: '2026-0319-004',
|
||||
name: '青年品牌传播路径会商',
|
||||
type: 'consultation',
|
||||
participants: ['GoalAgent', 'NarrativeAgent', 'RiskAgent'],
|
||||
status: 'completed',
|
||||
column: 'completed',
|
||||
summary: '已完成多智能体会商,形成结论与分歧报告',
|
||||
creator: '王策划',
|
||||
createTime: '2026-03-19 14:30',
|
||||
updateTime: '2026-03-19 17:00',
|
||||
},
|
||||
]);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [modeFilter, setModeFilter] = useState<string>('all');
|
||||
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
|
||||
const [selectedTaskForAnalysis, setSelectedTaskForAnalysis] = useState<TaskListItem | null>(null);
|
||||
const [unanalyzedTotal, setUnanalyzedTotal] = useState<number>(0); // 未分析
|
||||
const [runningTotal, setRunningTotal] = useState<number>(0); // 分析中
|
||||
const [completedTotal, setCompletedTotal] = useState<number>(0); // 已完成
|
||||
const [unanalyzedList, setUnanalyzedList] = useState<TaskListItem[]>([]); // 未分析
|
||||
const [runningList, setRunningList] = useState<TaskListItem[]>([]); // 分析中
|
||||
const [completedList, setCompletedList] = useState<TaskListItem[]>([]); // 已完成
|
||||
const [unanalyzedLoading, setUnanalyzedLoading] = useState<boolean>(false); // 未分析
|
||||
const [runningLoading, setRunningLoading] = useState<boolean>(false); // 分析中
|
||||
const [completedLoading, setCompletedLoading] = useState<boolean>(false); // 已完成
|
||||
const [presets] = useState<DateRangePickerProps['presets']>({
|
||||
最近7天: [dayjs().subtract(6, 'day').toDate(), dayjs().toDate()],
|
||||
最近3天: [dayjs().subtract(2, 'day').toDate(), dayjs().toDate()],
|
||||
今天: [dayjs().toDate(), dayjs().toDate()],
|
||||
});
|
||||
const [range, setRange] = useState<DateRangeValue>([]);
|
||||
const state = useRef<stateItem>({
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
task: {} as TaskListItem,
|
||||
})
|
||||
|
||||
// 时间日期选择器查询
|
||||
const setRangeChange = async (val) => {
|
||||
setRange(val);
|
||||
console.log('时间日期', val);
|
||||
state.current.startTime = '';
|
||||
state.current.endTime = '';
|
||||
if (val.length > 0) {
|
||||
state.current.startTime = val[0];
|
||||
state.current.endTime = val[1];
|
||||
}
|
||||
|
||||
await taskDataChange();
|
||||
}
|
||||
|
||||
// 获取任务类型配置
|
||||
const getTypeConfig = (type: TaskType) => {
|
||||
if (isDark) {
|
||||
switch (type) {
|
||||
case 'single':
|
||||
return { label: '单智能体', color: 'bg-cyan-500/20 text-cyan-400', icon: Bot };
|
||||
case 'scene':
|
||||
return { label: '场景执行', color: 'bg-purple-500/20 text-purple-400', icon: Workflow };
|
||||
case 'consultation':
|
||||
return { label: '多智能体会商', color: 'bg-orange-500/20 text-orange-400', icon: Users };
|
||||
case 'multi-agent':
|
||||
return { label: '多智能体协同', color: 'bg-green-500/20 text-green-400', icon: Layers };
|
||||
}
|
||||
} else {
|
||||
switch (type) {
|
||||
case 'single':
|
||||
return { label: '单智能体', color: 'bg-blue-100 text-blue-600', icon: Bot };
|
||||
case 'scene':
|
||||
return { label: '场景执行', color: 'bg-purple-100 text-purple-600', icon: Workflow };
|
||||
case 'consultation':
|
||||
return { label: '多智能体会商', color: 'bg-orange-100 text-orange-600', icon: Users };
|
||||
case 'multi-agent':
|
||||
return { label: '多智能体协同', color: 'bg-green-100 text-green-600', icon: Layers };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态配置
|
||||
const getStatusConfig = (status: TaskStatus, column: ColumnType) => {
|
||||
if (column === 'unanalyzed') {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return { label: '草稿', color: isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-gray-100 text-gray-600' };
|
||||
case 'pending':
|
||||
return { label: '待启动', color: isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-gray-100 text-gray-600' };
|
||||
default:
|
||||
return { label: '待配置', color: isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-600' };
|
||||
}
|
||||
} else if (column === 'analyzing') {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return { label: '运行中', color: isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-red-100 text-red-600' };
|
||||
case 'paused':
|
||||
return { label: '已暂停', color: isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-600' };
|
||||
case 'blocked':
|
||||
return { label: '阻塞中', color: isDark ? 'bg-red-500/20 text-red-400' : 'bg-orange-100 text-orange-600' };
|
||||
default:
|
||||
return { label: '待处理', color: isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-600' };
|
||||
}
|
||||
} else {
|
||||
return { label: '已完成', color: isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-600' };
|
||||
}
|
||||
};
|
||||
|
||||
// 筛选任务
|
||||
const filteredTasks = tasks.filter(task => {
|
||||
const matchesSearch = task.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
task.id.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesMode = modeFilter === 'all' || task.type === modeFilter;
|
||||
return matchesSearch && matchesMode;
|
||||
});
|
||||
|
||||
const unanalyzedTasks = filteredTasks.filter(t => t.column === 'unanalyzed');
|
||||
const analyzingTasks = filteredTasks.filter(t => t.column === 'analyzing');
|
||||
const completedTasks = filteredTasks.filter(t => t.column === 'completed');
|
||||
|
||||
// 统计数据
|
||||
const stats = {
|
||||
total: tasks.length,
|
||||
unanalyzed: unanalyzedTasks.length,
|
||||
analyzing: analyzingTasks.length,
|
||||
completed: completedTasks.length,
|
||||
};
|
||||
|
||||
// 处理开始分析按钮点击
|
||||
const handleStartAnalysis = (task: TaskListItem) => {
|
||||
setSelectedTaskForAnalysis(task);
|
||||
state.current.task = task;
|
||||
setShowAnalysisModal(true);
|
||||
};
|
||||
|
||||
// 处理分析模式选择
|
||||
const handleSelectAnalysisMode = (mode: TaskType) => {
|
||||
setShowAnalysisModal(false);
|
||||
console.log('mode', mode);
|
||||
if (mode === 'single') {
|
||||
onNavigate('task-single');
|
||||
} else if (mode === 'scene') {
|
||||
onNavigate('task-scene');
|
||||
} else if (mode === 'consultation') {
|
||||
console.log('state.current.task', state.current.task)
|
||||
const sceneItem = JSON.parse(state.current.task.scene);
|
||||
if (sceneItem.taskJson) {
|
||||
sceneItem.taskJson = JSON.parse(sceneItem.taskJson);
|
||||
setStartConfig({
|
||||
...sceneItem.taskJson,
|
||||
llm_preset_id: 'BAILING',
|
||||
consistency_mode: 'soft',
|
||||
phase_config: {diverge_rounds: 3},
|
||||
task_context: state.current.task.overview
|
||||
});
|
||||
router('/qa-center/mope-chat')
|
||||
}
|
||||
console.log('sceneItem', sceneItem);
|
||||
// onNavigate('task-consult-create');
|
||||
// window.open(`http://ch1.b.uat.4.cn/mopeweb/#/mope?taskOverview=${state.current.task.overview}`)
|
||||
} else if (mode === 'multi-agent') {
|
||||
onNavigate('multi-agent-qa');
|
||||
} else if (mode === 'task-search') {
|
||||
window.open(`https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-info-search?id=${state.current.task.id}`)
|
||||
}
|
||||
};
|
||||
|
||||
// 处理卡片点击
|
||||
const handleTaskClick = (task: Task) => {
|
||||
if (task.column === 'completed') {
|
||||
if (task.type === 'consultation') {
|
||||
onNavigate('task-consult-result');
|
||||
} else {
|
||||
onNavigate('strategy-result');
|
||||
}
|
||||
} else if (task.column === 'analyzing') {
|
||||
if (task.type === 'scene') {
|
||||
onNavigate('task-scene');
|
||||
} else if (task.type === 'consultation') {
|
||||
onNavigate('task-consult');
|
||||
} else {
|
||||
onNavigate('task-single');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const taskListSearch = async (taskType: string, pageNum: number = 1, pageSize: number = 10) => {
|
||||
let taskStatus: number[] = []
|
||||
if (taskType === 'unanalyzed') { // 未分析
|
||||
taskStatus = [0, 10];
|
||||
setUnanalyzedLoading(true);
|
||||
} else if (taskType === 'running') { // 分析中
|
||||
taskStatus = [20];
|
||||
setRunningLoading(true);
|
||||
} else if (taskType === 'completed') { // 已完成
|
||||
taskStatus = [25, 30, 40, 60, 70, 80, 90, 100, 110];
|
||||
setCompletedLoading(true);
|
||||
}
|
||||
const param = {
|
||||
content: searchQuery,
|
||||
pageNum: pageNum,
|
||||
pageSize: pageSize,
|
||||
taskStatus: taskStatus,
|
||||
startTime: state.current.startTime,
|
||||
endTime: state.current.endTime,
|
||||
taskDirection: ''
|
||||
}
|
||||
const res = await service.post(`${zncmUrl}/task/cop-tasks`, param);
|
||||
console.log('任务列表', res);
|
||||
const result = res.data;
|
||||
result.records.forEach((item: TaskListItem) => {
|
||||
item.column = taskType;
|
||||
if (!item.taskLevel) item.taskLevel = 1;
|
||||
})
|
||||
if (taskType === 'unanalyzed') {
|
||||
setUnanalyzedLoading(false);
|
||||
setUnanalyzedTotal(result.total);
|
||||
setUnanalyzedList(result.records);
|
||||
} else if (taskType === 'running') {
|
||||
setRunningLoading(false);
|
||||
setRunningTotal(result.total);
|
||||
setRunningList(result.records);
|
||||
} else if (taskType === 'completed') {
|
||||
setCompletedLoading(false);
|
||||
setCompletedTotal(result.total);
|
||||
setCompletedList(result.records);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页码变化时的回调函数
|
||||
* 当用户切换页码时触发,更新页码并重新获取数据
|
||||
* @param value - 新的页码值
|
||||
*/
|
||||
const pageIndexChange = async (type: string, pageIndex: number) => {
|
||||
// 更新 ref 中存储的页码值
|
||||
// 更新页码状态,触发组件重新渲染
|
||||
await taskListSearch(type, pageIndex);
|
||||
}
|
||||
|
||||
const taskDataChange = async (pageNum: number = 1, pageSize: number = 10) => {
|
||||
taskListSearch('unanalyzed', pageNum, pageSize);
|
||||
taskListSearch('running', pageNum, pageSize);
|
||||
taskListSearch('completed', pageNum, pageSize);
|
||||
}
|
||||
|
||||
// 回车处理
|
||||
const handleKeyDown = async (e) => {
|
||||
if ((e.metaKey || e.ctrlKey || e.shiftKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
} else if (e.keyCode === 13) {
|
||||
e.preventDefault();
|
||||
await taskDataChange();
|
||||
} else if (e.key === "/") {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
taskDataChange();
|
||||
}, []);
|
||||
|
||||
// 渲染看板列
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className={`rounded-xl p-4 flex-shrink-0 mb-4 ${cardBg} border ${cardBorder}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-red-100 text-red-600'}`}>
|
||||
{category}
|
||||
</span>
|
||||
<span className={`text-xs ${textMuted}`}>/</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-cyan-500/20 text-cyan-400' : 'bg-red-100 text-red-600'}`}>
|
||||
{subCategory}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className={`text-xl font-bold ${textPrimary}`}>任务中心</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('task-create')}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors ${isDark
|
||||
? 'bg-blue-600 hover:bg-blue-500'
|
||||
: 'bg-red-600 hover:bg-red-500 shadow-md'
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
新建任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${textMuted}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索任务名称"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e)}
|
||||
className={`w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-colors ${inputBg} ${cardBorder} ${textPrimary} placeholder:${textMuted}`}
|
||||
/>
|
||||
</div>
|
||||
{/* <select
|
||||
value={modeFilter}
|
||||
onChange={(e) => setModeFilter(e.target.value)}
|
||||
className={`px-4 py-2 border rounded-lg text-sm focus:outline-none cursor-pointer ${inputBg} ${cardBorder} ${textSecondary}`}
|
||||
>
|
||||
<option value="all">全部模式</option>
|
||||
<option value="single">单智能体</option>
|
||||
<option value="multi-agent">多智能体协同</option>
|
||||
<option value="consultation">多智能体会商</option>
|
||||
<option value="scene">场景执行</option>
|
||||
</select> */}
|
||||
{/* <button className={`flex items-center gap-2 px-4 py-2 border rounded-lg text-sm transition-colors ${inputBg} ${cardBorder} ${textSecondary}`}>
|
||||
<Clock className="w-4 h-4" />
|
||||
时间范围
|
||||
</button> */}
|
||||
<DateRangePicker value={range} presets={presets} onChange={(val) => setRangeChange(val)} enableTimePicker={true} clearable />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计概览栏 */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-4 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => { setModeFilter('all'); }}
|
||||
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
|
||||
>
|
||||
<p className={`text-2xl font-bold ${textPrimary}`}>{unanalyzedTotal + runningTotal + completedTotal}</p>
|
||||
<p className={`text-sm ${textSecondary}`}>全部任务</p>
|
||||
</button>
|
||||
<button
|
||||
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
|
||||
>
|
||||
<p className={`text-2xl font-bold ${textPrimary}`}>{unanalyzedTotal}</p>
|
||||
<p className={`text-sm ${textSecondary}`}>未分析</p>
|
||||
</button>
|
||||
<button
|
||||
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
|
||||
>
|
||||
<p className={`text-2xl font-bold ${isDark ? 'text-blue-400' : 'text-red-600'}`}>{runningTotal}</p>
|
||||
<p className={`text-sm ${textSecondary}`}>分析中</p>
|
||||
</button>
|
||||
<button
|
||||
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
|
||||
>
|
||||
<p className={`text-2xl font-bold ${isDark ? 'text-green-400' : 'text-green-600'}`}>{completedTotal}</p>
|
||||
<p className={`text-sm ${textSecondary}`}>已完成</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 看板区 */}
|
||||
<div className="flex-1 grid grid-cols-3 gap-4 min-h-0 overflow-hidden">
|
||||
<Loading loading={unanalyzedLoading}>
|
||||
<KanbanColumn title="未分析" taskType={"unanalyzed"} count={unanalyzedTotal} color={isDark ? 'bg-yellow-400' : 'bg-yellow-400'} tasks={unanalyzedList} onNavigate={onNavigate} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis}/>
|
||||
</Loading>
|
||||
<Loading loading={runningLoading}>
|
||||
<KanbanColumn title="分析中" taskType="running" count={runningTotal} color={isDark ? 'bg-blue-400' : 'bg-red-400'} tasks={runningList} onNavigate={onNavigate} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis}/>
|
||||
</Loading>
|
||||
<Loading loading={completedLoading}>
|
||||
<KanbanColumn title="已完成" taskType="completed" count={completedTotal} color={isDark ? 'bg-green-400' : 'bg-green-400'} tasks={completedList} onNavigate={onNavigate} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis}/>
|
||||
</Loading>
|
||||
</div>
|
||||
|
||||
{/* 选择分析模式弹窗 */}
|
||||
{showAnalysisModal && selectedTaskForAnalysis && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowAnalysisModal(false)}>
|
||||
<div
|
||||
className={`w-full max-w-lg rounded-2xl p-6 ${cardBg} border ${cardBorder} shadow-xl`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className={`text-xl font-bold ${textPrimary}`}>选择分析模式</h2>
|
||||
<p className={`text-sm ${textMuted} mt-1`}>为任务「{selectedTaskForAnalysis.taskName}」选择分析方式</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAnalysisModal(false)}
|
||||
className={`p-2 rounded-lg hover:bg-slate-700/50 ${textSecondary}`}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 单智能体分析 */}
|
||||
{/* <button
|
||||
onClick={() => handleSelectAnalysisMode('single')}
|
||||
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
|
||||
? 'bg-slate-700/50 border-blue-800/30 hover:border-cyan-500/50'
|
||||
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-cyan-500/20' : 'bg-red-100'
|
||||
}`}>
|
||||
<Bot className={`w-6 h-6 ${isDark ? 'text-cyan-400' : 'text-red-500'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${textPrimary}`}>单智能体分析</h3>
|
||||
<p className={`text-sm ${textSecondary}`}>选择一个AI智能体完成单一维度的专项分析</p>
|
||||
</div>
|
||||
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
|
||||
</div>
|
||||
</button> */}
|
||||
|
||||
{/* 多智能体协同分析 */}
|
||||
{/* <button
|
||||
onClick={() => handleSelectAnalysisMode('multi-agent')}
|
||||
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
|
||||
? 'bg-slate-700/50 border-blue-800/30 hover:border-green-500/50'
|
||||
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-green-500/20' : 'bg-green-100'
|
||||
}`}>
|
||||
<Layers className={`w-6 h-6 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${textPrimary}`}>多智能体协同分析</h3>
|
||||
<p className={`text-sm ${textSecondary}`}>多个AI智能体并行分析,输出各自的判断和结论</p>
|
||||
</div>
|
||||
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
|
||||
</div>
|
||||
</button> */}
|
||||
<button
|
||||
onClick={() => handleSelectAnalysisMode('task-search')}
|
||||
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
|
||||
? 'bg-slate-700/50 border-blue-800/30 hover:border-green-500/50'
|
||||
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-green-500/20' : 'bg-green-100'
|
||||
}`}>
|
||||
<Layers className={`w-6 h-6 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${textPrimary}`}>查看任务详情</h3>
|
||||
<p className={`text-sm ${textSecondary}`}>进入b1系统查看任务详情</p>
|
||||
</div>
|
||||
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 多智能体会商 */}
|
||||
<button
|
||||
onClick={() => handleSelectAnalysisMode('consultation')}
|
||||
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
|
||||
? 'bg-slate-700/50 border-blue-800/30 hover:border-orange-500/50'
|
||||
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-orange-500/20' : 'bg-red-100'
|
||||
}`}>
|
||||
<Users className={`w-6 h-6 ${isDark ? 'text-orange-400' : 'text-red-500'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${textPrimary}`}>多智能体会商</h3>
|
||||
<p className={`text-sm ${textSecondary}`}>多个AI智能体从不同视角开展讨论并形成共识</p>
|
||||
</div>
|
||||
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 场景执行 */}
|
||||
{/* <button
|
||||
onClick={() => handleSelectAnalysisMode('scene')}
|
||||
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
|
||||
? 'bg-slate-700/50 border-blue-800/30 hover:border-purple-500/50'
|
||||
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-purple-500/20' : 'bg-red-100'
|
||||
}`}>
|
||||
<Workflow className={`w-6 h-6 ${isDark ? 'text-purple-400' : 'text-red-500'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${textPrimary}`}>场景执行</h3>
|
||||
<p className={`text-sm ${textSecondary}`}>按照预设场景模板自动执行多阶段策略分析</p>
|
||||
</div>
|
||||
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
|
||||
</div>
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskAnalyzes;
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { TaskListItem } from '@/pages/TaskManagement';
|
||||
import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizontal, Play, User, Users, Workflow } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { PageType } from '../Sidebar';
|
||||
import { format } from 'date-fns';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useChatContext } from '@/contexts/ChatContext';
|
||||
|
||||
interface KanbanColumnProps {
|
||||
TaskType?: string;
|
||||
task: TaskListItem;
|
||||
onNavigate: (page: PageType) => void;
|
||||
handleStartAnalysis: (task: TaskListItem) => void
|
||||
}
|
||||
|
||||
export const TaskCard: React.FC<KanbanColumnProps> = ({ task, TaskType = 'task', onNavigate, handleStartAnalysis }) => {
|
||||
const { theme } = useTheme();
|
||||
const { setStartConfig } = useChatContext();
|
||||
const isDark = theme === 'dark';
|
||||
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
||||
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
|
||||
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleStartChange = (task: TaskListItem) => {
|
||||
// if (TaskType === 'task') {
|
||||
// handleStartAnalysis(task);
|
||||
// } else {
|
||||
// sceneDataChange(task);
|
||||
// }
|
||||
handleStartAnalysis(task);
|
||||
}
|
||||
|
||||
const sceneDataChange = (task: TaskListItem) => {
|
||||
const sceneItem = JSON.parse(task.scene);
|
||||
if (sceneItem.taskJson) {
|
||||
sceneItem.taskJson = JSON.parse(sceneItem.taskJson);
|
||||
setStartConfig({
|
||||
...sceneItem.taskJson,
|
||||
llm_preset_id: 'BAILING',
|
||||
consistency_mode: 'soft',
|
||||
phase_config: { diverge_rounds: 3 },
|
||||
task_context: task.overview
|
||||
});
|
||||
navigate('/qa-center/mope-chat')
|
||||
}
|
||||
console.log('sceneItem', sceneItem);
|
||||
}
|
||||
// 处理卡片点击
|
||||
const handleTaskClick = (task: TaskListItem) => {
|
||||
if (TaskType === 'sentiment') {
|
||||
console.log('点击了', task)
|
||||
sceneDataChange(task);
|
||||
// window.open(`http://ch1.b.uat.4.cn/taskcop/#/situation-overview/action/sentiment?taskId=${task.id}`)
|
||||
} else {
|
||||
if (task.column === 'completed') {
|
||||
// if (task.type === 'consultation') {
|
||||
// onNavigate('task-consult-result');
|
||||
// } else {
|
||||
// onNavigate('strategy-result');
|
||||
// }
|
||||
navigate('/strategy-result')
|
||||
} else if (task.column === 'running') {
|
||||
// navigate('/task-management/task-consult')
|
||||
sceneDataChange(task);
|
||||
// window.open(`http://ch1.b.uat.4.cn/mopeweb/#/mope?taskOverview=${task.overview}`)
|
||||
// if (task.type === 'scene') {
|
||||
// onNavigate('task-scene');
|
||||
// } else if (task.type === 'consultation') {
|
||||
// onNavigate('task-consult');
|
||||
// } else {
|
||||
// onNavigate('task-single');
|
||||
// }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取任务类型配置
|
||||
const getTypeConfig = (type: string) => {
|
||||
if (isDark) {
|
||||
switch (type) {
|
||||
case 'single':
|
||||
return { label: '单智能体', color: 'bg-cyan-500/20 text-cyan-400', icon: Bot };
|
||||
case 'scene':
|
||||
return { label: '场景执行', color: 'bg-purple-500/20 text-purple-400', icon: Workflow };
|
||||
case 'consultation':
|
||||
return { label: '多智能体会商', color: 'bg-orange-500/20 text-orange-400', icon: Users };
|
||||
case 'multi-agent':
|
||||
return { label: '多智能体协同', color: 'bg-green-500/20 text-green-400', icon: Layers };
|
||||
}
|
||||
} else {
|
||||
switch (type) {
|
||||
case 'single':
|
||||
return { label: '单智能体', color: 'bg-blue-100 text-blue-600', icon: Bot };
|
||||
case 'scene':
|
||||
return { label: '场景执行', color: 'bg-purple-100 text-purple-600', icon: Workflow };
|
||||
case 'consultation':
|
||||
return { label: '多智能体会商', color: 'bg-orange-100 text-orange-600', icon: Users };
|
||||
case 'multi-agent':
|
||||
return { label: '多智能体协同', color: 'bg-green-100 text-green-600', icon: Layers };
|
||||
}
|
||||
}
|
||||
};
|
||||
const typeConfig = getTypeConfig('single');
|
||||
// 获取任务等级
|
||||
const getLevel = (type: number) => {
|
||||
if (isDark) {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return { label: '一般', color: 'bg-cyan-500/20 text-cyan-400', icon: Bot };
|
||||
case 2:
|
||||
return { label: '紧急', color: 'bg-purple-500/20 text-purple-400', icon: Workflow };
|
||||
case 3:
|
||||
return { label: '加急', color: 'bg-orange-500/20 text-orange-400', icon: Users };
|
||||
}
|
||||
} else {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return { label: '一般', color: 'bg-blue-100 text-blue-600', icon: Bot };
|
||||
case 2:
|
||||
return { label: '紧急', color: 'bg-purple-100 text-purple-600', icon: Workflow };
|
||||
case 3:
|
||||
return { label: '加急', color: 'bg-orange-100 text-orange-600', icon: Users };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const levelConfig = getLevel(task.taskLevel);
|
||||
const LevelIcon = levelConfig?.icon || Bot;
|
||||
// 获取状态配置
|
||||
const getStatusConfig = (column: string) => {
|
||||
if (column === 'unanalyzed') {
|
||||
return { label: '待分析', color: isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-600' };
|
||||
} else if (column === 'running') {
|
||||
return { label: '运行中', color: isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-red-100 text-red-600' };
|
||||
} else {
|
||||
return { label: '已完成', color: isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-600' };
|
||||
}
|
||||
};
|
||||
const statusConfig = getStatusConfig(task.column);
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl p-4 hover:shadow-md transition-all duration-200 cursor-pointer group theme-card border ${isDark
|
||||
? 'bg-slate-800/40 backdrop-blur-xl border-blue-800/30 hover:border-blue-500/50'
|
||||
: 'bg-white border-red-100 hover:shadow-md shadow-red-100'
|
||||
}`}
|
||||
onClick={() => handleTaskClick(task)}
|
||||
>
|
||||
{/* 卡片头部:模式标签 + 状态标签 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs font-medium ${levelConfig.color}`}>
|
||||
{LevelIcon && <LevelIcon className="w-3 h-3" />}
|
||||
{levelConfig.label}
|
||||
</span>
|
||||
<span></span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${statusConfig.color}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 任务名称 */}
|
||||
<h3 className={`font-semibold text-sm mb-1 line-clamp-2 group-hover:text-red-500 transition-colors ${textPrimary}`}>
|
||||
{task.taskName}
|
||||
</h3>
|
||||
|
||||
{/* 任务编号 */}
|
||||
<p className={`text-xs mb-2 ${textMuted}`}>TASK-{task.id}</p>
|
||||
|
||||
{/* 任务摘要 */}
|
||||
<p className={`text-xs mb-3 line-clamp-2 ${textSecondary}`}>{task.overview}</p>
|
||||
|
||||
{/* 补充信息区 */}
|
||||
{/* <div className="space-y-1 mb-3">
|
||||
{task.type === 'single' && task.agent && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Bot className={`w-3 h-3 ${isDark ? 'text-cyan-400' : 'text-red-500'}`} />
|
||||
<span className={textSecondary}>执行智能体:</span>
|
||||
<span className={isDark ? 'text-cyan-400' : 'text-red-600'}>{task.agent}</span>
|
||||
</div>
|
||||
)}
|
||||
{task.type === 'scene' && task.scene && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Workflow className={`w-3 h-3 ${isDark ? 'text-purple-400' : 'text-red-500'}`} />
|
||||
<span className={textSecondary}>关联场景:</span>
|
||||
<span className={isDark ? 'text-purple-400' : 'text-red-600'}>{task.scene}</span>
|
||||
</div>
|
||||
)}
|
||||
{task.type === 'consultation' && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Users className={`w-3 h-3 ${isDark ? 'text-orange-400' : 'text-red-500'}`} />
|
||||
<span className={textSecondary}>参与智能体:</span>
|
||||
<span className={isDark ? 'text-orange-400' : 'text-red-600'}>{task.participants?.length || 0}个</span>
|
||||
</div>
|
||||
{task.currentRound && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Clock className={`w-3 h-3 ${textMuted}`} />
|
||||
<span className={textSecondary}>当前轮次:</span>
|
||||
<span className={textPrimary}>第{task.currentRound}轮</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{task.column === 'analyzing' && task.currentPhase && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Target className={`w-3 h-3 ${textMuted}`} />
|
||||
<span className={textSecondary}>当前阶段:</span>
|
||||
<span className={textPrimary}>{task.currentPhase}</span>
|
||||
</div>
|
||||
)}
|
||||
</div> */}
|
||||
|
||||
{/* 时间信息 */}
|
||||
<div className={`flex items-center justify-between text-xs pt-3 border-t ${isDark ? 'border-blue-800/30 text-slate-500' : 'border-red-100 text-gray-400'
|
||||
}`}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<User className="w-3 h-3" />
|
||||
<span>{task.taskDirection}</span>
|
||||
</div>
|
||||
<span>{format(new Date(task.updateTime), 'yyyy-MM-dd HH:mm:ss')}</span>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区 */}
|
||||
<div className={`flex items-center gap-2 mt-3 pt-3 border-t ${isDark ? 'border-blue-800/30' : 'border-red-100'
|
||||
}`}>
|
||||
{task.column === 'unanalyzed' && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleStartChange(task); }}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-white rounded-lg text-xs font-medium transition-colors ${isDark
|
||||
? 'bg-blue-600 hover:bg-blue-500'
|
||||
: 'bg-red-600 hover:bg-red-500 shadow-md'
|
||||
}`}
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
开始分析
|
||||
</button>
|
||||
{/* <button
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate('task-create'); }}
|
||||
className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700/50' : 'hover:bg-red-50'
|
||||
}`}
|
||||
title="编辑"
|
||||
>
|
||||
<Edit className={`w-4 h-4 ${isDark ? 'text-slate-400' : 'text-red-400'}`} />
|
||||
</button> */}
|
||||
</>
|
||||
)}
|
||||
{task.column === 'running' && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleStartChange(task); }}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-white rounded-lg text-xs font-medium transition-colors ${isDark
|
||||
? 'bg-blue-600 hover:bg-blue-500'
|
||||
: 'bg-red-600 hover:bg-red-500 shadow-md'
|
||||
}`}
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
开始分析
|
||||
</button>
|
||||
{/* <button
|
||||
onClick={(e) => { e.stopPropagation(); handleStartChange(task); }}
|
||||
className={`px-2 py-1.5 rounded-lg text-xs transition-colors ${isDark
|
||||
? 'hover:bg-slate-700/50 border border-blue-800/30 text-slate-300'
|
||||
: 'hover:bg-red-50 border border-red-200 text-red-600'
|
||||
}`}
|
||||
>
|
||||
查看进展
|
||||
</button> */}
|
||||
</>
|
||||
)}
|
||||
{task.column === 'completed' && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleStartChange(task); }}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-white rounded-lg text-xs font-medium transition-colors ${isDark
|
||||
? 'bg-green-600 hover:bg-green-500'
|
||||
: 'bg-green-600 hover:bg-green-500 shadow-md'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
开始分析
|
||||
</button>
|
||||
{/* <button className={`px-2 py-1.5 rounded-lg text-xs transition-colors ${isDark
|
||||
? 'hover:bg-slate-700/50 border border-blue-800/30 text-slate-300'
|
||||
: 'hover:bg-red-50 border border-red-200 text-red-600'
|
||||
}`}>
|
||||
<Download className="w-4 h-4" />
|
||||
</button> */}
|
||||
</>
|
||||
)}
|
||||
{/* <button className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700/50' : 'hover:bg-red-50'
|
||||
}`} title="更多">
|
||||
<MoreHorizontal className={`w-4 h-4 ${isDark ? 'text-slate-400' : 'text-red-400'}`} />
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
Users,
|
||||
Play,
|
||||
Pause,
|
||||
Download,
|
||||
CheckCircle,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
Send,
|
||||
Star,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
Settings,
|
||||
Lightbulb,
|
||||
Zap,
|
||||
Check,
|
||||
X,
|
||||
Minus,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { PageType } from '@/components/Sidebar';
|
||||
|
||||
interface TaskConsultProps {
|
||||
onNavigate: (page: PageType) => void;
|
||||
}
|
||||
|
||||
// 模拟参与智能体
|
||||
const participants = [
|
||||
{
|
||||
id: 'goal',
|
||||
name: 'GoalAgent',
|
||||
role: '目标分析专家',
|
||||
color: 'blue',
|
||||
status: 'idle' as const,
|
||||
lastOpinion: '建议聚焦提升品牌认知度这一核心目标',
|
||||
},
|
||||
{
|
||||
id: 'audience',
|
||||
name: 'AudienceAgent',
|
||||
role: '受众分析专家',
|
||||
color: 'cyan',
|
||||
status: 'speaking' as const,
|
||||
lastOpinion: '核心受众应为18-25岁基层青年群体',
|
||||
},
|
||||
{
|
||||
id: 'narrative',
|
||||
name: 'NarrativeAgent',
|
||||
role: '叙事策略专家',
|
||||
color: 'purple',
|
||||
status: 'idle' as const,
|
||||
lastOpinion: '应采用真实、有态度的青年故事叙事',
|
||||
},
|
||||
{
|
||||
id: 'risk',
|
||||
name: 'RiskAgent',
|
||||
role: '风险评估专家',
|
||||
color: 'red',
|
||||
status: 'idle' as const,
|
||||
lastOpinion: '需注意UGC质量不稳定和话题热度维持的风险',
|
||||
},
|
||||
];
|
||||
|
||||
// 模拟讨论记录
|
||||
const discussionRecords = [
|
||||
{
|
||||
id: 1,
|
||||
agentId: 'goal',
|
||||
agentName: 'GoalAgent',
|
||||
agentRole: '目标分析专家',
|
||||
color: 'blue',
|
||||
content: `从目标分析角度看,这次五四青年节宣传活动应聚焦以下核心目标:
|
||||
|
||||
**主目标**:提升品牌在Z世代用户中的认知度
|
||||
|
||||
**量化指标建议**:
|
||||
- 曝光量:500万+
|
||||
- 互动率:5%+
|
||||
- UGC数量:1000+
|
||||
|
||||
**优先级判断**:
|
||||
建议优先聚焦品牌认知度提升,短期内不追求直接转化。这样可以更好地与目标受众建立情感连接。`,
|
||||
time: '14:30',
|
||||
tags: ['核心目标', '品牌认知'],
|
||||
markedAs: null as string | null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
agentId: 'audience',
|
||||
agentName: 'AudienceAgent',
|
||||
agentRole: '受众分析专家',
|
||||
color: 'cyan',
|
||||
content: `基于受众分析,我建议:
|
||||
|
||||
**核心受众画像**:
|
||||
- 年龄:18-25岁
|
||||
- 身份:学生、基层青年工作者
|
||||
- 地域:一二线城市为主
|
||||
|
||||
**关键洞察**:
|
||||
这部分群体是社交媒体重度用户,注重"真实感"和"态度感",反感过度营销。需要用平等对话的方式,而非说教式传播。
|
||||
|
||||
**内容偏好**:
|
||||
真实故事 > 精致包装
|
||||
态度表达 > 信息堆砌
|
||||
参与互动 > 被动观看`,
|
||||
time: '14:32',
|
||||
tags: ['受众画像', '内容偏好'],
|
||||
markedAs: 'valuable' as string | null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
agentId: 'narrative',
|
||||
agentName: 'NarrativeAgent',
|
||||
agentRole: '叙事策略专家',
|
||||
color: 'purple',
|
||||
content: `从叙事策略角度,我建议采用以下框架:
|
||||
|
||||
**叙事主题**:真实、有态度的青年故事
|
||||
|
||||
**叙事结构**:
|
||||
1. 讲述真实的人物故事
|
||||
2. 引发情感共鸣
|
||||
3. 传递品牌价值观
|
||||
4. 激发用户参与
|
||||
|
||||
**关键要素**:
|
||||
- 真实感 > 精致感
|
||||
- 态度感 > 说教感
|
||||
- 参与感 > 观看感
|
||||
|
||||
**内容类型建议**:
|
||||
- 人物专访型:真实青年故事
|
||||
- 事件记录型:青年节活动现场
|
||||
- 观点表达型:青年态度宣言`,
|
||||
time: '14:35',
|
||||
tags: ['叙事框架', '内容类型'],
|
||||
markedAs: null as string | null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
agentId: 'risk',
|
||||
agentName: 'RiskAgent',
|
||||
agentRole: '风险评估专家',
|
||||
color: 'red',
|
||||
content: `我需要提醒以下风险:
|
||||
|
||||
**高风险项**:
|
||||
1. 内容质量不稳定
|
||||
- 风险:UGC质量参差不齐
|
||||
- 对策:建立内容审核机制
|
||||
|
||||
**中风险项**:
|
||||
2. 预算控制难度大
|
||||
- 风险:投放效果不可控
|
||||
- 对策:分阶段预算分配
|
||||
|
||||
3. 青年群体注意力分散
|
||||
- 风险:话题热度难维持
|
||||
- 对策:多波次内容规划
|
||||
|
||||
**建议**:重点关注内容质量风险,建立审核机制。`,
|
||||
time: '14:38',
|
||||
tags: ['风险识别', '应对建议'],
|
||||
markedAs: 'pending' as string | null,
|
||||
},
|
||||
];
|
||||
|
||||
const TaskConsult: React.FC<TaskConsultProps> = ({ onNavigate }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [currentRound, setCurrentRound] = useState(2);
|
||||
const [currentTopic, setCurrentTopic] = useState('宣传策略方向选择');
|
||||
const [humanInput, setHumanInput] = useState('');
|
||||
const [consensusPoints] = useState([
|
||||
'核心受众应聚焦18-25岁基层青年',
|
||||
'内容应以真实、有态度为核心',
|
||||
]);
|
||||
const [disputedPoints] = useState([
|
||||
'叙事角度:事件叙事 vs 人物叙事',
|
||||
'传播渠道:短视频平台 vs 社交媒体',
|
||||
]);
|
||||
const [humanNotes, setHumanNotes] = useState('');
|
||||
|
||||
const getColorClasses = (color: string) => {
|
||||
const colors: Record<string, { bg: string; border: string; text: string }> = {
|
||||
blue: { bg: isDark ? 'bg-blue-500/20' : 'bg-blue-50', border: isDark ? 'border-blue-500/50' : 'border-blue-200', text: isDark ? 'text-blue-400' : 'text-blue-600' },
|
||||
cyan: { bg: isDark ? 'bg-cyan-500/20' : 'bg-cyan-50', border: isDark ? 'border-cyan-500/50' : 'border-cyan-200', text: isDark ? 'text-cyan-400' : 'text-cyan-600' },
|
||||
green: { bg: isDark ? 'bg-green-500/20' : 'bg-green-50', border: isDark ? 'border-green-500/50' : 'border-green-200', text: isDark ? 'text-green-400' : 'text-green-600' },
|
||||
yellow: { bg: isDark ? 'bg-yellow-500/20' : 'bg-yellow-50', border: isDark ? 'border-yellow-500/50' : 'border-yellow-200', text: isDark ? 'text-yellow-400' : 'text-yellow-600' },
|
||||
purple: { bg: isDark ? 'bg-purple-500/20' : 'bg-purple-50', border: isDark ? 'border-purple-500/50' : 'border-purple-200', text: isDark ? 'text-purple-400' : 'text-purple-600' },
|
||||
red: { bg: isDark ? 'bg-red-500/20' : 'bg-red-50', border: isDark ? 'border-red-500/50' : 'border-red-200', text: isDark ? 'text-red-400' : 'text-red-600' },
|
||||
};
|
||||
return colors[color] || colors.blue;
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'speaking':
|
||||
return { label: '发言中', color: isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-700', dot: isDark ? 'bg-green-400' : 'bg-green-500' };
|
||||
case 'completed':
|
||||
return { label: '已完成', color: isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-gray-100 text-gray-600', dot: isDark ? 'bg-slate-400' : 'bg-gray-400' };
|
||||
default:
|
||||
return { label: '待发言', color: isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-gray-100 text-gray-600', dot: isDark ? 'bg-slate-400' : 'bg-gray-400' };
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextRound = () => {
|
||||
setCurrentRound(currentRound + 1);
|
||||
};
|
||||
|
||||
const handleFinishConsult = () => {
|
||||
onNavigate('task-consult-result');
|
||||
};
|
||||
|
||||
// 主题颜色类
|
||||
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
|
||||
const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-200';
|
||||
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
||||
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
|
||||
const textMuted = isDark ? 'text-slate-500' : 'text-gray-500';
|
||||
const btnSecondary = isDark ? 'bg-slate-700/50 hover:bg-slate-600/50 border-blue-800/30 text-slate-300' : 'bg-gray-100 hover:bg-gray-200 border-gray-300 text-gray-700';
|
||||
const btnPrimary = isDark ? 'bg-blue-600 hover:bg-blue-500' : 'bg-red-600 hover:bg-red-500';
|
||||
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-gray-50';
|
||||
const inputBorder = isDark ? 'border-blue-800/30' : 'border-gray-300';
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-140px)] flex flex-col space-y-4">
|
||||
{/* 顶部信息栏 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl border ${cardBorder} rounded-xl p-4 flex-shrink-0`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => onNavigate('task-list')}
|
||||
className={`flex items-center gap-2 px-3 py-2 ${btnSecondary} rounded-lg text-sm transition-colors`}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回
|
||||
</button>
|
||||
<div className={`h-8 w-px ${isDark ? 'bg-blue-800/30' : 'bg-red-200'}`} />
|
||||
<div>
|
||||
<h1 className={`${textPrimary} font-bold text-lg`}>五四青年节宣传策略会商</h1>
|
||||
<p className={`${textSecondary} text-sm`}>多智能体会商任务 · 第 {currentRound} 轮</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className={textSecondary}>
|
||||
模式:<span className={isDark ? 'text-purple-400' : 'text-purple-600'}>受控交叉讨论</span>
|
||||
</span>
|
||||
<span className={textSecondary}>
|
||||
参与:<span className={isDark ? 'text-cyan-400' : 'text-cyan-600'}>{participants.length} 个智能体</span>
|
||||
</span>
|
||||
<span className={textSecondary}>
|
||||
创建:<span className={textPrimary}>2026-03-19 14:30</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={`h-8 w-px ${isDark ? 'bg-blue-800/30' : 'bg-red-200'}`} />
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsPaused(!isPaused)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
isPaused
|
||||
? (isDark ? 'bg-green-500/20 hover:bg-green-500/30 border-green-500/50 text-green-400' : 'bg-green-100 hover:bg-green-200 border-green-300 text-green-700')
|
||||
: (isDark ? 'bg-yellow-500/20 hover:bg-yellow-500/30 border-yellow-500/50 text-yellow-400' : 'bg-yellow-100 hover:bg-yellow-200 border-yellow-300 text-yellow-700')
|
||||
}`}
|
||||
>
|
||||
{isPaused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||
{isPaused ? '继续会商' : '暂停会商'}
|
||||
</button>
|
||||
<button className={`flex items-center gap-2 px-3 py-1.5 ${btnSecondary} rounded-lg text-sm transition-colors`}>
|
||||
<Download className="w-4 h-4" />
|
||||
导出纪要
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFinishConsult}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-orange-600 hover:bg-orange-500 text-white rounded-lg text-sm transition-colors"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
结束并整合
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 三栏主体区域 */}
|
||||
<div className="flex-1 grid grid-cols-12 gap-4 min-h-0">
|
||||
{/* 左栏:参与智能体与议题导航 */}
|
||||
<div className="col-span-3 flex flex-col gap-4 min-h-0">
|
||||
{/* 参与智能体 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl border ${cardBorder} rounded-xl p-4 flex-1 overflow-hidden flex flex-col`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-3 flex items-center gap-2`}>
|
||||
<Users className={`w-4 h-4 ${isDark ? 'text-purple-400' : 'text-purple-600'}`} />
|
||||
参与智能体
|
||||
</h3>
|
||||
<div className="space-y-2 flex-1 overflow-y-auto">
|
||||
{participants.map((agent) => {
|
||||
const colors = getColorClasses(agent.color);
|
||||
const statusConfig = getStatusConfig(agent.status);
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className={`p-3 rounded-lg border transition-all ${
|
||||
agent.status === 'speaking'
|
||||
? `${colors.bg} ${colors.border}`
|
||||
: isDark ? 'bg-slate-900/50 border-blue-800/30' : 'bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-8 h-8 rounded-lg ${colors.bg} flex items-center justify-center`}>
|
||||
<Bot className={`w-4 h-4 ${colors.text}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${agent.status === 'speaking' ? colors.text : textPrimary}`}>
|
||||
{agent.name}
|
||||
</p>
|
||||
<p className={`${textMuted} text-xs`}>{agent.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${statusConfig.color}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
{agent.lastOpinion && (
|
||||
<p className={`${textSecondary} text-xs line-clamp-2 pl-10`}>{agent.lastOpinion}</p>
|
||||
)}
|
||||
<button
|
||||
className={`mt-2 w-full px-2 py-1.5 rounded text-xs transition-colors ${
|
||||
agent.status === 'speaking'
|
||||
? (isDark ? 'bg-slate-700/50 text-slate-400 cursor-not-allowed' : 'bg-gray-200 text-gray-500 cursor-not-allowed')
|
||||
: (isDark ? 'bg-purple-500/20 hover:bg-purple-500/30 text-purple-400' : 'bg-purple-100 hover:bg-purple-200 text-purple-700')
|
||||
}`}
|
||||
disabled={agent.status === 'speaking'}
|
||||
>
|
||||
点名发言
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 会商议题导航 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl border ${cardBorder} rounded-xl p-4`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-3 flex items-center gap-2`}>
|
||||
<MessageSquare className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
|
||||
会商议题
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className={`p-3 rounded-lg border ${isDark ? 'bg-green-500/20 border-green-500/50' : 'bg-green-50 border-green-300'}`}>
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-green-400' : 'text-green-700'}`}>当前议题</p>
|
||||
<p className={`${textPrimary} text-sm mt-1`}>{currentTopic}</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg border ${isDark ? 'bg-slate-900/50 border-blue-800/30' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<p className={`${textMuted} text-xs mb-1`}>待讨论</p>
|
||||
<p className={isDark ? 'text-slate-300 text-sm' : 'text-gray-700 text-sm'}>渠道策略选择</p>
|
||||
<p className={isDark ? 'text-slate-300 text-sm' : 'text-gray-700 text-sm'}>预算分配方案</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg border ${isDark ? 'bg-slate-900/50 border-blue-800/30' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<p className={`${textMuted} text-xs mb-1`}>已讨论</p>
|
||||
<p className={`${textMuted} text-sm line-through`}>受众定位</p>
|
||||
<p className={`${textMuted} text-sm line-through`}>目标设定</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className={`flex-1 px-2 py-1.5 ${btnSecondary} rounded-lg text-xs transition-colors`}>
|
||||
切换议题
|
||||
</button>
|
||||
<button className={`flex-1 px-2 py-1.5 ${btnSecondary} rounded-lg text-xs transition-colors`}>
|
||||
结束议题
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中栏:会商讨论区 */}
|
||||
<div className="col-span-6 flex flex-col gap-4 min-h-0">
|
||||
{/* 当前议题卡 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl border ${cardBorder} rounded-xl p-4 flex-shrink-0`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={`${textSecondary} text-xs`}>当前议题</p>
|
||||
<p className={`${textPrimary} font-medium mt-0.5`}>{currentTopic}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 rounded text-xs ${isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-700'}`}>
|
||||
受控交叉讨论模式
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded text-xs ${isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-blue-100 text-blue-700'}`}>
|
||||
第 {currentRound} 轮
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 讨论流 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl border ${cardBorder} rounded-xl overflow-hidden flex flex-col`}>
|
||||
<div className={`p-4 border-b ${isDark ? 'border-blue-800/30' : 'border-gray-200'} flex items-center justify-between`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm`}>讨论记录</h3>
|
||||
<button
|
||||
onClick={handleNextRound}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 ${btnPrimary} bg-opacity-20 hover:bg-opacity-30 ${isDark ? 'border border-blue-500/30 text-blue-400' : 'border border-red-400 text-red-600'} rounded-lg text-xs transition-colors`}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
下一轮
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{discussionRecords.map((record) => {
|
||||
const colors = getColorClasses(record.color);
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className={`p-4 rounded-xl border ${
|
||||
record.markedAs === 'valuable'
|
||||
? (isDark ? 'bg-green-500/10 border-green-500/30' : 'bg-green-50 border-green-300')
|
||||
: record.markedAs === 'pending'
|
||||
? (isDark ? 'bg-yellow-500/10 border-yellow-500/30' : 'bg-yellow-50 border-yellow-300')
|
||||
: (isDark ? 'bg-slate-900/50 border-blue-800/30' : 'bg-gray-50 border-gray-200')
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${colors.bg} flex items-center justify-center`}>
|
||||
<Bot className={`w-5 h-5 ${colors.text}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-sm font-medium ${colors.text}`}>{record.agentName}</p>
|
||||
{record.markedAs === 'valuable' && (
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs flex items-center gap-0.5 ${isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-700'}`}>
|
||||
<Star className="w-3 h-3" /> 有价值
|
||||
</span>
|
||||
)}
|
||||
{record.markedAs === 'pending' && (
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs flex items-center gap-0.5 ${isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-700'}`}>
|
||||
<AlertCircle className="w-3 h-3" /> 待验证
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={`${textMuted} text-xs`}>{record.agentRole} · {record.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className={`p-1.5 rounded transition-colors ${isDark ? 'hover:bg-green-500/20' : 'hover:bg-green-100'}`}
|
||||
title="标记为有价值"
|
||||
>
|
||||
<Star className={`w-4 h-4 ${isDark ? 'text-slate-400 hover:text-green-400' : 'text-gray-500 hover:text-green-600'}`} />
|
||||
</button>
|
||||
<button
|
||||
className={`p-1.5 rounded transition-colors ${isDark ? 'hover:bg-yellow-500/20' : 'hover:bg-yellow-100'}`}
|
||||
title="标记为待验证"
|
||||
>
|
||||
<AlertCircle className={`w-4 h-4 ${isDark ? 'text-slate-400 hover:text-yellow-400' : 'text-gray-500 hover:text-yellow-600'}`} />
|
||||
</button>
|
||||
<button
|
||||
className={`p-1.5 rounded transition-colors ${isDark ? 'hover:bg-red-500/20' : 'hover:bg-red-100'}`}
|
||||
title="暂不采纳"
|
||||
>
|
||||
<X className={`w-4 h-4 ${isDark ? 'text-slate-400 hover:text-red-400' : 'text-gray-500 hover:text-red-600'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-13">
|
||||
<div className={`${isDark ? 'text-slate-300' : 'text-gray-700'} text-sm whitespace-pre-wrap leading-relaxed`}>
|
||||
{record.content}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
{record.tags.map((tag) => (
|
||||
<span key={tag} className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-blue-100 text-blue-700'}`}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button className={`flex items-center gap-1 px-2 py-1 ${btnSecondary} rounded text-xs transition-colors`}>
|
||||
<FileText className="w-3 h-3" />
|
||||
引用到整合区
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 人工发言区 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl border ${cardBorder} rounded-xl p-4 flex-shrink-0`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${isDark ? 'bg-blue-500/20' : 'bg-blue-100'}`}>
|
||||
<Users className={`w-5 h-5 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={humanInput}
|
||||
onChange={(e) => setHumanInput(e.target.value)}
|
||||
placeholder="输入您的评论、问题或指令... (可点名某个智能体:@GoalAgent)"
|
||||
rows={2}
|
||||
className={`w-full px-4 py-2.5 ${inputBg} border ${inputBorder} rounded-lg ${textPrimary} text-sm ${isDark ? 'placeholder-slate-500' : 'placeholder-gray-400'} focus:outline-none ${isDark ? 'focus:border-blue-500/50' : 'focus:border-red-400'} transition-colors resize-none`}
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button className={`px-2 py-1 ${btnSecondary} rounded text-xs transition-colors`}>
|
||||
点名提问
|
||||
</button>
|
||||
<button className={`px-2 py-1 ${btnSecondary} rounded text-xs transition-colors`}>
|
||||
发起对比问题
|
||||
</button>
|
||||
<button className={`px-2 py-1 ${btnSecondary} rounded text-xs transition-colors`}>
|
||||
要求补充说明
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
disabled={!humanInput.trim()}
|
||||
className={`flex items-center gap-2 px-4 py-2 ${btnPrimary} disabled:${isDark ? 'bg-slate-700 text-slate-500' : 'bg-gray-300 text-gray-500'} text-white rounded-lg text-sm transition-colors`}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右栏:观点整合区 */}
|
||||
<div className="col-span-3 flex flex-col gap-4 min-h-0">
|
||||
{/* 各智能体核心观点 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl border ${cardBorder} rounded-xl p-4 flex-1 overflow-hidden flex flex-col`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-3 flex items-center gap-2`}>
|
||||
<Bot className={`w-4 h-4 ${isDark ? 'text-cyan-400' : 'text-cyan-600'}`} />
|
||||
各方观点
|
||||
</h3>
|
||||
<div className="space-y-3 flex-1 overflow-y-auto">
|
||||
{participants.map((agent) => {
|
||||
const colors = getColorClasses(agent.color);
|
||||
return (
|
||||
<div key={agent.id} className={`p-3 rounded-lg ${isDark ? 'bg-slate-900/50 border-blue-800/30' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Bot className={`w-4 h-4 ${colors.text}`} />
|
||||
<span className={`text-sm font-medium ${colors.text}`}>{agent.name}</span>
|
||||
</div>
|
||||
<p className={`${textSecondary} text-xs line-clamp-3`}>{agent.lastOpinion}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 共识观点 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl border ${isDark ? 'border-green-800/30' : 'border-green-300'} rounded-xl p-4`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-3 flex items-center gap-2`}>
|
||||
<Check className={`w-4 h-4 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
|
||||
共识观点
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ml-auto ${isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-700'}`}>
|
||||
{consensusPoints.length} 条
|
||||
</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{consensusPoints.map((point, index) => (
|
||||
<div key={index} className={`p-2 rounded-lg ${isDark ? 'bg-green-500/10 border border-green-500/30' : 'bg-green-50 border border-green-200'}`}>
|
||||
<p className={isDark ? 'text-green-300 text-sm' : 'text-green-800 text-sm'}>{point}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分歧观点 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl border ${isDark ? 'border-yellow-800/30' : 'border-yellow-300'} rounded-xl p-4`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-3 flex items-center gap-2`}>
|
||||
<Minus className={`w-4 h-4 ${isDark ? 'text-yellow-400' : 'text-yellow-600'}`} />
|
||||
分歧观点
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ml-auto ${isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-700'}`}>
|
||||
{disputedPoints.length} 条
|
||||
</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{disputedPoints.map((point, index) => (
|
||||
<div key={index} className={`p-2 rounded-lg ${isDark ? 'bg-yellow-500/10 border border-yellow-500/30' : 'bg-yellow-50 border border-yellow-200'}`}>
|
||||
<p className={isDark ? 'text-yellow-300 text-sm' : 'text-yellow-800 text-sm'}>{point}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 人工整合区 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl border ${isDark ? 'border-purple-800/30' : 'border-purple-300'} rounded-xl p-4 flex-1 overflow-hidden flex flex-col`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-3 flex items-center gap-2`}>
|
||||
<Zap className={`w-4 h-4 ${isDark ? 'text-purple-400' : 'text-purple-600'}`} />
|
||||
人工整合
|
||||
</h3>
|
||||
<textarea
|
||||
value={humanNotes}
|
||||
onChange={(e) => setHumanNotes(e.target.value)}
|
||||
placeholder="记录您的判断、暂定方向、待确认事项..."
|
||||
className={`flex-1 min-h-[120px] px-3 py-2 ${inputBg} border ${inputBorder} rounded-lg ${textPrimary} text-sm ${isDark ? 'placeholder-slate-500' : 'placeholder-gray-400'} focus:outline-none ${isDark ? 'focus:border-blue-500/50' : 'focus:border-red-400'} transition-colors resize-none`}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 mt-3">
|
||||
<button className={`w-full flex items-center justify-center gap-2 px-3 py-2 ${isDark ? 'bg-purple-600 hover:bg-purple-500' : 'bg-purple-600 hover:bg-purple-500'} text-white rounded-lg text-xs font-medium transition-colors`}>
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
生成会商结论
|
||||
</button>
|
||||
<button className={`w-full flex items-center justify-center gap-2 px-3 py-2 ${btnSecondary} rounded-lg text-xs transition-colors`}>
|
||||
<FileText className="w-4 h-4" />
|
||||
转为任务摘要
|
||||
</button>
|
||||
<button className={`w-full flex items-center justify-center gap-2 px-3 py-2 ${btnSecondary} rounded-lg text-xs transition-colors`}>
|
||||
<Settings className="w-4 h-4" />
|
||||
转为场景任务输入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskConsult;
|
||||
@@ -0,0 +1,572 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
Users,
|
||||
Settings,
|
||||
FileText,
|
||||
Upload,
|
||||
Play,
|
||||
Save,
|
||||
Search,
|
||||
CheckCircle,
|
||||
MessageSquare,
|
||||
Lightbulb,
|
||||
Target,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { PageType } from '@/components/Sidebar';
|
||||
|
||||
interface TaskConsultCreateProps {
|
||||
onNavigate: (page: PageType) => void;
|
||||
}
|
||||
|
||||
// 可选智能体列表
|
||||
const availableAgents = [
|
||||
{
|
||||
id: 'goal',
|
||||
name: 'GoalAgent',
|
||||
role: '目标分析专家',
|
||||
description: '分析业务目标,提取核心KPI和成功标准',
|
||||
color: 'blue',
|
||||
tags: ['目标分解', 'KPI提取'],
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: 'audience',
|
||||
name: 'AudienceAgent',
|
||||
role: '受众分析专家',
|
||||
description: '定义目标受众画像,分析用户特征和需求',
|
||||
color: 'cyan',
|
||||
tags: ['用户画像', '行为分析'],
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: 'behavior',
|
||||
name: 'BehaviorAgent',
|
||||
role: '行为设计专家',
|
||||
description: '设计用户行为路径和转化漏斗',
|
||||
color: 'green',
|
||||
tags: ['行为设计', '转化路径'],
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: 'driver',
|
||||
name: 'DriverAgent',
|
||||
role: '驱动因素专家',
|
||||
description: '分析影响用户决策的核心驱动因素',
|
||||
color: 'yellow',
|
||||
tags: ['驱动分析', '动机挖掘'],
|
||||
recommended: false,
|
||||
},
|
||||
{
|
||||
id: 'narrative',
|
||||
name: 'NarrativeAgent',
|
||||
role: '叙事策略专家',
|
||||
description: '设计品牌叙事框架和内容策略',
|
||||
color: 'purple',
|
||||
tags: ['故事设计', '内容策略'],
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: 'risk',
|
||||
name: 'RiskAgent',
|
||||
role: '风险评估专家',
|
||||
description: '识别项目风险,制定应对策略',
|
||||
color: 'red',
|
||||
tags: ['风险识别', '应对策略'],
|
||||
recommended: true,
|
||||
},
|
||||
];
|
||||
|
||||
const TaskConsultCreate: React.FC<TaskConsultCreateProps> = ({ onNavigate }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// 主题颜色类
|
||||
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
|
||||
const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-200';
|
||||
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
||||
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
|
||||
const textMuted = isDark ? 'text-slate-500' : 'text-gray-500';
|
||||
const btnSecondary = isDark ? 'bg-slate-700/50 hover:bg-slate-600/50 border-blue-800/30 text-slate-300' : 'bg-gray-100 hover:bg-gray-200 border-gray-300 text-gray-700';
|
||||
const btnPrimary = isDark ? 'bg-blue-600 hover:bg-blue-500' : 'bg-red-600 hover:bg-red-500';
|
||||
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-gray-50';
|
||||
const inputBorder = isDark ? 'border-blue-800/30' : 'border-gray-300';
|
||||
|
||||
const [taskName, setTaskName] = useState('');
|
||||
const [taskTheme, setTaskTheme] = useState('');
|
||||
const [taskDescription, setTaskDescription] = useState('');
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[]>(['goal', 'audience', 'narrative', 'risk']);
|
||||
const [consultMode, setConsultMode] = useState<'parallel' | 'cross' | 'human'>('parallel');
|
||||
const [maxRounds, setMaxRounds] = useState(3);
|
||||
const [allowAgentQuestion, setAllowAgentQuestion] = useState(true);
|
||||
const [allowHumanIntervene, setAllowHumanIntervene] = useState(true);
|
||||
const [autoSummary, setAutoSummary] = useState(true);
|
||||
const [allowConvertToScene, setAllowConvertToScene] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const toggleAgent = (agentId: string) => {
|
||||
if (selectedAgents.includes(agentId)) {
|
||||
if (selectedAgents.length > 2) {
|
||||
setSelectedAgents(selectedAgents.filter((id) => id !== agentId));
|
||||
}
|
||||
} else {
|
||||
if (selectedAgents.length < 8) {
|
||||
setSelectedAgents([...selectedAgents, agentId]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectRecommended = () => {
|
||||
const recommendedIds = availableAgents.filter((a) => a.recommended).map((a) => a.id);
|
||||
setSelectedAgents(recommendedIds);
|
||||
};
|
||||
|
||||
const handleStartConsult = () => {
|
||||
if (taskName && selectedAgents.length >= 2) {
|
||||
onNavigate('task-consult');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAgents = availableAgents.filter(
|
||||
(agent) =>
|
||||
agent.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
agent.role.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const getColorClasses = (color: string) => {
|
||||
const colors: Record<string, { bg: string; border: string; text: string; ring: string }> = {
|
||||
blue: { bg: 'bg-blue-500/20', border: 'border-blue-500/50', text: 'text-blue-400', ring: 'ring-blue-500/50' },
|
||||
cyan: { bg: 'bg-cyan-500/20', border: 'border-cyan-500/50', text: 'text-cyan-400', ring: 'ring-cyan-500/50' },
|
||||
green: { bg: 'bg-green-500/20', border: 'border-green-500/50', text: 'text-green-400', ring: 'ring-green-500/50' },
|
||||
yellow: { bg: 'bg-yellow-500/20', border: 'border-yellow-500/50', text: 'text-yellow-400', ring: 'ring-yellow-500/50' },
|
||||
purple: { bg: 'bg-purple-500/20', border: 'border-purple-500/50', text: 'text-purple-400', ring: 'ring-purple-500/50' },
|
||||
red: { bg: 'bg-red-500/20', border: 'border-red-500/50', text: 'text-red-400', ring: 'ring-red-500/50' },
|
||||
};
|
||||
return colors[color] || colors.blue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 顶部操作栏 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl ${cardBorder} rounded-xl p-4`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => onNavigate('task-list')}
|
||||
className={`flex items-center gap-2 px-3 py-2 ${btnSecondary} rounded-lg text-sm transition-colors`}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回
|
||||
</button>
|
||||
<div className={`h-8 w-px ${isDark ? 'bg-blue-800/30' : 'bg-gray-200'}`} />
|
||||
<div>
|
||||
<h1 className={`${textPrimary} font-bold text-lg`}>新建多智能体会商任务</h1>
|
||||
<p className={`${textSecondary} text-sm`}>多视角分析 + 人机共判 + 会商整合</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-3 py-1.5 ${isDark ? 'bg-orange-500/20 text-orange-400' : 'bg-orange-100 text-orange-600'} rounded-full text-sm font-medium flex items-center gap-1.5`}>
|
||||
<Users className="w-4 h-4" />
|
||||
会商研判模式
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{/* 左侧:基础信息和智能体选择 */}
|
||||
<div className="col-span-8 space-y-6">
|
||||
{/* 基础信息区 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl ${cardBorder} rounded-xl p-5`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-4 flex items-center gap-2`}>
|
||||
<FileText className="w-4 h-4 text-blue-400" />
|
||||
基础信息
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={`block ${textSecondary} text-sm mb-2`}>任务名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={taskName}
|
||||
onChange={(e) => setTaskName(e.target.value)}
|
||||
placeholder="例如:五四青年节宣传策略会商"
|
||||
className={`w-full px-4 py-2.5 ${inputBg} ${inputBorder} rounded-lg ${textPrimary} text-sm ${textMuted} focus:outline-none focus:border-blue-500/50 transition-colors`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={`block ${textSecondary} text-sm mb-2`}>会商主题</label>
|
||||
<input
|
||||
type="text"
|
||||
value={taskTheme}
|
||||
onChange={(e) => setTaskTheme(e.target.value)}
|
||||
placeholder="例如:宣传策略前期研判"
|
||||
className={`w-full px-4 py-2.5 ${inputBg} ${inputBorder} rounded-lg ${textPrimary} text-sm ${textMuted} focus:outline-none focus:border-blue-500/50 transition-colors`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={`block ${textSecondary} text-sm mb-2`}>任务描述</label>
|
||||
<textarea
|
||||
value={taskDescription}
|
||||
onChange={(e) => setTaskDescription(e.target.value)}
|
||||
placeholder="详细描述本次会商要解决的问题和目标..."
|
||||
rows={3}
|
||||
className={`w-full px-4 py-2.5 ${inputBg} ${inputBorder} rounded-lg ${textPrimary} text-sm ${textMuted} focus:outline-none focus:border-blue-500/50 transition-colors resize-none`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={`block ${textSecondary} text-sm mb-2`}>背景材料上传</label>
|
||||
<div className={`border-2 border-dashed ${inputBorder} rounded-lg p-6 text-center hover:border-blue-500/50 transition-colors cursor-pointer`}>
|
||||
<Upload className={`w-8 h-8 ${textMuted} mx-auto mb-2`} />
|
||||
<p className={`${textSecondary} text-sm`}>点击或拖拽上传文件</p>
|
||||
<p className={`${textMuted} text-xs mt-1`}>支持 PDF、Word、图片等格式</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 智能体选择区 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl ${cardBorder} rounded-xl p-5`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className={`${textPrimary} font-semibold text-sm flex items-center gap-2`}>
|
||||
<Bot className="w-4 h-4 text-purple-400" />
|
||||
选择参与智能体
|
||||
<span className={`${textMuted} font-normal text-xs ml-2`}>
|
||||
(已选 {selectedAgents.length} 个,建议 3-6 个)
|
||||
</span>
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={selectRecommended}
|
||||
className={`px-3 py-1.5 bg-purple-500/20 hover:bg-purple-500/30 border border-purple-500/30 text-purple-400 rounded-lg text-xs transition-colors`}
|
||||
>
|
||||
一键选择推荐组合
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="relative mb-4">
|
||||
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${textMuted}`} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索智能体..."
|
||||
className={`w-full pl-10 pr-4 py-2 ${inputBg} ${inputBorder} rounded-lg ${textPrimary} text-sm ${textMuted} focus:outline-none focus:border-blue-500/50 transition-colors`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 智能体卡片列表 */}
|
||||
<div className="grid grid-cols-2 gap-3 max-h-[320px] overflow-y-auto pr-2">
|
||||
{filteredAgents.map((agent) => {
|
||||
const isSelected = selectedAgents.includes(agent.id);
|
||||
const colors = getColorClasses(agent.color);
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
onClick={() => toggleAgent(agent.id)}
|
||||
className={`p-4 rounded-xl border transition-all text-left ${
|
||||
isSelected
|
||||
? `${colors.bg} ${colors.border} ring-1 ${colors.ring}`
|
||||
: `${inputBg} ${inputBorder} hover:border-blue-500/30`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${colors.bg} flex items-center justify-center flex-shrink-0`}>
|
||||
<Bot className={`w-5 h-5 ${colors.text}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-sm font-medium ${isSelected ? colors.text : textPrimary}`}>
|
||||
{agent.name}
|
||||
</p>
|
||||
{agent.recommended && (
|
||||
<span className={`px-1.5 py-0.5 ${isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-600'} rounded text-xs`}>
|
||||
推荐
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={`${textSecondary} text-xs mt-0.5`}>{agent.role}</p>
|
||||
<p className={`${textMuted} text-xs mt-1 line-clamp-2`}>{agent.description}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{agent.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`px-1.5 py-0.5 ${isSelected ? colors.bg : isDark ? 'bg-slate-700/50' : 'bg-gray-100'} ${
|
||||
isSelected ? colors.text : textSecondary
|
||||
} rounded text-xs`}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`w-5 h-5 rounded border flex items-center justify-center flex-shrink-0 ${
|
||||
isSelected ? `${colors.bg} ${colors.border}` : isDark ? 'border-slate-600' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{isSelected && <CheckCircle className={`w-3.5 h-3.5 ${colors.text}`} />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:会商规则设置和预览 */}
|
||||
<div className="col-span-4 space-y-6">
|
||||
{/* 会商规则设置 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl ${cardBorder} rounded-xl p-5`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-4 flex items-center gap-2`}>
|
||||
<Settings className="w-4 h-4 text-green-400" />
|
||||
会商规则设置
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* 会商模式 */}
|
||||
<div>
|
||||
<label className={`block ${textSecondary} text-sm mb-2`}>会商模式</label>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{
|
||||
id: 'parallel',
|
||||
label: '并行独立分析',
|
||||
desc: '首轮各智能体独立发言',
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
id: 'cross',
|
||||
label: '受控交叉讨论',
|
||||
desc: '主持人指定智能体互相回应',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
id: 'human',
|
||||
label: '人机共判模式',
|
||||
desc: '人类主导,全程可控',
|
||||
icon: Sparkles,
|
||||
},
|
||||
].map((mode) => (
|
||||
<button
|
||||
key={mode.id}
|
||||
onClick={() => setConsultMode(mode.id as typeof consultMode)}
|
||||
className={`w-full p-3 rounded-lg border transition-all text-left ${
|
||||
consultMode === mode.id
|
||||
? 'bg-green-500/20 border-green-500/50'
|
||||
: `${inputBg} ${inputBorder} hover:border-blue-500/30`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<mode.icon
|
||||
className={`w-5 h-5 ${
|
||||
consultMode === mode.id ? 'text-green-400' : textSecondary
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
consultMode === mode.id ? 'text-green-400' : textPrimary
|
||||
}`}
|
||||
>
|
||||
{mode.label}
|
||||
</p>
|
||||
<p className={`${textMuted} text-xs`}>{mode.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 最大轮数 */}
|
||||
<div>
|
||||
<label className={`block ${textSecondary} text-sm mb-2`}>
|
||||
最大讨论轮数 <span className={textMuted}>(1-10)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxRounds}
|
||||
onChange={(e) => setMaxRounds(Math.min(10, Math.max(1, parseInt(e.target.value) || 1)))}
|
||||
className={`w-full px-4 py-2.5 ${inputBg} ${inputBorder} rounded-lg ${textPrimary} text-sm focus:outline-none focus:border-blue-500/50 transition-colors`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 开关设置 */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div>
|
||||
<p className={`${textPrimary} text-sm`}>允许智能体互相提问</p>
|
||||
<p className={`${textMuted} text-xs`}>智能体可主动向其他智能体发起讨论</p>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setAllowAgentQuestion(!allowAgentQuestion)}
|
||||
className={`w-11 h-6 rounded-full transition-colors relative ${
|
||||
allowAgentQuestion ? 'bg-green-500' : isDark ? 'bg-slate-600' : 'bg-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full absolute top-0.5 transition-transform ${
|
||||
allowAgentQuestion ? 'translate-x-5.5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div>
|
||||
<p className={`${textPrimary} text-sm`}>允许人工插话</p>
|
||||
<p className={`${textMuted} text-xs`}>用户可随时打断并发表意见</p>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setAllowHumanIntervene(!allowHumanIntervene)}
|
||||
className={`w-11 h-6 rounded-full transition-colors relative ${
|
||||
allowHumanIntervene ? 'bg-green-500' : isDark ? 'bg-slate-600' : 'bg-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full absolute top-0.5 transition-transform ${
|
||||
allowHumanIntervene ? 'translate-x-5.5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div>
|
||||
<p className={`${textPrimary} text-sm`}>自动生成摘要</p>
|
||||
<p className={`${textMuted} text-xs`}>每轮结束后自动提炼观点</p>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setAutoSummary(!autoSummary)}
|
||||
className={`w-11 h-6 rounded-full transition-colors relative ${
|
||||
autoSummary ? 'bg-green-500' : isDark ? 'bg-slate-600' : 'bg-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full absolute top-0.5 transition-transform ${
|
||||
autoSummary ? 'translate-x-5.5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div>
|
||||
<p className={`${textPrimary} text-sm`}>支持转为场景任务</p>
|
||||
<p className={`${textMuted} text-xs`}>会商结束后可转入场景执行</p>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setAllowConvertToScene(!allowConvertToScene)}
|
||||
className={`w-11 h-6 rounded-full transition-colors relative ${
|
||||
allowConvertToScene ? 'bg-green-500' : isDark ? 'bg-slate-600' : 'bg-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full absolute top-0.5 transition-transform ${
|
||||
allowConvertToScene ? 'translate-x-5.5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 会商流程预览 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl ${cardBorder} rounded-xl p-5`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-4 flex items-center gap-2`}>
|
||||
<Target className="w-4 h-4 text-orange-400" />
|
||||
会商流程预览
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ step: 1, label: '首轮独立发言', desc: '各智能体各抒己见' },
|
||||
{ step: 2, label: '交叉讨论', desc: '按模式互动回应' },
|
||||
{ step: 3, label: '观点提炼', desc: '系统自动汇总' },
|
||||
{ step: 4, label: '人工整合', desc: '人类主导判断' },
|
||||
{ step: 5, label: '输出结论', desc: '生成会商纪要' },
|
||||
].map((item, index) => (
|
||||
<div key={item.step} className="flex items-center gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-6 h-6 rounded-full ${isDark ? 'bg-orange-500/20 border-orange-500/50' : 'bg-orange-100 border-orange-200'} flex items-center justify-center`}>
|
||||
<span className={`${isDark ? 'text-orange-400' : 'text-orange-600'} text-xs font-medium`}>{item.step}</span>
|
||||
</div>
|
||||
{index < 4 && <div className={`w-px h-4 ${isDark ? 'bg-blue-800/30' : 'bg-gray-200'}`} />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className={`${textPrimary} text-sm`}>{item.label}</p>
|
||||
<p className={`${textMuted} text-xs`}>{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选中智能体预览 */}
|
||||
{selectedAgents.length > 0 && (
|
||||
<div className={`${cardBg} backdrop-blur-xl ${isDark ? 'border-purple-800/30' : 'border-purple-200'} rounded-xl p-5`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-3`}>
|
||||
已选智能体 ({selectedAgents.length})
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedAgents.map((agentId) => {
|
||||
const agent = availableAgents.find((a) => a.id === agentId);
|
||||
if (!agent) return null;
|
||||
const colors = getColorClasses(agent.color);
|
||||
return (
|
||||
<span
|
||||
key={agentId}
|
||||
className={`px-2 py-1 ${colors.bg} ${colors.text} rounded-lg text-xs flex items-center gap-1`}
|
||||
>
|
||||
<Bot className="w-3 h-3" />
|
||||
{agent.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleStartConsult}
|
||||
disabled={!taskName || selectedAgents.length < 2}
|
||||
className={`w-full flex items-center justify-center gap-2 px-4 py-3 ${btnPrimary} disabled:${isDark ? 'bg-slate-700 text-slate-500' : 'bg-gray-300 text-gray-500'} text-white rounded-xl text-sm font-medium transition-colors`}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
开始会商
|
||||
</button>
|
||||
<button className={`w-full flex items-center justify-center gap-2 px-4 py-3 ${btnSecondary} rounded-xl text-sm transition-colors`}>
|
||||
<Save className="w-4 h-4" />
|
||||
保存草稿
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className={`${isDark ? 'bg-blue-500/10 border-blue-500/30' : 'bg-blue-50 border-blue-200'} rounded-lg p-3`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb className={`w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5`} />
|
||||
<div className={`text-xs ${textSecondary}`}>
|
||||
<p className={`${isDark ? 'text-blue-400' : 'text-blue-600'} font-medium mb-1`}>小提示</p>
|
||||
<p>会商任务不追求自动闭环,而是帮助您更好地判断。建议在任务前期研判、多方案比较、争议问题讨论等场景使用。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskConsultCreate;
|
||||
@@ -0,0 +1,431 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
CheckCircle,
|
||||
Download,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
Zap,
|
||||
AlertCircle,
|
||||
Check,
|
||||
Target,
|
||||
X,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { PageType } from '@/components/Sidebar';
|
||||
|
||||
interface TaskConsultResultProps {
|
||||
onNavigate: (page: PageType) => void;
|
||||
}
|
||||
|
||||
const TaskConsultResult: React.FC<TaskConsultResultProps> = ({ onNavigate }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// 主题颜色类
|
||||
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
|
||||
const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-200';
|
||||
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
||||
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
|
||||
const textMuted = isDark ? 'text-slate-500' : 'text-gray-500';
|
||||
const btnSecondary = isDark ? 'bg-slate-700/50 hover:bg-slate-600/50 border-blue-800/30 text-slate-300' : 'bg-gray-100 hover:bg-gray-200 border-gray-300 text-gray-700';
|
||||
const btnPrimary = isDark ? 'bg-blue-600 hover:bg-blue-500' : 'bg-red-600 hover:bg-red-500';
|
||||
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-gray-50';
|
||||
const inputBorder = isDark ? 'border-blue-800/30' : 'border-gray-300';
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'conclusion' | 'opinions' | 'disputes'>('conclusion');
|
||||
|
||||
// 会商结论数据
|
||||
const conclusion = {
|
||||
mainConclusion: '五四青年节宣传活动应聚焦"真实、有态度的青年故事"这一叙事方向,以18-25岁基层青年为核心受众,优先在短视频平台建立品牌认知。',
|
||||
recommendedDirection: [
|
||||
'以真实人物故事为核心叙事内容',
|
||||
'采用平等对话而非说教式的传播语气',
|
||||
'优先布局抖音、小红书等短视频平台',
|
||||
'建立UGC激励机制促进用户参与',
|
||||
],
|
||||
notRecommendedDirection: [
|
||||
'过度包装的精致化内容',
|
||||
'单向输出的宣传式传播',
|
||||
'纯文字为主的长图文形式',
|
||||
],
|
||||
pendingIssues: [
|
||||
'叙事角度:事件叙事 vs 人物叙事(待进一步验证)',
|
||||
'渠道策略:短视频为主 vs 社交媒体为主',
|
||||
'预算在内容制作和达人投放的分配比例',
|
||||
],
|
||||
nextSteps: [
|
||||
'建议由 NarrativeAgent 细化叙事框架',
|
||||
'建议由 AudienceAgent 补充平台选择建议',
|
||||
'可考虑转入"宣传策略生成"场景任务进行完整方案输出',
|
||||
],
|
||||
};
|
||||
|
||||
// 观点汇总数据
|
||||
const agentOpinions = [
|
||||
{
|
||||
agentId: 'goal',
|
||||
agentName: 'GoalAgent',
|
||||
agentRole: '目标分析专家',
|
||||
color: 'blue',
|
||||
coreOpinion: '建议聚焦提升品牌认知度这一核心目标,短期内不追求直接转化',
|
||||
adopted: true,
|
||||
notes: '与整体策略方向一致',
|
||||
},
|
||||
{
|
||||
agentId: 'audience',
|
||||
agentName: 'AudienceAgent',
|
||||
agentRole: '受众分析专家',
|
||||
color: 'cyan',
|
||||
coreOpinion: '核心受众应为18-25岁基层青年群体,注重"真实感"和"态度感"',
|
||||
adopted: true,
|
||||
notes: '已采纳为策略基础',
|
||||
},
|
||||
{
|
||||
agentId: 'narrative',
|
||||
agentName: 'NarrativeAgent',
|
||||
agentRole: '叙事策略专家',
|
||||
color: 'purple',
|
||||
coreOpinion: '应采用"真实、有态度的青年故事"叙事框架',
|
||||
adopted: true,
|
||||
notes: '核心建议,已采纳',
|
||||
},
|
||||
{
|
||||
agentId: 'risk',
|
||||
agentName: 'RiskAgent',
|
||||
agentRole: '风险评估专家',
|
||||
color: 'red',
|
||||
coreOpinion: '需注意UGC质量不稳定和话题热度维持的风险',
|
||||
adopted: true,
|
||||
notes: '需在执行阶段重点关注',
|
||||
},
|
||||
];
|
||||
|
||||
// 分歧与待确认数据
|
||||
const disputes = [
|
||||
{
|
||||
id: 1,
|
||||
issue: '叙事角度选择',
|
||||
description: '人物叙事 vs 事件叙事',
|
||||
viewpoints: [
|
||||
{ agent: 'NarrativeAgent', opinion: '人物叙事更有情感穿透力', position: '支持人物叙事' },
|
||||
{ agent: 'GoalAgent', opinion: '事件叙事更聚焦目标达成', position: '支持事件叙事' },
|
||||
],
|
||||
status: '待确认',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
issue: '渠道策略选择',
|
||||
description: '短视频平台 vs 社交媒体',
|
||||
viewpoints: [
|
||||
{ agent: 'AudienceAgent', opinion: '核心受众在短视频平台活跃度更高', position: '支持短视频平台' },
|
||||
{ agent: 'GoalAgent', opinion: '品牌传播需要社交媒体的裂变效应', position: '支持社交媒体' },
|
||||
],
|
||||
status: '待确认',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
issue: '风险应对方案',
|
||||
description: '内容质量控制机制',
|
||||
viewpoints: [
|
||||
{ agent: 'RiskAgent', opinion: '需要建立严格的内容审核机制', position: '强调审核' },
|
||||
{ agent: 'NarrativeAgent', opinion: '审核流程可能影响内容时效性', position: '担忧时效' },
|
||||
],
|
||||
status: '需进一步讨论',
|
||||
},
|
||||
];
|
||||
|
||||
const getColorClasses = (color: string) => {
|
||||
const colors: Record<string, { bg: string; border: string; text: string }> = {
|
||||
blue: { bg: isDark ? 'bg-blue-500/20' : 'bg-blue-50', border: isDark ? 'border-blue-500/50' : 'border-blue-200', text: isDark ? 'text-blue-400' : 'text-blue-600' },
|
||||
cyan: { bg: isDark ? 'bg-cyan-500/20' : 'bg-cyan-50', border: isDark ? 'border-cyan-500/50' : 'border-cyan-200', text: isDark ? 'text-cyan-400' : 'text-cyan-600' },
|
||||
green: { bg: isDark ? 'bg-green-500/20' : 'bg-green-50', border: isDark ? 'border-green-500/50' : 'border-green-200', text: isDark ? 'text-green-400' : 'text-green-600' },
|
||||
yellow: { bg: isDark ? 'bg-yellow-500/20' : 'bg-yellow-50', border: isDark ? 'border-yellow-500/50' : 'border-yellow-200', text: isDark ? 'text-yellow-400' : 'text-yellow-600' },
|
||||
purple: { bg: isDark ? 'bg-purple-500/20' : 'bg-purple-50', border: isDark ? 'border-purple-500/50' : 'border-purple-200', text: isDark ? 'text-purple-400' : 'text-purple-600' },
|
||||
red: { bg: isDark ? 'bg-red-500/20' : 'bg-red-50', border: isDark ? 'border-red-500/50' : 'border-red-200', text: isDark ? 'text-red-400' : 'text-red-600' },
|
||||
};
|
||||
return colors[color] || colors.blue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 顶部总览区 */}
|
||||
<div className={`${cardBg} backdrop-blur-xl ${cardBorder} rounded-xl p-5`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => onNavigate('task-list')}
|
||||
className={`flex items-center gap-2 px-3 py-2 ${btnSecondary} rounded-lg text-sm transition-colors`}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回
|
||||
</button>
|
||||
<div className={`h-8 w-px ${isDark ? 'bg-blue-800/30' : 'bg-red-200'}`} />
|
||||
<div>
|
||||
<h1 className={`${textPrimary} font-bold text-lg`}>五四青年节宣传策略会商</h1>
|
||||
<p className={`${textMuted} text-sm`}>会商结果 · 已完成</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-3 py-1.5 ${isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-700'} rounded-full text-sm font-medium flex items-center gap-1.5`}>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
会商完成
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className={`grid grid-cols-4 gap-4 p-4 ${inputBg} rounded-lg`}>
|
||||
<div className="text-center">
|
||||
<p className={`${textMuted} text-xs mb-1`}>参与智能体</p>
|
||||
<p className={`${textPrimary} font-medium text-lg`}>4</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className={`${textMuted} text-xs mb-1`}>会商轮数</p>
|
||||
<p className={`${textPrimary} font-medium text-lg`}>3</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className={`${textMuted} text-xs mb-1`}>完成时间</p>
|
||||
<p className={`${textPrimary} font-medium text-lg`}>2026-03-19</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className={`${textMuted} text-xs mb-1`}>采纳观点</p>
|
||||
<p className={`${isDark ? 'text-green-400' : 'text-green-600'} font-medium text-lg`}>4/4</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<button className={`flex items-center gap-2 px-4 py-2 ${btnSecondary} rounded-lg text-sm transition-colors`}>
|
||||
<Download className="w-4 h-4" />
|
||||
导出纪要
|
||||
</button>
|
||||
<button className={`flex items-center gap-2 px-4 py-2 ${btnSecondary} rounded-lg text-sm transition-colors`}>
|
||||
<FileText className="w-4 h-4" />
|
||||
导出结论
|
||||
</button>
|
||||
<button className={`flex items-center gap-2 px-4 py-2 ${isDark ? 'bg-cyan-600/20 hover:bg-cyan-600/30 border-cyan-500/50 text-cyan-400' : 'bg-cyan-100 hover:bg-cyan-200 border-cyan-300 text-cyan-700'} rounded-lg text-sm transition-colors`}>
|
||||
<Bot className="w-4 h-4" />
|
||||
转为单智能体任务
|
||||
</button>
|
||||
<button className={`flex items-center gap-2 px-4 py-2 ${isDark ? 'bg-purple-600/20 hover:bg-purple-600/30 border-purple-500/50 text-purple-400' : 'bg-purple-100 hover:bg-purple-200 border-purple-300 text-purple-700'} rounded-lg text-sm transition-colors`}>
|
||||
<Zap className="w-4 h-4" />
|
||||
转为场景任务
|
||||
</button>
|
||||
<button className={`flex items-center gap-2 px-4 py-2 ${btnPrimary} text-white rounded-lg text-sm transition-colors ml-auto`}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
重新发起会商
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab切换 */}
|
||||
<div className={`flex items-center gap-1 ${cardBg} p-1 rounded-lg w-fit`}>
|
||||
<button
|
||||
onClick={() => setActiveTab('conclusion')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === 'conclusion'
|
||||
? `${btnPrimary} text-white`
|
||||
: `${textSecondary} ${isDark ? 'hover:text-white hover:bg-slate-700/50' : 'hover:text-gray-900 hover:bg-gray-200'}`
|
||||
}`}
|
||||
>
|
||||
会商结论
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('opinions')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === 'opinions'
|
||||
? `${btnPrimary} text-white`
|
||||
: `${textSecondary} ${isDark ? 'hover:text-white hover:bg-slate-700/50' : 'hover:text-gray-900 hover:bg-gray-200'}`
|
||||
}`}
|
||||
>
|
||||
观点汇总
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('disputes')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === 'disputes'
|
||||
? `${btnPrimary} text-white`
|
||||
: `${textSecondary} ${isDark ? 'hover:text-white hover:bg-slate-700/50' : 'hover:text-gray-900 hover:bg-gray-200'}`
|
||||
}`}
|
||||
>
|
||||
分歧与待确认
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
{activeTab === 'conclusion' && (
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{/* 会商结论 */}
|
||||
<div className="col-span-8 space-y-6">
|
||||
<div className={`${cardBg} backdrop-blur-xl ${cardBorder} rounded-xl p-5`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-4 flex items-center gap-2`}>
|
||||
<CheckCircle className={`w-4 h-4 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
|
||||
本次会商结论
|
||||
</h3>
|
||||
<div className={`p-4 ${isDark ? 'bg-green-500/10 border-green-500/30' : 'bg-green-50 border-green-200'} rounded-lg`}>
|
||||
<p className={`${isDark ? 'text-green-300' : 'text-green-800'} leading-relaxed`}>{conclusion.mainConclusion}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${cardBg} backdrop-blur-xl ${cardBorder} rounded-xl p-5`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-4 flex items-center gap-2`}>
|
||||
<Check className={`w-4 h-4 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
|
||||
推荐采用方向
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{conclusion.recommendedDirection.map((item, index) => (
|
||||
<div key={index} className={`flex items-center gap-3 p-3 ${isDark ? 'bg-green-500/10 border-green-500/30' : 'bg-green-50 border-green-200'} rounded-lg`}>
|
||||
<div className={`w-6 h-6 rounded-full ${isDark ? 'bg-green-500/20' : 'bg-green-100'} flex items-center justify-center`}>
|
||||
<Check className={`w-4 h-4 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
|
||||
</div>
|
||||
<p className={`${isDark ? 'text-green-300' : 'text-green-800'} text-sm`}>{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${cardBg} backdrop-blur-xl ${cardBorder} rounded-xl p-5`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-4 flex items-center gap-2`}>
|
||||
<X className={`w-4 h-4 ${isDark ? 'text-red-400' : 'text-red-600'}`} />
|
||||
不建议采用方向
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{conclusion.notRecommendedDirection.map((item, index) => (
|
||||
<div key={index} className={`flex items-center gap-3 p-3 ${isDark ? 'bg-red-500/10 border-red-500/30' : 'bg-red-50 border-red-200'} rounded-lg`}>
|
||||
<div className={`w-6 h-6 rounded-full ${isDark ? 'bg-red-500/20' : 'bg-red-100'} flex items-center justify-center`}>
|
||||
<X className={`w-4 h-4 ${isDark ? 'text-red-400' : 'text-red-600'}`} />
|
||||
</div>
|
||||
<p className={`${isDark ? 'text-red-300' : 'text-red-800'} text-sm`}>{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<div className="col-span-4 space-y-6">
|
||||
<div className={`${cardBg} backdrop-blur-xl ${isDark ? 'border-yellow-800/30' : 'border-yellow-200'} rounded-xl p-5`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-4 flex items-center gap-2`}>
|
||||
<AlertCircle className={`w-4 h-4 ${isDark ? 'text-yellow-400' : 'text-yellow-600'}`} />
|
||||
待确认问题
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{conclusion.pendingIssues.map((item, index) => (
|
||||
<div key={index} className={`p-3 ${isDark ? 'bg-yellow-500/10 border-yellow-500/30' : 'bg-yellow-50 border-yellow-200'} rounded-lg`}>
|
||||
<p className={`${isDark ? 'text-yellow-300' : 'text-yellow-800'} text-sm`}>{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${cardBg} backdrop-blur-xl ${isDark ? 'border-purple-800/30' : 'border-purple-200'} rounded-xl p-5`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-4 flex items-center gap-2`}>
|
||||
<ChevronRight className={`w-4 h-4 ${isDark ? 'text-purple-400' : 'text-purple-600'}`} />
|
||||
推荐下一步动作
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{conclusion.nextSteps.map((item, index) => (
|
||||
<div key={index} className={`p-3 ${isDark ? 'bg-purple-500/10 border-purple-500/30' : 'bg-purple-50 border-purple-200'} rounded-lg`}>
|
||||
<p className={`${isDark ? 'text-purple-300' : 'text-purple-800'} text-sm`}>{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className={`w-full mt-4 flex items-center justify-center gap-2 px-4 py-3 ${btnPrimary} text-white rounded-xl text-sm font-medium transition-colors`}>
|
||||
<Zap className="w-4 h-4" />
|
||||
转入宣传策略生成场景
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'opinions' && (
|
||||
<div className={`${cardBg} backdrop-blur-xl ${cardBorder} rounded-xl p-5`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-4 flex items-center gap-2`}>
|
||||
<Bot className={`w-4 h-4 ${isDark ? 'text-cyan-400' : 'text-cyan-600'}`} />
|
||||
各智能体观点汇总
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{agentOpinions.map((agent) => {
|
||||
const colors = getColorClasses(agent.color);
|
||||
return (
|
||||
<div key={agent.agentId} className={`p-4 ${inputBg} ${inputBorder} rounded-xl`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${colors.bg} flex items-center justify-center`}>
|
||||
<Bot className={`w-5 h-5 ${colors.text}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-sm font-medium ${colors.text}`}>{agent.agentName}</p>
|
||||
{agent.adopted ? (
|
||||
<span className={`px-1.5 py-0.5 ${isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-700'} rounded text-xs`}>
|
||||
已采纳
|
||||
</span>
|
||||
) : (
|
||||
<span className={`px-1.5 py-0.5 ${isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-gray-200 text-gray-600'} rounded text-xs`}>
|
||||
未采纳
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={`${textMuted} text-xs`}>{agent.agentRole}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-13">
|
||||
<p className={`${textSecondary} text-sm mb-2`}>{agent.coreOpinion}</p>
|
||||
<p className={`${textMuted} text-xs`}>备注:{agent.notes}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'disputes' && (
|
||||
<div className={`${cardBg} backdrop-blur-xl ${cardBorder} rounded-xl p-5`}>
|
||||
<h3 className={`${textPrimary} font-semibold text-sm mb-4 flex items-center gap-2`}>
|
||||
<AlertCircle className={`w-4 h-4 ${isDark ? 'text-yellow-400' : 'text-yellow-600'}`} />
|
||||
争议点与待确认事项
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{disputes.map((dispute) => (
|
||||
<div key={dispute.id} className={`p-4 ${inputBg} ${isDark ? 'border-yellow-800/30' : 'border-yellow-200'} rounded-xl`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p className={`${textPrimary} font-medium text-sm`}>{dispute.issue}</p>
|
||||
<p className={`${textMuted} text-xs mt-0.5`}>{dispute.description}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 ${isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-700'} rounded text-xs`}>
|
||||
{dispute.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{dispute.viewpoints.map((viewpoint, index) => (
|
||||
<div key={index} className={`p-3 ${isDark ? 'bg-yellow-500/5 border-yellow-500/20' : 'bg-yellow-50 border-yellow-200'} rounded-lg`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Bot className={`w-4 h-4 ${textMuted}`} />
|
||||
<span className={`${textMuted} text-xs`}>{viewpoint.agent}</span>
|
||||
</div>
|
||||
<p className={`${isDark ? 'text-yellow-300' : 'text-yellow-800'} text-sm`}>{viewpoint.opinion}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className={`mt-3 flex items-center gap-1 px-3 py-1.5 ${isDark ? 'bg-yellow-500/20 hover:bg-yellow-500/30 border-yellow-500/50 text-yellow-400' : 'bg-yellow-100 hover:bg-yellow-200 border-yellow-300 text-yellow-700'} rounded text-xs transition-colors`}>
|
||||
<Target className="w-3 h-3" />
|
||||
发起单智能体分析
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskConsultResult;
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user