# Conflicts:
#	frontend-web/src/components/workspace/chats/chat-box.tsx
#	offline-backend-20260512/backend/app/gateway/routers/agents.py
This commit is contained in:
吉鑫升 committed 2026-05-28 17:29:36 +08:00
commit 6792b4f4c4
200 files changed
+78520 -392

No files matched your search

+15 -1
View File
@@ -7,6 +7,20 @@ VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 轻应用「新建笔记」系列接口 # 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=htttps://ty.b.uat.4.cn
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL) # 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL= # VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -3,5 +3,18 @@
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=htttps://ty.b.uat.4.cn
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000 # VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
+13
View File
@@ -7,6 +7,19 @@ VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# 轻应用「新建笔记」系列接口 # 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=htttps://ty.b.uat.4.cn
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址 # 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL= # VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -0,0 +1,24 @@
.sidebar-contents{
width:98px;
height:986px;
background:rgba(3, 38, 80, 0.8);
border:1px solid;
border-color:rgba(43, 143, 254, 0.6);
border-radius:4px;
backdrop-filter:blur(2px);
}
.item-nav{
width:78px;
height:70px;
background:rgba(0, 12, 28, 0.15);
border:1px solid;
border-color:rgba(85, 166, 254, 0.2);
line-height:8px;
font-family:Noto Sans S Chinese;
font-weight:500;
color:rgba(255, 255, 255, 0.8);
font-size:20px;
padding-bottom:18px;
text-align:center;
}
@@ -0,0 +1,311 @@
import { useEffect, useState } from "react";
import type { ElementType } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
Bell,
Bot,
Brain,
CalendarClock,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
CircleHelp,
Database,
FileBox,
FileText,
Folder,
FolderOpen,
Gauge,
Grid3X3,
HelpCircle,
History,
Layout,
Library,
List,
ListChecks,
ListTodo,
MessageSquareCode,
MessageSquarePlus,
Network,
NotebookPen,
Plus,
Settings,
Sparkles,
Target,
User,
Users,
Wrench,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { SettingsDialog } from "@/components/workspace/settings";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import './page-sdebar.css'
type MenuItem = {
id: string;
label: string;
icon: ElementType;
path?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
type PageSidebarDarkProps = {
collapsed: boolean;
onToggleCollapsed: () => void;
};
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
const menuItems: MenuItem[] = [
{
id: "qa-center",
label: "问答中心",
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
],
},
{
id: "light-app-center",
label: "轻应用",
icon: Grid3X3,
children: [
{
id: "light-app-note-management",
label: "笔记管理",
icon: NotebookPen,
children: [
{
id: "light-app-new-note",
label: "新建笔记",
icon: Plus,
path: "/page/strategy/light-app/new-note",
},
{
id: "light-app-note-list",
label: "笔记列表",
icon: List,
path: "/page/strategy/light-app/notes",
},
{
id: "light-app-note-qa",
label: "笔记问答",
icon: MessageSquareCode,
path: "/page/strategy/light-app/note-qa",
},
],
},
{ id: "light-app-square", label: "应用广场", icon: Layout },
{ id: "light-app-records", label: "运行记录", icon: History },
],
},
{
id: "task-management",
label: "任务管理",
icon: Target,
children: [
{
id: "xk-center-task",
label: "XK中任务",
icon: Folder,
children: [
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo, path: "/page/strategy/task-management/xk-task-jc" },
{ id: "xk-task-dispatch", label: "任务调度", icon: ListTodo, path: "/page/strategy/task-management/xk-task-dispatch" },
],
},
{
id: "coh-center-task",
label: "COH中任务",
icon: Folder,
children: [
{ id: "coh-task-3qfx", label: "3QFX", icon: ListTodo },
{ id: "coh-task-fx", label: "任务FX", icon: ListTodo },
{ id: "coh-task-xdfx", label: "XDFX", icon: ListTodo },
],
},
{
id: "pg-center-task",
label: "PG中任务",
icon: Folder,
children: [
{ id: "pg-task-pg", label: "任务PG", icon: ListTodo },
{ id: "pg-task-xdpg", label: "XDPG", icon: ListTodo },
{ id: "pg-task-info-pg", label: "信息PG", icon: ListTodo },
],
},
{
id: "ty-center-task",
label: "TY中任务",
icon: Folder,
children: [
{ id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo },
{ id: "ty-task-plan", label: "方案TY任务", icon: ListTodo },
{ id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo },
],
},
],
},
{
id: "comprehensive-management",
label: "综合管理",
icon: Settings,
children: [
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
id: "mgmt-tasks",
label: "任务管理",
icon: ListChecks,
path: "/page/strategy/scheduled/tasks",
},
{
id: "mgmt-recommended-questions",
label: "推荐问题配置",
icon: MessageSquarePlus,
path: "/page/strategy/settings/recommended-questions",
adminOnly: true,
},
{
id: "mgmt-prompt-prefix",
label: "问答前缀配置",
icon: MessageSquareCode,
path: "/page/strategy/settings/prompt-prefix",
adminOnly: true,
},
{
id: "mgmt-llm-metrics",
label: "大模型调用统计",
icon: Gauge,
path: "/page/strategy/settings/llm-metrics",
adminOnly: true,
},
{
id: "mgmt-users",
label: "用户管理",
icon: Users,
path: "/page/strategy/admin/users",
adminOnly: true,
},
{
id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理",
icon: CalendarClock,
path: "/page/strategy/admin/scheduled-tasks",
adminOnly: true,
},
{
id: "mgmt-user-activity",
label: "用户活动审计",
icon: History,
path: "/page/strategy/admin/user-activity",
adminOnly: true,
},
],
},
{
id: "resource-management",
label: "资源管理",
icon: Database,
children: [
{ id: "resource-network-xwt", label: "网络xwt资源管理", icon: Network },
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
{ id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
],
},
];
const defaultExpandedIds = new Set([
"qa-center",
"light-app-center",
"light-app-note-management",
"task-management",
"xk-center-task",
]);
function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
return items
.filter((item) => !item.adminOnly || isAdmin)
.map((item) =>
item.children
? { ...item, children: filterMenuItemsByRole(item.children, isAdmin) }
: item,
);
}
export function PageSidebarDark({
collapsed,
onToggleCollapsed,
}: PageSidebarDarkProps) {
const { theme } = useTheme();
const isDark = theme === "dark";
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = filterMenuItemsByRole(menuItems, !!isAdmin);
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] =
useState<SettingsSection>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const navigate = useNavigate();
const { pathname } = useLocation();
useEffect(() => {
setMounted(true);
}, []);
const openSettings = (section: SettingsSection) => {
setSettingsDefaultSection(section);
setSettingsOpen(true);
};
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
{/* TODO: 在这里编写深色主题的侧边栏 HTML 结构 */}
<div className="sidebar-contnet">
<div className="sidebar-contents">
<div className="item-nav">
问答管理
</div>
</div>
</div>
</>
);
}
@@ -42,6 +42,7 @@ import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-di
import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { import {
useAgent,
useDeleteAgent, useDeleteAgent,
useSetAgentFeatured, useSetAgentFeatured,
useSetAgentPrivate, useSetAgentPrivate,
@@ -81,6 +82,13 @@ export function AgentCard({ agent }: AgentCardProps) {
); );
const [detailSkillName, setDetailSkillName] = useState<string | null>(null); const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
// The /api/agents list response intentionally omits `soul` (perf optimization
// — SOUL.md is only read on demand). Fetch the detail row when the edit
// dialog opens so the SOUL textarea can be hydrated.
const { agent: agentDetail, isLoading: isAgentDetailLoading } = useAgent(
editOpen ? agent.id : null,
);
const storedAuth = useMemo(() => getStoredAuth(), []); const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null; const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin"; const isAdmin = storedAuth?.system_role === "admin";
@@ -105,7 +113,6 @@ export function AgentCard({ agent }: AgentCardProps) {
} }
setEditName(agent.name); setEditName(agent.name);
setEditDescription(agent.description ?? ""); setEditDescription(agent.description ?? "");
setEditSoul(agent.soul ?? "");
setEditPublished(agent.published); setEditPublished(agent.published);
setEditIsPrivate(agent.is_private); setEditIsPrivate(agent.is_private);
setSelectedSkills(agent.skills ?? []); setSelectedSkills(agent.skills ?? []);
@@ -115,10 +122,23 @@ export function AgentCard({ agent }: AgentCardProps) {
agent.name, agent.name,
agent.published, agent.published,
agent.skills, agent.skills,
agent.soul,
editOpen, editOpen,
]); ]);
useEffect(() => {
if (!editOpen) {
return;
}
// SOUL.md only ships on the detail endpoint; fall back to whatever the list
// happened to carry so older deployments (or cached objects that still
// include `soul`) keep working.
if (agentDetail) {
setEditSoul(agentDetail.soul ?? "");
} else {
setEditSoul(agent.soul ?? "");
}
}, [agent.soul, agentDetail, editOpen]);
function handleChat() { function handleChat() {
router.push(`/page/workspace/agents/${agent.id}/chats/new`); router.push(`/page/workspace/agents/${agent.id}/chats/new`);
} }
@@ -563,7 +583,11 @@ export function AgentCard({ agent }: AgentCardProps) {
<Textarea <Textarea
value={editSoul ?? ""} value={editSoul ?? ""}
onChange={(e) => setEditSoul(e.target.value)} onChange={(e) => setEditSoul(e.target.value)}
placeholder="输入 SOUL.md 内容" placeholder={
isAgentDetailLoading && !agentDetail
? "正在加载 SOUL.md..."
: "输入 SOUL.md 内容"
}
className="min-h-40 font-mono text-xs" className="min-h-40 font-mono text-xs"
/> />
</div> </div>
@@ -32,7 +32,10 @@ interface ArtifactsProviderProps {
} }
export function ArtifactsProvider({ children }: ArtifactsProviderProps) { export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifacts] = useState<string[]>([]); const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
setArtifactsState(Array.isArray(next) ? next : []);
}, []);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null); const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true); const [autoSelect, setAutoSelect] = useState(true);
const [open, setOpen] = useState( const [open, setOpen] = useState(
@@ -163,7 +163,8 @@ export function RecentChatList() {
const visibleThreads = threads.filter( const visibleThreads = threads.filter(
(thread) => (thread) =>
thread.metadata?.thread_type !== "scheduler" && thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap", thread.metadata?.thread_type !== "bootstrap" &&
thread.metadata?.thread_type !== "roundtable",
); );
if (visibleThreads.length === 0) { if (visibleThreads.length === 0) {
+1
View File
@@ -34,6 +34,7 @@ export default function ChatsPage() {
return threads?.filter((thread) => { return threads?.filter((thread) => {
return ( return (
thread.metadata?.thread_type !== "scheduler" && thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable" &&
titleOfThread(thread).toLowerCase().includes(search.toLowerCase()) titleOfThread(thread).toLowerCase().includes(search.toLowerCase())
); );
}); });
+15 -4
View File
@@ -2,7 +2,9 @@ import { useMemo, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { PageSidebar } from "@/components/page-sidebar"; import { PageSidebar } from "@/components/page-sidebar";
import { PageSidebarDark } from "@/components/page-sidebar-dark";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled"; import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { useTheme } from "@/contexts/ThemeContext";
import WorkspaceRoutes from "./WorkspaceRoutes"; import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes"; import StrategyRoutes from "./StrategyRoutes";
@@ -13,16 +15,25 @@ import RoundtableRoutes from "./RoundtableRoutes";
function PageLayout() { function PageLayout() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []); const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
const { theme } = useTheme();
const isDark = theme === "dark";
return ( return (
<div className="h-screen flex flex-col bg-muted/20"> <div className="h-screen flex flex-col bg-muted/20">
{sidebarEnabled && <Header collapsed={collapsed} />} {sidebarEnabled && <Header collapsed={collapsed} />}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{sidebarEnabled && ( {sidebarEnabled && (
<PageSidebar isDark ? (
collapsed={collapsed} <PageSidebarDark
onToggleCollapsed={() => setCollapsed((prev) => !prev)} collapsed={collapsed}
/> onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
) : (
<PageSidebar
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
)
)} )}
<main <main
className={`flex-1 overflow-hidden transition-[padding] duration-300 ${ className={`flex-1 overflow-hidden transition-[padding] duration-300 ${
+156 -135
View File
@@ -28,151 +28,172 @@ import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage"; import LightAppNoteQAPage from "./LightAppNoteQAPage";
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
import TaskManagement from "@/strategy-components/components/task-components/TaskManagement";
export default function StrategyRoutes() { export default function StrategyRoutes() {
return ( return (
<RouteBaseProvider value="/page/strategy"> <RouteBaseProvider value="/page/strategy">
<SidebarProvider> <SidebarProvider>
<div className="h-full w-full overflow-hidden"> <div className="h-full w-full overflow-hidden">
<Routes> <Routes>
<Route index element={<Navigate to="qa/general" replace />} /> <Route index element={<Navigate to="qa/general" replace />} />
{/* 问答中心 */} {/* 问答中心 */}
<Route path="qa/general" element={<GeneralQA />} /> <Route path="qa/general" element={<GeneralQA />} />
{/* 任务管理 */}
<Route path="task-management" element={<TaskManagement />}>
{/* XK任务 */}
<Route
path="xk-task-jc"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageHome/pageSwrw'}
/>
}
/>
<Route
path="xk-task-dispatch"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageJczl'}
/>
}
/>
</Route>
{/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 轻应用 */} {/* 对话 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} /> <Route path="chats" element={<ChatsPage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} /> <Route
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} /> path="chats/:thread_id"
element={
<ChatRuntime>
<ChatPage />
</ChatRuntime>
}
/>
{/* 对话 */} {/* 智能体管理 */}
<Route path="chats" element={<ChatsPage />} /> <Route path="agents" element={<AgentsPage />} />
<Route <Route path="agents/new" element={<NewAgentPage />} />
path="chats/:thread_id" <Route
element={ path="agents/:agent_id/chats"
<ChatRuntime> element={<Navigate to="/page/strategy/agents" replace />}
<ChatPage /> />
</ChatRuntime> <Route
} path="agents/:agent_id/chats/:thread_id"
/> element={
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
}
/>
{/* 智能体管理 */} {/* 技能 / 记忆 */}
<Route path="agents" element={<AgentsPage />} /> <Route path="skills" element={<SkillsPage />} />
<Route path="agents/new" element={<NewAgentPage />} /> <Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route <Route path="memory" element={<MemoryPage />} />
path="agents/:agent_id/chats"
element={<Navigate to="/page/strategy/agents" replace />}
/>
<Route
path="agents/:agent_id/chats/:thread_id"
element={
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
}
/>
{/* 技能 / 记忆 */} {/* 定时任务 */}
<Route path="skills" element={<SkillsPage />} /> <Route
<Route path="skills/evolution" element={<SkillEvolutionPage />} /> path="scheduled"
<Route path="memory" element={<MemoryPage />} /> element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
/>
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} />
<Route
path="scheduled/runs/:run_id"
element={<ScheduledTaskRunDetailPage />}
/>
{/* 定时任务 */} {/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
<Route <Route
path="scheduled" path="settings/recommended-questions"
element={<Navigate to="/page/strategy/scheduled/tasks" replace />} element={
/> getStoredAuth()?.system_role === "admin" ? (
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} /> <RecommendedQuestionsPage />
<Route ) : (
path="scheduled/runs/:run_id" <Navigate to="/page/workspace/chats/new" replace />
element={<ScheduledTaskRunDetailPage />} )
/> }
/>
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */} <Route
<Route path="settings/prompt-prefix"
path="settings/recommended-questions" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <PromptPrefixSettingsPage />
<RecommendedQuestionsPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="settings/private-square"
path="settings/prompt-prefix" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <PrivateSquareSettingsPage />
<PromptPrefixSettingsPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="settings/llm-metrics"
path="settings/private-square" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <LlmMetricsPage />
<PrivateSquareSettingsPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="admin/users"
path="settings/llm-metrics" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <UsersPage />
<LlmMetricsPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="admin/scheduled-tasks"
path="admin/users" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <AdminScheduledTasksPage />
<UsersPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="admin/user-activity"
path="admin/scheduled-tasks" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <AdminUserActivityPage />
<AdminScheduledTasksPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="admin/tags"
path="admin/user-activity" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <TagsPage />
<AdminUserActivityPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> </Routes>
<Route </div>
path="admin/tags" </SidebarProvider>
element={ <Toaster position="top-center" />
getStoredAuth()?.system_role === "admin" ? (
<TagsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</div>
</SidebarProvider>
<Toaster position="top-center" />
</RouteBaseProvider> </RouteBaseProvider>
); );
} }
@@ -27,7 +27,7 @@ export interface StreamRecommendResult {
* `done` → picks parsed and validated, ready to populate the dialog as * `done` → picks parsed and validated, ready to populate the dialog as
* default-checked selections. * default-checked selections.
* `asking` → recommender failed to emit a usable [RECOMMEND_READY] block, * `asking` → recommender failed to emit a usable [RECOMMEND_READY] block,
* caller should pick from the live candidate pool as fallback. * caller should apply its fallback list (typically the 6 roundtable-* seats).
* `error` → upstream / network failure. * `error` → upstream / network failure.
*/ */
status: "done" | "asking" | "error"; status: "done" | "asking" | "error";
@@ -202,6 +202,8 @@ export interface ReasoningBlockProps {
/** 用于显示「思考用时」的可选时长(秒)。流式期间不显示。 */ /** 用于显示「思考用时」的可选时长(秒)。流式期间不显示。 */
durationSec?: number; durationSec?: number;
className?: string; className?: string;
/** 展开内容区额外 class,例如 max-h + overflow-y-auto。 */
contentClassName?: string;
} }
const REASONING_AUTO_CLOSE_DELAY_MS = 1000; const REASONING_AUTO_CLOSE_DELAY_MS = 1000;
@@ -211,6 +213,7 @@ export function ReasoningBlock({
streaming = false, streaming = false,
durationSec, durationSec,
className, className,
contentClassName,
}: ReasoningBlockProps) { }: ReasoningBlockProps) {
// open 初始值跟 streaming 同步:流式中默认展开方便观察思考过程,完成后会自动收起一次。 // open 初始值跟 streaming 同步:流式中默认展开方便观察思考过程,完成后会自动收起一次。
const [open, setOpen] = useState<boolean>(streaming); const [open, setOpen] = useState<boolean>(streaming);
@@ -260,7 +263,12 @@ export function ReasoningBlock({
/> />
</button> </button>
{open && ( {open && (
<div className="rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[11.5px] text-slate-600 leading-relaxed"> <div
className={cn(
"rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[11.5px] text-slate-600 leading-relaxed",
contentClassName,
)}
>
<MarkdownContent>{children}</MarkdownContent> <MarkdownContent>{children}</MarkdownContent>
</div> </div>
)} )}
@@ -46,6 +46,8 @@ export interface MessageStepsCardProps {
className?: string; className?: string;
/** 点击 write_file / present_files 等产物相关步骤时打开沙箱。 */ /** 点击 write_file / present_files 等产物相关步骤时打开沙箱。 */
onArtifactStepClick?: (step: BubbleStep) => void; onArtifactStepClick?: (step: BubbleStep) => void;
/** 把 agent_orchestration 派活的 agent id 解析成中文席位名(用于 chip 显示)。 */
resolveAgentName?: (idOrName: string) => string;
} }
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */ /** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
@@ -58,6 +60,7 @@ export function MessageStepsCard({
streaming, streaming,
className, className,
onArtifactStepClick, onArtifactStepClick,
resolveAgentName,
}: MessageStepsCardProps) { }: MessageStepsCardProps) {
// 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` / // 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` /
// `lastReasoningStep` 划分对齐。 // `lastReasoningStep` 划分对齐。
@@ -131,12 +134,14 @@ export function MessageStepsCard({
step={s} step={s}
status="complete" status="complete"
onArtifactStepClick={onArtifactStepClick} onArtifactStepClick={onArtifactStepClick}
resolveAgentName={resolveAgentName}
/> />
))} ))}
<ToolStep <ToolStep
step={lastToolStep} step={lastToolStep}
status={streaming && trailingReasoning.length === 0 ? "active" : "complete"} status={streaming && trailingReasoning.length === 0 ? "active" : "complete"}
onArtifactStepClick={onArtifactStepClick} onArtifactStepClick={onArtifactStepClick}
resolveAgentName={resolveAgentName}
/> />
</ChainOfThoughtContent> </ChainOfThoughtContent>
)} )}
@@ -155,18 +160,23 @@ function ToolStep({
step, step,
status, status,
onArtifactStepClick, onArtifactStepClick,
resolveAgentName,
}: { }: {
step: BubbleStep; step: BubbleStep;
status: "complete" | "active"; status: "complete" | "active";
onArtifactStepClick?: (step: BubbleStep) => void; onArtifactStepClick?: (step: BubbleStep) => void;
resolveAgentName?: (idOrName: string) => string;
}) { }) {
const display = getStepDisplay(step.key, step.label, step.args); const display = getStepDisplay(step.key, step.label, step.args);
const argNode = renderStepArgs(step.key, step.args); const argNode = renderStepArgs(step.key, step.args, resolveAgentName);
const [, toolName] = step.key.split(":"); const [, toolName] = step.key.split(":");
// read_file 也可点 —— 验证类「查看文件…」步骤常出现在最后一条,点击后在沙箱里
// 查看该文件内容(走虚拟预览或 outputs HTTP)。主聊天只让 write 类可点,这里增强。
const isArtifactStep = const isArtifactStep =
toolName === "write_file" || toolName === "write_file" ||
toolName === "str_replace" || toolName === "str_replace" ||
toolName === "present_files"; toolName === "present_files" ||
toolName === "read_file";
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
@@ -62,6 +62,20 @@ export interface RecommendAgentsDialogProps {
const MIN_SELECTED = 2; const MIN_SELECTED = 2;
const MAX_SELECTED = 8; const MAX_SELECTED = 8;
/**
* Default selection when the recommender fails or returns < 2 valid picks.
* Intersected with the live candidate pool at runtime so missing built-ins
* don't break the dialog.
*/
const FALLBACK_AGENT_IDS = [
"roundtable-intelligence",
"roundtable-environment",
"roundtable-solution-design",
"roundtable-risk-review",
"roundtable-execution-plan",
"roundtable-summary",
];
type DialogPhase = type DialogPhase =
| "idle" | "idle"
| "loading_agents" | "loading_agents"
@@ -305,17 +319,12 @@ export function RecommendAgentsDialog({
return; return;
} }
// Fallback: status === "asking" or picks too few — pick from live pool. // Fallback: status === "asking" or picks too few. Use the 6 built-in
const seed = candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id); // roundtable seats, intersected against the live pool.
if (seed.length < MIN_SELECTED) { const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates);
setState((prev) => ({ const seed = fallback.length >= MIN_SELECTED
...prev, ? fallback
phase: "error", : candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
streamPhase: null,
errorMessage: `候选智能体不足 ${MIN_SELECTED} 个,无法进入圆桌研讨。请先发布更多智能体。`,
}));
return;
}
setSelectedIds(new Set(seed)); setSelectedIds(new Set(seed));
const { rationale } = parseRecommendStreamContent(result.content || ""); const { rationale } = parseRecommendStreamContent(result.content || "");
setState((prev) => ({ setState((prev) => ({
@@ -325,7 +334,7 @@ export function RecommendAgentsDialog({
recommendedIds: seed, recommendedIds: seed,
reasons, reasons,
streamPhase: null, streamPhase: null,
warning: "智能推荐未生成有效结果,已为你勾选候选池中的前几位智能体,可手动调整。", warning: "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。",
})); }));
} catch (err) { } catch (err) {
if (myGen !== generationRef.current) return; if (myGen !== generationRef.current) return;
@@ -421,8 +430,8 @@ export function RecommendAgentsDialog({
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col"> <DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col overflow-hidden gap-3">
<DialogHeader> <DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-5 text-purple-500" /> <SparklesIcon className="size-5 text-purple-500" />
选择参会智能体 选择参会智能体
@@ -457,7 +466,7 @@ export function RecommendAgentsDialog({
<CheckIcon className="size-3 text-emerald-500" /> <CheckIcon className="size-3 text-emerald-500" />
推荐参会智能体 推荐参会智能体
</div> </div>
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4"> <div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar pr-1 -mr-1 space-y-4">
{state.phase === "error" ? ( {state.phase === "error" ? (
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5"> <div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
{state.errorMessage ?? "未知错误"} {state.errorMessage ?? "未知错误"}
@@ -527,7 +536,7 @@ export function RecommendAgentsDialog({
</div> </div>
</section> </section>
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap"> <DialogFooter className="shrink-0 flex-row sm:justify-between items-center gap-2 flex-wrap border-t pt-3 mt-1">
<div className="flex items-center gap-3 text-xs"> <div className="flex items-center gap-3 text-xs">
<span <span
className={cn( className={cn(
@@ -635,14 +644,14 @@ function CollapsibleModelOutput({
})(); })();
return ( return (
<section className="border rounded-md bg-muted/20 shrink-0"> <section className="border rounded-md bg-muted/20 shrink-0 max-h-[38vh] flex flex-col min-h-0 overflow-hidden">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
userTouchedRef.current = true; userTouchedRef.current = true;
setOpen((v) => !v); setOpen((v) => !v);
}} }}
className="w-full flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer" className="w-full shrink-0 flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
> >
{isLoading ? ( {isLoading ? (
<Loader2Icon className="size-3 text-purple-500 shrink-0 animate-spin" /> <Loader2Icon className="size-3 text-purple-500 shrink-0 animate-spin" />
@@ -667,7 +676,11 @@ function CollapsibleModelOutput({
)} )}
/> />
</button> </button>
{open && <div className="px-3 pb-3">{children}</div>} {open && (
<div className="px-3 pb-3 min-h-0 flex-1 overflow-y-auto custom-scrollbar">
{children}
</div>
)}
</section> </section>
); );
} }
@@ -736,7 +749,12 @@ function RecommenderOutput({ state }: { state: InternalState }) {
)} )}
{hasReasoning && ( {hasReasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning!}</ReasoningBlock> <ReasoningBlock
streaming={isStreaming}
contentClassName="max-h-48 overflow-y-auto custom-scrollbar"
>
{reasoning!}
</ReasoningBlock>
)} )}
{!hasRationale && !hasReasoning && isStreaming && ( {!hasRationale && !hasReasoning && isStreaming && (
@@ -744,7 +762,7 @@ function RecommenderOutput({ state }: { state: InternalState }) {
)} )}
{hasRationale && ( {hasRationale && (
<div className="max-h-72 overflow-y-auto rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed"> <div className="max-h-40 overflow-y-auto custom-scrollbar rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
<MarkdownContent>{cleanedRationale}</MarkdownContent> <MarkdownContent>{cleanedRationale}</MarkdownContent>
{isStreaming && <StreamingCursor />} {isStreaming && <StreamingCursor />}
</div> </div>
@@ -353,7 +353,13 @@ function Step1Message({
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md"> <div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md">
{introText && <MarkdownContent>{introText}</MarkdownContent>} {introText && <MarkdownContent>{introText}</MarkdownContent>}
{parts.summary && <IntentReadySummaryCard summary={parts.summary} />} {parts.summary && <IntentReadySummaryCard summary={parts.summary} />}
{parts.after && <MarkdownContent>{parts.after}</MarkdownContent>} {/*
parts.after 是模型违反 SOUL §0「硬规则 3」在 [INTENT_READY] 块后
继续输出的内容(典型:"那么我先帮你列一下大纲……")。后端 intent.py
的终态帧已经把 content 截断到 marker 结尾,但流式期间 LangGraph
messages-tuple 还是会把那段废话逐 token 推过来——故意在 UI 层
把它丢弃,让用户看到的就是"复述 + 摘要卡",干净结束。
*/}
{isStreaming && <StreamingCursor />} {isStreaming && <StreamingCursor />}
{isPendingClarification && clarification && ( {isPendingClarification && clarification && (
<IntentClarificationCard <IntentClarificationCard
@@ -15,7 +15,7 @@
* *
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。 * 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { import {
Cpu, Cpu,
FileIcon, FileIcon,
@@ -67,6 +67,11 @@ export interface Step2PanelProps {
agentConfigs: Record<number, AgentConfig>; agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null; selectedAgentId: number | null;
roles: RoleEntry[]; roles: RoleEntry[];
/** agent id(小写)→ 席位元数据,用于把派活 chip 的 id 显示成中文席位名。 */
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
selectedModel: string; selectedModel: string;
availableModels: ModelOption[]; availableModels: ModelOption[];
modelsLoading: boolean; modelsLoading: boolean;
@@ -89,7 +94,7 @@ export interface Step2PanelProps {
// ─── Handlers ───────────────────────────────────────────────── // ─── Handlers ─────────────────────────────────────────────────
onPauseRoundtable: () => void; onPauseRoundtable: () => void;
onResumeRoundtable: () => void; onResumeRoundtable: () => void;
onRecordInjectMessage: () => void; onHandleInjectToRoundtable: () => void;
onSubmitClarification: (answer: string) => void; onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void; onSavePersona: (id: number) => void;
onProceedToStep3: () => void; onProceedToStep3: () => void;
@@ -109,6 +114,7 @@ export function Step2Panel(props: Step2PanelProps) {
agentConfigs, agentConfigs,
selectedAgentId, selectedAgentId,
roles, roles,
agentNameToRole,
selectedModel, selectedModel,
availableModels, availableModels,
modelsLoading, modelsLoading,
@@ -123,7 +129,7 @@ export function Step2Panel(props: Step2PanelProps) {
setSelectedAgentId, setSelectedAgentId,
onPauseRoundtable, onPauseRoundtable,
onResumeRoundtable, onResumeRoundtable,
onRecordInjectMessage, onHandleInjectToRoundtable,
onSubmitClarification, onSubmitClarification,
onSavePersona, onSavePersona,
onProceedToStep3, onProceedToStep3,
@@ -134,24 +140,20 @@ export function Step2Panel(props: Step2PanelProps) {
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]); useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
// 干预研审 → 暂停会商:与右上角按钮共用 onPauseRoundtable;乐观 UI 避免等父 state 刷新。 const resolveAgentName = useCallback(
const [pauseFlash, setPauseFlash] = useState(false); (idOrName: string) =>
const [optimisticPaused, setOptimisticPaused] = useState(false); agentNameToRole[idOrName.toLowerCase()]?.displayName ?? idOrName,
const showPausedUi = !isRoundTableRunning || optimisticPaused; [agentNameToRole],
);
useEffect(() => { // 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
if (!isRoundTableRunning) { const [pauseFlash, setPauseFlash] = useState(false);
setOptimisticPaused(false);
}
}, [isRoundTableRunning]);
const handleInject = useCallback(() => { const handleInject = useCallback(() => {
setOptimisticPaused(true); onHandleInjectToRoundtable();
onPauseRoundtable();
onRecordInjectMessage();
setPauseFlash(true); setPauseFlash(true);
window.setTimeout(() => setPauseFlash(false), 1600); window.setTimeout(() => setPauseFlash(false), 1600);
}, [onPauseRoundtable, onRecordInjectMessage]); }, [onHandleInjectToRoundtable]);
const isSandboxStreaming = const isSandboxStreaming =
isOrchestrating || isOrchestrating ||
@@ -196,7 +198,7 @@ export function Step2Panel(props: Step2PanelProps) {
</option> </option>
))} ))}
</select> </select>
{showPausedUi ? ( {!isRoundTableRunning ? (
<button <button
type="button" type="button"
onClick={onResumeRoundtable} onClick={onResumeRoundtable}
@@ -255,6 +257,7 @@ export function Step2Panel(props: Step2PanelProps) {
<Step2Message <Step2Message
key={msg.id} key={msg.id}
msg={msg} msg={msg}
resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)} onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => { onFileCardClick={(path) => {
if (msg.agentThreadId) { if (msg.agentThreadId) {
@@ -354,10 +357,10 @@ export function Step2Panel(props: Step2PanelProps) {
<button <button
type="button" type="button"
onClick={handleInject} onClick={handleInject}
title="暂停会商并记录干预指令;点击右上角「恢复会商」继续执行" title="记录干预指令并立即恢复会商执行"
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10" className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
> >
干预研审 干预并发送
</button> </button>
<div className="h-6 w-[1px] bg-slate-200 shrink-0" /> <div className="h-6 w-[1px] bg-slate-200 shrink-0" />
@@ -406,6 +409,8 @@ export function Step2Panel(props: Step2PanelProps) {
*/ */
interface Step2MessageProps { interface Step2MessageProps {
msg: Step2Dialogue; msg: Step2Dialogue;
/** 把派活 chip 的 agent id 解析成中文席位名。 */
resolveAgentName?: (idOrName: string) => string;
onArtifactStepClick?: ( onArtifactStepClick?: (
step: NonNullable<Step2Dialogue["steps"]>[number], step: NonNullable<Step2Dialogue["steps"]>[number],
) => void; ) => void;
@@ -415,6 +420,7 @@ interface Step2MessageProps {
function Step2Message({ function Step2Message({
msg, msg,
resolveAgentName,
onArtifactStepClick, onArtifactStepClick,
onFileCardClick, onFileCardClick,
}: Step2MessageProps) { }: Step2MessageProps) {
@@ -445,6 +451,7 @@ function Step2Message({
steps={msg.steps} steps={msg.steps}
streaming={isStreaming} streaming={isStreaming}
onArtifactStepClick={onArtifactStepClick} onArtifactStepClick={onArtifactStepClick}
resolveAgentName={resolveAgentName}
/> />
)} )}
{reasoning && ( {reasoning && (
@@ -469,8 +476,13 @@ function Step2Message({
} }
/** /**
* 扫描所有 `tool_calling:present_files` 步骤里的 `filepaths`,按出现顺序去重。 * 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。
* 同一气泡可能多次 present_files(总控分批交付),都聚合到一行卡片里。 *
* 三类来源都算:
* - `present_files`:总控呈现文件,args.filepaths 是数组(可一次多个);
* - `write_file` / `str_replace`:子智能体被后端 excluded_tools 禁用了 present_files,
* 只能 write_file 写文件,单文件 path 在 args.path —— 也聚合进卡片,保证"凡是写了
* 文件就出可点的卡片",而不是只有 present_files 才出。
*/ */
function collectPresentedFiles( function collectPresentedFiles(
steps: Step2Dialogue["steps"], steps: Step2Dialogue["steps"],
@@ -478,14 +490,22 @@ function collectPresentedFiles(
if (!Array.isArray(steps) || steps.length === 0) return []; if (!Array.isArray(steps) || steps.length === 0) return [];
const out: string[] = []; const out: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
const add = (fp: unknown) => {
if (typeof fp !== "string" || !fp || seen.has(fp)) return;
seen.add(fp);
out.push(fp);
};
for (const step of steps) { for (const step of steps) {
if (step.key !== "tool_calling:present_files") continue; if (step.key === "tool_calling:present_files") {
const fps = step.args?.filepaths; const fps = step.args?.filepaths;
if (!Array.isArray(fps)) continue; if (Array.isArray(fps)) fps.forEach(add);
for (const fp of fps) { continue;
if (typeof fp !== "string" || !fp || seen.has(fp)) continue; }
seen.add(fp); if (
out.push(fp); step.key === "tool_calling:write_file" ||
step.key === "tool_calling:str_replace"
) {
add(step.args?.path);
} }
} }
return out; return out;
@@ -50,11 +50,12 @@ export function Step2SandboxLayout({
}: Step2SandboxLayoutProps) { }: Step2SandboxLayoutProps) {
const layoutRef = useRef<GroupImperativeHandle>(null); const layoutRef = useRef<GroupImperativeHandle>(null);
const { const {
artifacts, artifacts: rawArtifacts,
open: artifactsOpen, open: artifactsOpen,
setOpen: setArtifactsOpen, setOpen: setArtifactsOpen,
selectedArtifact, selectedArtifact,
} = useArtifacts(); } = useArtifacts();
const artifacts = rawArtifacts ?? [];
const artifactPanelOpen = artifactsOpen; const artifactPanelOpen = artifactsOpen;
@@ -26,6 +26,7 @@ import {
type ThreadIdMap, type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent"; } from "@/roundtable-planning/api/multi-agent";
import { import {
DEFAULT_SELECTED_AGENTS,
MAX_CYCLES, MAX_CYCLES,
buildAgentNameToRole, buildAgentNameToRole,
buildRoleTemplates, buildRoleTemplates,
@@ -98,8 +99,18 @@ export type Step2Snapshot = {
seatStubMessages: Record<string, StubAiMessage[]>; seatStubMessages: Record<string, StubAiMessage[]>;
}; };
/** 席位 Persona 配置;初始为空,用户在 Persona 抽屉保存后写入。 */ /**
const DEFAULT_AGENT_CONFIGS: Record<number, AgentConfig> = {}; * 6 个默认席位的初始 Persona 配置。model 留空 → 运行时 `agentConfigs[seat]?.model
* || selectedModel || undefined`,即「席位覆盖优先,否则跟随全局模型」。
*/
const DEFAULT_AGENT_CONFIGS: Record<number, AgentConfig> = {
2: { model: "", weight: 90, temp: 0.2, prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。" },
3: { model: "", weight: 85, temp: 0.3, prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。" },
4: { model: "", weight: 88, temp: 0.1, prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。" },
5: { model: "", weight: 92, temp: 0.4, prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。" },
6: { model: "", weight: 95, temp: 0.1, prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。" },
7: { model: "", weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" },
};
export interface UseStep2OrchestrationOptions { export interface UseStep2OrchestrationOptions {
/** 当前页面 currentStep,===2 触发自动 init。 */ /** 当前页面 currentStep,===2 触发自动 init。 */
@@ -164,11 +175,11 @@ export interface UseStep2OrchestrationReturn {
pauseRoundtable: () => void; pauseRoundtable: () => void;
/** 与 Step2Panel「恢复会商」按钮相同 —— 恢复自动研讨(含待处理的干预指令)。 */ /** 与 Step2Panel「恢复会商」按钮相同 —— 恢复自动研讨(含待处理的干预指令)。 */
resumeRoundtable: () => void; resumeRoundtable: () => void;
/** 记录干预指令(不暂停);通常由 Step2Panel 在 pauseRoundtable 之后调用。 */ /** 记录干预指令(不暂停不恢复);通常由 Step2Panel 在 pauseRoundtable 之后调用。 */
recordInjectMessage: () => void; recordInjectMessage: () => void;
/** 中断所有 in-flight SSE(给新建任务 / 加载草稿用)。 */ /** 中断所有 in-flight SSE(给新建任务 / 加载草稿用)。 */
abortAllInFlight: () => void; abortAllInFlight: () => void;
/** @deprecated 请用 pauseRoundtable + recordInjectMessage;保留兼容组合调用。 */ /** 一键干预:记录指令 + 自动恢复会商 + 触发新一轮编排(替代 pause + record + resume 组合)。 */
handleInjectToRoundtable: () => void; handleInjectToRoundtable: () => void;
/** 总控澄清回复 —— 把回答拼到 prompt 里再起一轮编排。 */ /** 总控澄清回复 —— 把回答拼到 prompt 里再起一轮编排。 */
submitClarification: (rawAnswer: string) => void; submitClarification: (rawAnswer: string) => void;
@@ -223,7 +234,7 @@ export function useStep2Orchestration(
} = useArtifacts(); } = useArtifacts();
const artifactPathsRef = useRef<string[]>([]); const artifactPathsRef = useRef<string[]>([]);
useEffect(() => { useEffect(() => {
artifactPathsRef.current = artifacts; artifactPathsRef.current = artifacts ?? [];
}, [artifacts]); }, [artifacts]);
// 用户手动关掉沙箱后,autoOpen 会被 ArtifactsProvider 置 false —— 把它们打到 ref // 用户手动关掉沙箱后,autoOpen 会被 ArtifactsProvider 置 false —— 把它们打到 ref
// 上,让 openSeatVirtualArtifact 不用进 useCallback deps,避免每次 toggle 重建函数。 // 上,让 openSeatVirtualArtifact 不用进 useCallback deps,避免每次 toggle 重建函数。
@@ -242,7 +253,9 @@ export function useStep2Orchestration(
Record<string, StubAiMessage[]> Record<string, StubAiMessage[]>
>({}); >({});
const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({}); const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({});
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>([]); const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
DEFAULT_SELECTED_AGENTS,
);
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null); const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
const [coordinatorName, setCoordinatorName] = useState<string | null>(null); const [coordinatorName, setCoordinatorName] = useState<string | null>(null);
const [isInitializing, setIsInitializing] = useState<boolean>(false); const [isInitializing, setIsInitializing] = useState<boolean>(false);
@@ -421,6 +434,51 @@ export function useStep2Orchestration(
[], [],
); );
/**
* 反查某个产出文件是由哪个席位 thread 写入的。
*
* 总控(coordinator)常用 `present_files` 呈现「子智能体写的文件」,但文件物理上落在
* 子席位 thread 的 outputs 里,coordinator thread 并没有 —— 直接拿总控气泡的
* `agentThreadId`(=coordinator thread)走 HTTP 拉会 404(`Thread ... not found`)。
* 流式期间子席位 write_file 时,内容已缓存进 seatStubMessages(key=真实写入 thread)。
* 这里按 path 反查那条 stub,让关闭沙箱后再点文件卡也能复用虚拟预览(内存内容,
* 与流式期间体验一致,且不依赖后端 thread 是否仍可访问)。
*/
const findSeatArtifactSource = useCallback(
(
rawPath: string,
): { seatThreadId: string; messageId: string; toolCallId: string } | null => {
const target = rawPath.trim();
if (!target) return null;
const targetBase = target.split("/").pop() ?? target;
let basenameMatch:
| { seatThreadId: string; messageId: string; toolCallId: string }
| null = null;
for (const [seatThreadId, msgs] of Object.entries(
seatStubMessagesRef.current,
)) {
const msg = msgs?.[0];
if (!msg) continue;
for (const tc of msg.tool_calls) {
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
const p =
typeof (tc.args as { path?: unknown })?.path === "string"
? ((tc.args as { path?: string }).path as string)
: "";
if (!p) continue;
if (p === target) {
return { seatThreadId, messageId: msg.id, toolCallId: tc.id };
}
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
basenameMatch = { seatThreadId, messageId: msg.id, toolCallId: tc.id };
}
}
}
return basenameMatch;
},
[],
);
/** /**
* `openArtifactPreview` —— 走真实路径(present_files 等已落盘场景),走 HTTP API 加载。 * `openArtifactPreview` —— 走真实路径(present_files 等已落盘场景),走 HTTP API 加载。
* 沙箱 write_file 实时预览请用 `openSeatVirtualArtifact`。 * 沙箱 write_file 实时预览请用 `openSeatVirtualArtifact`。
@@ -428,7 +486,27 @@ export function useStep2Orchestration(
const openArtifactPreview = useCallback( const openArtifactPreview = useCallback(
(threadId: string, path: string, options?: { auto?: boolean }) => { (threadId: string, path: string, options?: { auto?: boolean }) => {
const normalized = path.trim(); const normalized = path.trim();
if (!normalized || !threadId) return; if (!normalized) return;
// present_files 呈现的文件多半是子智能体 write_file 写入的:真实内容/thread 在 stub 里。
// 先反查 stub 走虚拟预览(内存内容)—— 关闭沙箱后再点文件卡也能显示,且不受
// 总控 coordinator thread 在后端 artifacts 路由下 not found 的影响。
const seatSource = findSeatArtifactSource(normalized);
if (seatSource) {
const virtualUrl = buildWriteFileVirtualUrl(
normalized,
seatSource.messageId,
seatSource.toolCallId,
);
openSeatVirtualArtifact(
seatSource.seatThreadId,
virtualUrl,
options?.auto ?? false,
);
return;
}
if (!threadId) return;
setActiveArtifactThreadId(threadId); setActiveArtifactThreadId(threadId);
const prev = artifactPathsRef.current; const prev = artifactPathsRef.current;
@@ -449,7 +527,14 @@ export function useStep2Orchestration(
openPanel(); openPanel();
} }
}, },
[selectArtifact, setArtifacts, setArtifactsOpen], [
findSeatArtifactSource,
buildWriteFileVirtualUrl,
openSeatVirtualArtifact,
selectArtifact,
setArtifacts,
setArtifactsOpen,
],
); );
const openArtifactFromStep = useCallback( const openArtifactFromStep = useCallback(
@@ -472,6 +557,16 @@ export function useStep2Orchestration(
openSeatVirtualArtifact(seatThreadId, virtualUrl, false); openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
return; return;
} }
if (toolName === "read_file") {
// 验证类「查看文件…」步骤:用 args.path 在沙箱预览该文件。
// openArtifactPreview 内部 findSeatArtifactSource 优先按 path 命中流式期间
// write_file 缓存的内容(虚拟预览);未命中再走 seat thread 的 outputs HTTP。
const path = step.args?.path;
if (typeof path === "string" && path) {
openArtifactPreview(seatThreadId, path);
}
return;
}
if (toolName === "present_files") { if (toolName === "present_files") {
const filepaths = step.args?.filepaths; const filepaths = step.args?.filepaths;
if (Array.isArray(filepaths)) { if (Array.isArray(filepaths)) {
@@ -1166,13 +1261,41 @@ export function useStep2Orchestration(
confidence: "最高指令", confidence: "最高指令",
}); });
pendingInjectPromptRef.current = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`; pendingInjectPromptRef.current = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
triggerToast("干预指令已记录,点击「恢复会商」后执行"); triggerToast("干预指令已记录,会商已自动恢复执行");
}, [appendDialogue, customAgentInput, triggerToast]); }, [appendDialogue, customAgentInput, triggerToast]);
const handleInjectToRoundtable = useCallback(() => { const handleInjectToRoundtable = useCallback(() => {
pauseRoundtable(); const userText = customAgentInput.trim();
recordInjectMessage(); if (!userText) return;
}, [pauseRoundtable, recordInjectMessage]); if (!threadIdsRef.current || !coordinatorNameRef.current) {
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
return;
}
// 中止当前编排(如有)
orchestrationCancelRef.current = true;
abortAllInFlight();
// 强制标记非编排中,使后续 runOrchestration 可立即启动
isOrchestratingRef.current = false;
setIsOrchestrating(false);
setCustomAgentInput("");
appendDialogue({
sender: "用户 (插话干预)",
avatarType: "human",
content: `💡 (人工指令介入) ${userText}`,
confidence: "最高指令",
});
const prompt = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
// 确保处于运行状态(同步更新按钮 UI)
setIsRoundTableRunning(true);
triggerToast("干预指令已发送,会商继续执行");
// 直接启动新一轮编排
void runOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
}, [customAgentInput, appendDialogue, abortAllInFlight, runOrchestration, triggerToast]);
const submitClarification = useCallback( const submitClarification = useCallback(
(rawAnswer: string) => { (rawAnswer: string) => {
@@ -24,7 +24,23 @@ export type RoleEntry = {
/** 用户在推荐弹窗选定的一个研讨席位。 */ /** 用户在推荐弹窗选定的一个研讨席位。 */
export type SelectedAgent = { agent_id: string; name: string }; export type SelectedAgent = { agent_id: string; name: string };
/** 研讨席位的头像配色轮换池。 */ /**
* 6 个内置 roundtable 席位的展示元数据。用户保留默认席位时,
* 头像 / 配色 / 中文显示名都按这里走;非内置(用户自建)走 FALLBACK_AVATAR_TYPES。
*/
export const BUILTIN_AGENT_META: Record<
string,
{ avatarType: string; displayName: string }
> = {
"roundtable-intelligence": { avatarType: "purple", displayName: "情报收集" },
"roundtable-environment": { avatarType: "emerald", displayName: "环境评估" },
"roundtable-solution-design": { avatarType: "human", displayName: "方案设计" },
"roundtable-risk-review": { avatarType: "amber", displayName: "风险审查" },
"roundtable-execution-plan": { avatarType: "pink", displayName: "执行规划" },
"roundtable-summary": { avatarType: "blue", displayName: "总结陈述" },
};
/** 非内置 agent 的头像配色轮换池。 */
export const FALLBACK_AVATAR_TYPES = [ export const FALLBACK_AVATAR_TYPES = [
"blue", "blue",
"purple", "purple",
@@ -33,12 +49,28 @@ export const FALLBACK_AVATAR_TYPES = [
"pink", "pink",
] as const; ] as const;
export function getAvatarTypeFor(_agentId: string, index: number): string { /**
return FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length]; * 用户未确认推荐弹窗时,Step 2 默认使用的 6 个内置席位。
* 保留默认时页面与原硬编码行为完全一致。
*/
export const DEFAULT_SELECTED_AGENTS: SelectedAgent[] = [
{ agent_id: "roundtable-summary", name: "总结陈述" },
{ agent_id: "roundtable-intelligence", name: "情报收集" },
{ agent_id: "roundtable-environment", name: "环境评估" },
{ agent_id: "roundtable-solution-design", name: "方案设计" },
{ agent_id: "roundtable-risk-review", name: "风险审查" },
{ agent_id: "roundtable-execution-plan", name: "执行规划" },
];
export function getAvatarTypeFor(agentId: string, index: number): string {
return (
BUILTIN_AGENT_META[agentId]?.avatarType ??
FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length]
);
} }
export function getDisplayNameFor(agent: SelectedAgent): string { export function getDisplayNameFor(agent: SelectedAgent): string {
return agent.name; return BUILTIN_AGENT_META[agent.agent_id]?.displayName ?? agent.name;
} }
/** /**
@@ -103,14 +135,187 @@ export function newCoordinatorName(): string {
} }
/** /**
* 后端 intent agent 输出的 `[INTENT_READY]\n\`\`\`json {...}\`\`\`` 块。 * 后端 intent agent 输出的**标准** `[INTENT_READY]\n\`\`\`json {...}\`\`\`` 块形状。
* 前端用同样形状的正则把摘要从对话气泡里抽出来,渲染成绿色摘要卡片。 * 仅作格式参考 / 快速匹配用;实际从气泡正文里抽摘要走 `splitIntentReadyBlock` 的
* 容错提取(marker 定位 + 括号配对 + 多级降级 parse),不再依赖这条严格正则。
*/ */
export const INTENT_READY_RE = /\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/; export const INTENT_READY_RE = /\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/;
/**
* 把 JSON 文本里**字符串值内部**的字面控制字符(换行 / 回车 / 制表符)转义成合法的
* `\n` / `\r` / `\t`。模型偶尔会在 summary 的字符串值里直接敲换行,产出的 JSON 不合法,
* `JSON.parse` 会抛 `Bad control character in string literal`。这里只在字符串内替换,
* 不动结构性空白,避免破坏本来就合法的 JSON。
*/
function sanitizeJsonControlChars(raw: string): string {
let out = "";
let inString = false;
let escaped = false;
for (const ch of raw) {
if (escaped) {
out += ch;
escaped = false;
continue;
}
if (ch === "\\") {
out += ch;
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
out += ch;
continue;
}
if (inString) {
if (ch === "\n") {
out += "\\n";
continue;
}
if (ch === "\r") {
out += "\\r";
continue;
}
if (ch === "\t") {
out += "\\t";
continue;
}
}
out += ch;
}
return out;
}
/**
* 从 `fromIndex` 起找第一个 `{`,按花括号配对(跳过字符串内部的括号)找到匹配的 `}`,
* 返回 JSON 子串及其在原文里的起止下标。
*
* 比 `\{[\s\S]*?\}` 这种正则更抗模型跑偏:容忍 JSON 内嵌套对象、字符串里的裸换行 /
* 花括号,以及模型漏写 ```json 代码围栏(直接 `[INTENT_READY] {...}`)的情况。
*/
function extractBalancedJson(
text: string,
fromIndex: number,
): { json: string; end: number } | null {
const start = text.indexOf("{", fromIndex);
if (start < 0) return null;
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < text.length; i += 1) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === "{") depth += 1;
else if (ch === "}") {
depth -= 1;
if (depth === 0) return { json: text.slice(start, i + 1), end: i + 1 };
}
}
return null; // 括号未闭合(流式中段 / 模型截断)
}
/** 去掉对象 / 数组里 `}` `]` 之前的尾随逗号(只在字符串外处理)。 */
function stripTrailingCommas(raw: string): string {
let out = "";
let inString = false;
let escaped = false;
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (escaped) {
out += ch;
escaped = false;
continue;
}
if (ch === "\\") {
out += ch;
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
out += ch;
continue;
}
if (!inString && ch === ",") {
let j = i + 1;
while (j < raw.length && /\s/.test(raw[j])) j += 1;
if (raw[j] === "}" || raw[j] === "]") continue; // 丢弃尾随逗号
}
out += ch;
}
return out;
}
/** 把任意值归一成 string[]:数组逐项转字符串、单个字符串包成单元素、其它视为空。 */
function toStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((x) => (typeof x === "string" ? x : x == null ? "" : String(x)))
.filter((x) => x.trim().length > 0);
}
if (typeof value === "string" && value.trim().length > 0) return [value];
return [];
}
/**
* 把解析出的对象归一成 IntentSummary。只要 `objective` 是非空字符串就认。
* constraints / assumptions 容忍模型写成字符串 / null / 缺失,统一归到 string[]。
*/
function normalizeIntentSummary(parsed: unknown): IntentSummary | null {
if (!parsed || typeof parsed !== "object") return null;
const obj = parsed as Record<string, unknown>;
const objective =
typeof obj.objective === "string" ? obj.objective.trim() : "";
if (!objective) return null;
return {
objective,
constraints: toStringArray(obj.constraints),
assumptions: toStringArray(obj.assumptions),
};
}
/**
* 解析 INTENT_READY 的 JSON 块。内网模型输出不可控,这里逐级降级重试:
* 1) 原文直接 parse
* 2) 转义字符串内的裸控制字符(换行 / 制表符)后再 parse
* 3) 再去掉尾随逗号后 parse
* 三次都失败才放弃。任一成功都过 `normalizeIntentSummary` 兜字段类型。
*/
function parseIntentSummary(raw: string): IntentSummary | null {
const sanitized = sanitizeJsonControlChars(raw);
const candidates = [raw, sanitized, stripTrailingCommas(sanitized)];
for (const candidate of candidates) {
try {
const summary = normalizeIntentSummary(JSON.parse(candidate));
if (summary) return summary;
} catch {
// 下一个候选
}
}
return null;
}
const INTENT_READY_MARKER_RE = /\[INTENT_READY\]/i;
/** /**
* 把含 [INTENT_READY] 标记的气泡正文拆成前 / 摘要 / 后三段。 * 把含 [INTENT_READY] 标记的气泡正文拆成前 / 摘要 / 后三段。
* 摘要 JSON 格式异常时整体保留在 `before`,用户仍能看到原文。 * 摘要 JSON 格式异常时整体保留在 `before`,用户仍能看到原文。
*
* 提取不再依赖严格的 ```json 围栏正则:先定位 [INTENT_READY](大小写不敏感),
* 再用括号配对抓出后面的第一个完整 JSON 对象,最后多级降级 parse。这样模型漏写
* 代码围栏、JSON 里有裸换行 / 尾随逗号、字段类型写偏等情况都能尽量恢复。
*/ */
export function splitIntentReadyBlock(text: string): { export function splitIntentReadyBlock(text: string): {
before: string; before: string;
@@ -118,20 +323,16 @@ export function splitIntentReadyBlock(text: string): {
after: string; after: string;
} { } {
if (!text) return { before: text, summary: null, after: "" }; if (!text) return { before: text, summary: null, after: "" };
const m = INTENT_READY_RE.exec(text); const marker = INTENT_READY_MARKER_RE.exec(text);
if (!m) return { before: text, summary: null, after: "" }; if (!marker) return { before: text, summary: null, after: "" };
let summary: IntentSummary | null = null; const block = extractBalancedJson(text, marker.index + marker[0].length);
try { if (!block) return { before: text, summary: null, after: "" };
const parsed = JSON.parse(m[1]); const summary = parseIntentSummary(block.json);
if (parsed && typeof parsed === "object" && typeof parsed.objective === "string") { if (!summary) return { before: text, summary: null, after: "" };
summary = parsed as IntentSummary;
}
} catch {
return { before: text, summary: null, after: "" };
}
return { return {
before: text.slice(0, m.index).trimEnd(), before: text.slice(0, marker.index).trimEnd(),
summary, summary,
after: text.slice(m.index + m[0].length).trimStart(), // 丢掉 JSON 块之后残留的代码围栏 ``` 与模型多说的话(SOUL 要求 READY 后即停)。
after: text.slice(block.end).replace(/^\s*```+/, "").trimStart(),
}; };
} }
@@ -23,6 +23,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought"; import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought";
import { CodeBlock } from "@/components/ai-elements/code-block";
import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble";
/** /**
* 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。 * 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。
@@ -34,6 +36,7 @@ import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-th
export function renderStepArgs( export function renderStepArgs(
key: string, key: string,
args?: Record<string, unknown>, args?: Record<string, unknown>,
resolveAgentName?: (idOrName: string) => string,
): React.ReactNode { ): React.ReactNode {
if (!args) return null; if (!args) return null;
const [, toolName] = key.split(":"); const [, toolName] = key.split(":");
@@ -43,15 +46,18 @@ export function renderStepArgs(
const agentName = typeof args.agent_name === "string" ? args.agent_name : ""; const agentName = typeof args.agent_name === "string" ? args.agent_name : "";
const task = typeof args.task === "string" ? args.task : ""; const task = typeof args.task === "string" ? args.task : "";
if (!agentName && !task) return null; if (!agentName && !task) return null;
// 派活 chip 显示中文席位名而非 agent id(客户自建 agent 的 id 常是一串 hex)。
const displayName =
agentName && resolveAgentName ? resolveAgentName(agentName) : agentName;
return ( return (
<> <>
{agentName && ( {displayName && (
<ChainOfThoughtSearchResult>{agentName}</ChainOfThoughtSearchResult> <ChainOfThoughtSearchResult>{displayName}</ChainOfThoughtSearchResult>
)} )}
{task && ( {task && (
<span className="block text-[11px] text-slate-600 line-clamp-3 mt-0.5"> <div className="mt-1 max-h-64 overflow-y-auto rounded-md border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-600 leading-relaxed">
{task} <MarkdownContent>{task}</MarkdownContent>
</span> </div>
)} )}
</> </>
); );
@@ -75,9 +81,14 @@ export function renderStepArgs(
case "bash": { case "bash": {
const command = typeof args.command === "string" ? args.command : ""; const command = typeof args.command === "string" ? args.command : "";
return command ? ( return command ? (
<code className="block bg-slate-100 rounded px-2 py-1 text-[11px] font-mono text-slate-800 mt-0.5 line-clamp-2 break-all"> <div className="mt-1 max-h-60 overflow-auto rounded-md border border-slate-200 [&_pre]:px-2.5! [&_pre]:py-1.5!">
{command} <CodeBlock
</code> className="rounded-none border-none"
showLineNumbers={false}
language="bash"
code={command}
/>
</div>
) : null; ) : null;
} }
case "write_todos": { case "write_todos": {
@@ -62,6 +62,7 @@ import {
import { useStep2Orchestration } from "@/roundtable-planning/hooks/useStep2Orchestration"; import { useStep2Orchestration } from "@/roundtable-planning/hooks/useStep2Orchestration";
import { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence"; import { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence";
import { import {
BUILTIN_AGENT_META,
getAvatarTypeFor, getAvatarTypeFor,
getDisplayNameFor, getDisplayNameFor,
makeRolesFromTemplates, makeRolesFromTemplates,
@@ -259,7 +260,8 @@ export default function RoundtablePlanningPage() {
(agents: Array<{ agent_id: string; name: string }>) => { (agents: Array<{ agent_id: string; name: string }>) => {
const normalized: SelectedAgent[] = agents.map(({ agent_id, name }) => ({ const normalized: SelectedAgent[] = agents.map(({ agent_id, name }) => ({
agent_id, agent_id,
name: name ?? agent_id, // 内置 agent 走 BUILTIN_AGENT_META;非内置用候选池传过来的 name。
name: BUILTIN_AGENT_META[agent_id]?.displayName ?? name ?? agent_id,
})); }));
step2.setSelectedAgents(normalized); step2.setSelectedAgents(normalized);
step2.resetInitGate(); step2.resetInitGate();
@@ -486,6 +488,7 @@ export default function RoundtablePlanningPage() {
agentConfigs={step2.agentConfigs} agentConfigs={step2.agentConfigs}
selectedAgentId={step2.selectedAgentId} selectedAgentId={step2.selectedAgentId}
roles={roles} roles={roles}
agentNameToRole={step2.agentNameToRole}
selectedModel={selectedModel} selectedModel={selectedModel}
availableModels={availableModels} availableModels={availableModels}
modelsLoading={modelsLoading} modelsLoading={modelsLoading}
@@ -496,7 +499,7 @@ export default function RoundtablePlanningPage() {
openArtifactPreview={step2.openArtifactPreview} openArtifactPreview={step2.openArtifactPreview}
onPauseRoundtable={step2.pauseRoundtable} onPauseRoundtable={step2.pauseRoundtable}
onResumeRoundtable={step2.resumeRoundtable} onResumeRoundtable={step2.resumeRoundtable}
onRecordInjectMessage={step2.recordInjectMessage} onHandleInjectToRoundtable={step2.handleInjectToRoundtable}
setClarificationDraft={step2.setClarificationDraft} setClarificationDraft={step2.setClarificationDraft}
setCustomAgentInput={step2.setCustomAgentInput} setCustomAgentInput={step2.setCustomAgentInput}
setAgentConfigs={step2.setAgentConfigs} setAgentConfigs={step2.setAgentConfigs}
@@ -893,7 +896,9 @@ function LeftSidebar({
...selectedAgents.map((agent, i) => ({ ...selectedAgents.map((agent, i) => ({
id: agent.agent_id, id: agent.agent_id,
name: getDisplayNameFor(agent), name: getDisplayNameFor(agent),
role: "圆桌席位", role: BUILTIN_AGENT_META[agent.agent_id]
? `${BUILTIN_AGENT_META[agent.agent_id].displayName} 席位`
: "圆桌席位",
icon: getAvatar(getAvatarTypeFor(agent.agent_id, i), "h-7 w-7"), icon: getAvatar(getAvatarTypeFor(agent.agent_id, i), "h-7 w-7"),
})), })),
].map((contrib, idx) => ( ].map((contrib, idx) => (
@@ -0,0 +1,166 @@
import { Outlet } from 'react-router-dom';
export interface SceneInfo {
applicableTask: string;
createTime: string;
deleted: number;
id: number;
relatedAgent: string;
sceneCode: string;
sceneIntro: string;
sceneName: string;
sceneType: string;
status: number;
updateTime: string;
version: string;
}
export interface TaskListItem {
createBy: number;
createTime: string;
deptId: null;
id: number;
isTop: number;
issuingDept: string;
keywords: null;
overview: string;
planTaskTop: number;
platform: null;
ptaskId: null;
recommendedBasis: null;
scene: string; // 或 SceneInfo,如果你已经解析
sourceType: number;
step: null;
taskContent: string;
taskDirection: string;
taskEndDate: string;
taskLevel: number;
taskName: string;
taskNo: string;
taskStartDate: string;
taskStatus: number;
taskSuperiorFlag: number;
taskTimeLimit: null;
taskType: number;
column: string;
updateBy: number;
updateTime: string;
endCount: number;
sumAll: number;
progress: number;
taskStatusData: any;
}
export interface ActionListItem {
actionDay: number;
actionEndDate: string;
actionManager: number;
actionName: string;
actionNo: any;
actionStartDate: string;
actionStatus: number;
actionTerm: number;
actionTitle: any;
actorName: any;
audienceGroup: any
audienceGroupId: any;
audienceRecReason: any
behavior: any
createBy: number
createTime: string;
deleteFlag: number;
deptId: any;
deptName: string;
drivingFactor: any;
expectedBehaviorId: any;
id: number
keywords: string
level: number
scene: string
sourceType: number
step: null
taskId: number
updateBy: number
updateTime: number
useActionId: null
}
export interface actionColumnItem {
icon: string;
columnName: string;
actionList: ActionListItem[]
}
export const taskStatusList = {
'0': {
label: '待签收',
color: '#0062d9',
icon: '待签收.svg'
},
'10': {
label: '待分析',
color: '#2f9acc',
icon: '待CEH.svg'
},
'20': {
label: '分析中',
color: '#d97b00',
icon: '进行中.svg'
},
'25': {
label: '分析完成已提交',
color: '#1eb07f',
icon: '完成.svg'
},
'30': {
label: '已分析待审核',
color: '#1eb07f',
icon: '完成.svg'
},
'40': {
label: '分析审核通过',
color: '#1eb07f',
icon: '完成.svg'
},
'50': {
label: '分析审核未通过',
color: '#cf1322',
icon: '未通过.svg'
},
'60': {
label: '行动已下发待签收',
color: '#1eb07f',
icon: '完成.svg'
},
'70': {
label: '行动已签收待策划',
color: '#1eb07f',
icon: '完成.svg'
},
'80': {
label: '行动策划已提交',
color: '#1eb07f',
icon: '完成.svg'
},
'100': {
label: '作战方案待审核',
color: '#1eb07f',
icon: '完成.svg'
},
'110': {
label: '作战方案审核通过',
color: '#1eb07f',
icon: '完成.svg'
},
'120': {
label: '作战方案未通过',
color: '#cf1322',
icon: '未通过.svg'
}
};
export default function TaskManagement() {
return (
<div className="h-full flex flex-col">
<Outlet />
</div>
);
}
@@ -0,0 +1,23 @@
import { Outlet, useNavigate } from 'react-router-dom';
import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
const userInfo = localStorage.getItem('userInfo');
const xkLinkUrl = import.meta.env.VITE_AIPI_XKLINKTO_URL;
interface TaskPgFromProps {
gopath: string
}
const TaskXkFrom: React.FC<TaskPgFromProps> = ({gopath}) => {
const [xkUrl, setXkUrl] = useState(xkLinkUrl);
useEffect(() => {
if(userInfo) {
const userData: any = JSON.parse(userInfo);
setXkUrl(`${xkLinkUrl}${gopath}?accessToken=${userData.accessToken}`);
}
}, [gopath]);
return (
<div className="h-full flex flex-col">
<iframe className="h-[calc(100vh-92px)]" src={xkUrl}></iframe>
</div>
);
}
export default TaskXkFrom
@@ -503,6 +503,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception: except Exception:
logger.exception("Skill ownership migration failed (non-fatal)") logger.exception("Skill ownership migration failed (non-fatal)")
# 一次性把 .deer-flow/agents/{id}/ 目录里历史遗留的 agent 导入 agents 表。
# 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB
# SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低
# /api/agents 列表延迟。
try:
from app.gateway.routers.agents import (
_mark_legacy_sync_fresh,
_sync_legacy_agents,
)
agent_store = getattr(app.state, "agent_store", None)
if agent_store is not None:
await _sync_legacy_agents(agent_store)
_mark_legacy_sync_fresh()
logger.info("Legacy folder-backed agents synced at startup")
except Exception:
logger.exception("Legacy agent sync failed at startup (non-fatal)")
# 启动 skill curator 调度器:常驻后台任务,每小时检查一次需不需要进化 # 启动 skill curator 调度器:常驻后台任务,每小时检查一次需不需要进化
# (per-user interval_hours 仍是真正决定要不要跑进化的闸)。 # (per-user interval_hours 仍是真正决定要不要跑进化的闸)。
try: try:
@@ -406,7 +406,7 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
> 以下两个示例覆盖**不同任务类型**,目的是教你「思路要呼应任务本身」,**不要**把任何一个示例的具体职能链当模板。 > 以下两个示例覆盖**不同任务类型**,目的是教你「思路要呼应任务本身」,**不要**把任何一个示例的具体职能链当模板。
### 示例 A:合规改造任务(领域候选池) ### 示例 A:合规改造任务(职能型候选池)
**用户输入**: **用户输入**:
``` ```
@@ -415,11 +415,12 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
关键假设:现有系统具备基础数字化能力;允许预定窗口停机 关键假设:现有系统具备基础数字化能力;允许预定窗口停机
候选智能体池: 候选智能体池:
- compliance-research (合规调研):检索同类历史案例、行业最佳实践与监管口径 - roundtable-intelligence (情报收集):检索同类历史案例、行业最佳实践与外部情报
- infra-capacity (基础设施评估):评估硬件容量与系统量化承载力 - roundtable-environment (环境评估):评估硬件容量与系统量化承载力
- solution-architect (方案架构):产出主备方案与关键路径 - roundtable-solution-design (方案设计):产出主备方案与关键路径
- risk-auditor (风险审查):合规红线检查与收敛建议 - roundtable-risk-review (风险审查):合规红线检查与收敛建议
- execution-planner (执行规划):里程碑与资源排布 - roundtable-execution-plan (执行规划):里程碑与资源排布
- roundtable-summary (总结陈述):归纳冲突标记与盖印
- my-cost-calc (成本测算):基于预算和资源单价计算总成本 - my-cost-calc (成本测算):基于预算和资源单价计算总成本
``` ```
@@ -430,10 +431,10 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
```json ```json
{ {
"picks": [ "picks": [
{"agent_id": "compliance-research", "reason": "检索同类等保 2.0 改造案例与监管口径,直接支撑本次合规对标"}, {"agent_id": "roundtable-intelligence", "reason": "检索同类等保 2.0 改造案例与监管口径,直接支撑本次合规对标"},
{"agent_id": "solution-architect", "reason": "在 6 个月工期约束下产出主备改造方案与关键路径"}, {"agent_id": "roundtable-solution-design", "reason": "在 6 个月工期约束下产出主备改造方案与关键路径"},
{"agent_id": "risk-auditor", "reason": "对照等保 2.0 合规红线审查方案,避免后期返工"}, {"agent_id": "roundtable-risk-review", "reason": "对照等保 2.0 合规红线审查方案,避免后期返工"},
{"agent_id": "execution-planner", "reason": "把改造拆成可在 6 个月内交付的里程碑与资源排布"}, {"agent_id": "roundtable-execution-plan", "reason": "把改造拆成可在 6 个月内交付的里程碑与资源排布"},
{"agent_id": "my-cost-calc", "reason": "校验整体方案落在 500 万元预算硬约束内"} {"agent_id": "my-cost-calc", "reason": "校验整体方案落在 500 万元预算硬约束内"}
] ]
} }
@@ -452,7 +453,7 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
- channel-planner (渠道规划师):按预算和受众设计跨平台投放组合 - channel-planner (渠道规划师):按预算和受众设计跨平台投放组合
- audience-insight (受众洞察):画像、关键词、情绪点研究 - audience-insight (受众洞察):画像、关键词、情绪点研究
- creative-copy (创意文案):产出落地页与社媒文案变体 - creative-copy (创意文案):产出落地页与社媒文案变体
- ad-compliance (广告合规):审查文案与素材合规风险 - roundtable-risk-review (风险审查):合规红线检查与收敛建议
``` ```
**你的输出**: **你的输出**:
@@ -470,8 +471,8 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
} }
``` ```
> **关键观察**:两个示例都从候选池里按任务语义挑选,**不要**套用固定职能链(如"调研→设计→风险→执行")。 > **关键观察**:示例 A 选了 `roundtable-intelligence / solution-design / execution-plan` 等"职能型"智能体,示例 B **完全没用任何 `roundtable-*` 内置**,而是从用户自建的领域智能体里挑。
> 候选池里的智能体能不能匹配任务,只看 description 和任务的实际语义。如果候选池里有"广告设计智能体"且任务是广告设计,**你必须挑它**,而不是机械复制示例 A 的链路。 > 这是因为:**候选池里的智能体能不能匹配任务,只看 description 和任务的实际语义,不看 id 是不是 `roundtable-` 开头**。如果候选池里有"广告设计智能体"且任务是广告设计,**你必须挑它**,而不是套用示例 A 的职能链。
""" """
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import shutil import shutil
import time
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@@ -207,6 +208,11 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
This preserves old deployments where agents already existed under This preserves old deployments where agents already existed under
``.deer-flow/agents/{name}`` before the agents table was introduced. ``.deer-flow/agents/{name}`` before the agents table was introduced.
Heavy: scans the agents directory + parses each ``config.yaml`` + issues a
SELECT-then-INSERT/UPDATE per agent. Call once at startup via the lifespan,
and rely on :func:`_maybe_sync_legacy_agents` (TTL-gated) inside request
handlers as a safety net for environments that bypassed the lifespan hook.
""" """
try: try:
for agent_cfg in list_custom_agents(): for agent_cfg in list_custom_agents():
@@ -225,6 +231,44 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
logger.exception("Failed to sync legacy folder-backed agents") logger.exception("Failed to sync legacy folder-backed agents")
# Throttle the per-request legacy-sync fallback. The real sync runs once at
# startup (lifespan); request handlers used to call ``_sync_legacy_agents`` on
# every hit, which scanned the filesystem + did N DB upserts per agent each
# time. After the first successful sync we skip the work for ``_LEGACY_SYNC_TTL_SECONDS``
# — long enough to be effectively "startup-only" in steady state, short enough
# that a freshly dropped agent directory gets picked up on the next bucket.
_LEGACY_SYNC_TTL_SECONDS = 3600.0
_legacy_sync_state: dict[str, Any] = {"last_at": 0.0, "lock": None}
async def _maybe_sync_legacy_agents(store: AgentStore) -> None:
"""TTL-gated wrapper around :func:`_sync_legacy_agents`.
Becomes a no-op in steady state — the heavy lifting happens in the lifespan
hook. Inside the TTL window we don't even take the lock; outside it we
serialize callers so a thundering herd doesn't fan out into N filesystem
scans + N*M DB queries.
"""
now = time.monotonic()
if now - float(_legacy_sync_state["last_at"]) < _LEGACY_SYNC_TTL_SECONDS:
return
lock = _legacy_sync_state["lock"]
if lock is None:
lock = asyncio.Lock()
_legacy_sync_state["lock"] = lock
async with lock:
# Re-check under the lock — a sibling coroutine may have just refreshed.
if time.monotonic() - float(_legacy_sync_state["last_at"]) < _LEGACY_SYNC_TTL_SECONDS:
return
await _sync_legacy_agents(store)
_legacy_sync_state["last_at"] = time.monotonic()
def _mark_legacy_sync_fresh() -> None:
"""Called by the lifespan hook after running :func:`_sync_legacy_agents`."""
_legacy_sync_state["last_at"] = time.monotonic()
def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None: def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None:
"""Lazily restore an agent's on-disk artifacts from its DB record. """Lazily restore an agent's on-disk artifacts from its DB record.
@@ -262,14 +306,29 @@ def _record_to_response(
record: dict[str, Any], record: dict[str, Any],
*, *,
include_soul: bool = False, include_soul: bool = False,
include_extras: bool = True,
) -> AgentResponse: ) -> AgentResponse:
_ensure_agent_dir_from_record(record) """Convert a DB row to an API response.
Args:
include_soul: Read SOUL.md from disk and embed in ``soul``. Set False
in list endpoints — the frontend (gallery / recommend / selector)
never reads ``soul``, only the edit dialog does. Skipping this
avoids one synchronous file read per agent.
include_extras: Self-heal a missing agent dir and read deprecated
``model`` / ``tool_groups`` / ``skills`` from ``config.yaml``. Set
False in list endpoints to skip a stat + yaml parse per agent —
those fields are unused by current frontends and the dir restore
can happen lazily when the agent is actually opened.
"""
soul: str | None = None soul: str | None = None
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None}
if include_extras:
_ensure_agent_dir_from_record(record)
extras = _load_config_extras(record["id"])
if include_soul: if include_soul:
soul = load_agent_soul(record["id"]) or "" soul = load_agent_soul(record["id"]) or ""
extras = _load_config_extras(record["id"])
return AgentResponse( return AgentResponse(
id=record["id"], id=record["id"],
name=record["name"], name=record["name"],
@@ -288,6 +347,25 @@ def _record_to_response(
) )
async def _record_to_response_async(
record: dict[str, Any],
*,
include_soul: bool = False,
include_extras: bool = True,
) -> AgentResponse:
"""Async wrapper: when we actually touch disk, hop to a worker thread so
we don't block the asyncio loop. List endpoints (no soul, no extras) skip
this overhead and stay on the calling task."""
if not include_soul and not include_extras:
return _record_to_response(record, include_soul=False, include_extras=False)
return await asyncio.to_thread(
_record_to_response,
record,
include_soul=include_soul,
include_extras=include_extras,
)
async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) -> dict[str, Any]: async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) -> dict[str, Any]:
owned = await store.get_owned(agent_id, user_id) owned = await store.get_owned(agent_id, user_id)
if owned is not None: if owned is not None:
@@ -349,7 +427,7 @@ async def list_agents(
try: try:
store = get_agent_store(request) store = get_agent_store(request)
tag_store: TagStore = get_tag_store(request) tag_store: TagStore = get_tag_store(request)
await _sync_legacy_agents(store) await _maybe_sync_legacy_agents(store)
user_id = _current_user_id(request) user_id = _current_user_id(request)
if admin: if admin:
if not _is_admin_user(request): if not _is_admin_user(request):
@@ -357,7 +435,14 @@ async def list_agents(
agents = await store.list_all() agents = await store.list_all()
else: else:
agents = await store.list_visible(user_id) agents = await store.list_visible(user_id)
responses = [_record_to_response(a, include_soul=True) for a in agents] # List endpoint stays lean: skip per-agent SOUL.md read, config.yaml
# parse, and dir self-heal — none of those feed UI fields the gallery /
# recommend dialog / selector actually render. Detail endpoints (single
# agent + create/update responses) still load them.
responses = [
_record_to_response(a, include_soul=False, include_extras=False)
for a in agents
]
assignments = await tag_store.list_assignments("agent", [r.id for r in responses]) assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
favorite_ids = await store.list_favorite_ids(user_id) favorite_ids = await store.list_favorite_ids(user_id)
@@ -403,11 +488,18 @@ async def get_agent_selector(request: Request) -> AgentSelectorResponse:
_require_agents_api_enabled() _require_agents_api_enabled()
try: try:
store = get_agent_store(request) store = get_agent_store(request)
await _sync_legacy_agents(store) await _maybe_sync_legacy_agents(store)
user_id = _current_user_id(request) user_id = _current_user_id(request)
records = await store.list_featured(user_id) records = await store.list_featured(user_id)
return AgentSelectorResponse(agents=[_record_to_response(r) for r in records]) # Selector dropdown only renders name/description/featured_order — same
# rationale as list_agents: skip soul + extras + self-heal.
return AgentSelectorResponse(
agents=[
_record_to_response(r, include_soul=False, include_extras=False)
for r in records
]
)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -427,12 +519,15 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
try: try:
store = get_agent_store(request) store = get_agent_store(request)
await _sync_legacy_agents(store) await _maybe_sync_legacy_agents(store)
user_id = _current_user_id(request) user_id = _current_user_id(request)
record = await store.get_visible(agent_id, user_id) record = await store.get_visible(agent_id, user_id)
if record is None: if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
response = _record_to_response(record, include_soul=True) # Detail view legitimately reads SOUL.md + config.yaml — hop to a
# worker thread so the asyncio loop stays responsive under concurrent
# detail fetches.
response = await _record_to_response_async(record, include_soul=True, include_extras=True)
favorite_ids = await store.list_favorite_ids(user_id) favorite_ids = await store.list_favorite_ids(user_id)
response.is_favorite = agent_id in favorite_ids response.is_favorite = agent_id in favorite_ids
return response return response
@@ -488,7 +583,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
} }
) )
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir) logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
return _record_to_response(record, include_soul=True) return await _record_to_response_async(record, include_soul=True, include_extras=True)
except HTTPException: except HTTPException:
raise raise
@@ -569,7 +664,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
logger.info("Updated agent '%s' (%s)", record["name"], agent_id) logger.info("Updated agent '%s' (%s)", record["name"], agent_id)
return _record_to_response(record, include_soul=True) return await _record_to_response_async(record, include_soul=True, include_extras=True)
except HTTPException: except HTTPException:
raise raise
@@ -744,7 +839,7 @@ async def set_agent_featured(
try: try:
store = get_agent_store(request) store = get_agent_store(request)
await _sync_legacy_agents(store) await _maybe_sync_legacy_agents(store)
record = await store.get_any(agent_id) record = await store.get_any(agent_id)
if record is None: if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
@@ -756,7 +851,7 @@ async def set_agent_featured(
if updated is None: if updated is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
logger.info("Set agent '%s' featured_order=%s by admin", agent_id, body.order) logger.info("Set agent '%s' featured_order=%s by admin", agent_id, body.order)
return _record_to_response(updated, include_soul=True) return await _record_to_response_async(updated, include_soul=True, include_extras=True)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -65,6 +65,7 @@ from fastapi.responses import StreamingResponse
# identical in connection handling, payload shape, and error mapping. # identical in connection handling, payload shape, and error mapping.
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
from app.gateway.routers.multi_agent import ( from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers, _auth_headers,
_create_thread, _create_thread,
_diagnose_exception, _diagnose_exception,
@@ -133,6 +134,70 @@ def _normalize_options(raw: object) -> tuple[str, ...]:
return tuple(sorted(o for o in out if o)) return tuple(sorted(o for o in out if o))
def _count_past_clarifications(messages: list[dict[str, Any]]) -> int:
"""统计当前 thread 内已经发出过的 ``ask_clarification`` 次数。
用来给 ``stream_intent`` 做"3 轮硬上限"兜底:
- 0-1 次:正常流程,模型自行决定继续问还是收敛
- 2 次(即将进入第 3 轮):prompt 追加系统提示,要求本轮必须 INTENT_READY
- ≥3 次:工具白名单移除 ``ask_clarification``,模型物理上无法再问
只数"已经成型"的 tool_call,不区分用户是否回答过——LangGraph 把 ask
工具的调用永久记录在 AIMessage.tool_calls 里,数它即可。
"""
if not isinstance(messages, list):
return 0
count = 0
for msg in messages:
if not isinstance(msg, dict):
continue
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
continue
for tc in msg.get("tool_calls") or []:
if (tc.get("name") or "").lower() == "ask_clarification":
count += 1
return count
async def _fetch_thread_messages(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
thread_id: str,
) -> list[dict[str, Any]]:
"""读取当前 thread checkpoint 的 messages 列表(best-effort)。
用 ASGITransport loopback 调 ``GET /api/threads/{tid}/state``,失败时返回
空列表——计数兜底是个"刹车",失败时退化成原有的纯 SOUL 自律行为,不阻断主流程。
"""
try:
resp = await client.get(f"{base}/api/threads/{thread_id}/state", headers=headers)
except Exception as exc:
logger.warning("intent: get thread state failed (%s) — falling back to soul-only flow", exc)
return []
if resp.status_code != 200:
logger.warning(
"intent: get thread state %s returned %s — falling back to soul-only flow",
thread_id,
resp.status_code,
)
return []
try:
payload = resp.json()
except Exception:
return []
values = payload.get("values") if isinstance(payload, dict) else None
if not isinstance(values, dict):
return []
messages = values.get("messages")
return messages if isinstance(messages, list) else []
# 硬上限:超过这个轮数,无论模型怎么想,都强制移除 ask_clarification 工具。
# 与 SOUL.md §0 「硬规则 2」对齐——"绝不存在第 4 轮"。
_MAX_CLARIFICATION_ROUNDS = 3
def _is_repeated_clarification( def _is_repeated_clarification(
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
*, *,
@@ -222,7 +287,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
# 看不到逐 token 增量。 # 看不到逐 token 增量。
try: try:
async with _make_loopback_client(request) as client: async with _make_loopback_client(request) as client:
thread_id = await _create_thread(client, base, headers) thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
except HTTPException: except HTTPException:
# 上游已用结构化方式失败,原样传给前端不需要二次包装。 # 上游已用结构化方式失败,原样传给前端不需要二次包装。
raise raise
@@ -279,33 +344,70 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。
async with _make_loopback_client(request) as client: async with _make_loopback_client(request) as client:
try: try:
# —— 3 轮硬上限兜底 ——
# 在发起 run 之前先读一次 thread state,数当前 ask_clarification 已发出次数:
# past=0/1 → 正常,不动 message,不动工具白名单
# past=2(本轮即将变成第 3 轮)→ 给消息加前缀,强提示"必须 INTENT_READY"
# past≥3 → 直接把 ask_clarification 也加进 excluded_tools,工具物理消失
# 这一步是个"刹车":SOUL 让模型自律,这里让系统兜底。
past_clarifications = _count_past_clarifications(
await _fetch_thread_messages(client, base, headers, thread_id)
)
# 1. ask_clarification 的工具白名单——超过硬上限就移除。
base_excluded_tools = [
"web_search",
"present_files",
"view_image",
"agent_orchestration",
"bash",
"ls",
"read_file",
"write_file",
"str_replace",
"memory",
"hindsight_recall",
"hindsight_reflect",
"hindsight_retain",
"task",
"write_todos",
]
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
base_excluded_tools.append("ask_clarification")
logger.info(
"intent stream: past_clarifications=%d >= %d, force-disabling "
"ask_clarification to break the loop",
past_clarifications,
_MAX_CLARIFICATION_ROUNDS,
)
# 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。
effective_message = message
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
effective_message = (
f"[系统强制] 已澄清 {past_clarifications} 轮,触发硬上限。"
f"本轮请直接输出 [INTENT_READY],缺的字段用 assumptions 兜底,"
f"绝对不要再调任何工具,也不要再询问任何问题。\n\n"
f"用户最新输入:{message}"
)
elif past_clarifications >= _MAX_CLARIFICATION_ROUNDS - 1:
effective_message = (
f"[系统提示] 已澄清 {past_clarifications} 轮,这是最后一轮。"
f"本轮请直接输出 [INTENT_READY],未明确的字段用 assumptions 兜底。\n\n"
f"用户最新输入:{message}"
)
body = _run_payload( body = _run_payload(
agent_name=INTENT_AGENT_ID, agent_name=INTENT_AGENT_ID,
model_name=model_name, model_name=model_name,
thread_id=thread_id, thread_id=thread_id,
new_message=message, new_message=effective_message,
# Intent agent only ever needs ask_clarification. Block # Intent agent only ever needs ask_clarification (and even that
# is yanked once past_clarifications hits the cap above). Block
# everything else so the model can't drift into solutioning # everything else so the model can't drift into solutioning
# (e.g. saving a design doc with write_file) — the model # (e.g. saving a design doc with write_file) — SOUL prose is
# tends to ignore SOUL prose, hard exclusion is reliable. # not reliable on its own, hard exclusion is.
# 仅保留 ask_clarification;其余工具见模块 docstring「工具白名单」。 excluded_tools=base_excluded_tools,
excluded_tools=[
"web_search",
"present_files",
"view_image",
"agent_orchestration",
"bash",
"ls",
"read_file",
"write_file",
"str_replace",
"memory",
"hindsight_recall",
"hindsight_reflect",
"hindsight_retain",
"task",
"write_todos",
],
skill_stop_names=[], skill_stop_names=[],
) )
@@ -338,7 +440,13 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
logger.warning("intent summary JSON parse failed: %s", exc) logger.warning("intent summary JSON parse failed: %s", exc)
summary = None summary = None
ready_text = msg_text # SOUL §0 硬规则 3 要求 ```json``` 反引号闭合后不得再输出任何字符,
# 但模型仍偶尔会在后面接 "那么我帮你列一下大纲……" 之类的尾巴。
# 终态帧只回到 marker 块结尾 + 一个换行,把废话从 content 里裁掉——
# 草稿持久化 / 跨设备重放 / 前端 splitIntentReadyBlock 的 after
# 段都因此干净。流式期间用户仍会逐 token 看到那段废话,前端
# Step1Panel 已经把 `parts.after` 隐藏掉,体验闭环。
ready_text = msg_text[: m.end()].rstrip() + "\n"
break break
if summary and ready_text: if summary and ready_text:
final_frame = { final_frame = {
@@ -53,18 +53,28 @@ import os
import re import re
import time import time
import traceback import traceback
import uuid
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
import httpx import httpx
from deerflow.config import get_app_config from deerflow.config import get_app_config
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.time import now_iso
from fastapi import APIRouter, Body, HTTPException, Request from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from langgraph.checkpoint.base import empty_checkpoint
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"]) router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
# 圆桌(intent / recommend / 席位 / 协调)创建的 thread 都是功能型内部线程,
# 不是用户的普通对话。打上这个 metadata,前端「最近的对话」列表(recent-chat-list.tsx)
# 据 `metadata.thread_type` 过滤掉,与 scheduler / bootstrap 系统会话同一套机制。
_ROUNDTABLE_THREAD_METADATA: dict[str, Any] = {"thread_type": "roundtable", "system": True}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 共享:异常诊断器(intent/recommend/multi-agent 共用) # 共享:异常诊断器(intent/recommend/multi-agent 共用)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -285,9 +295,22 @@ def _auth_headers(request: Request) -> dict[str, str]:
return {"Authorization": token, "Content-Type": "application/json"} return {"Authorization": token, "Content-Type": "application/json"}
async def _create_thread(client: httpx.AsyncClient, base: str, headers: dict[str, str]) -> str: async def _create_thread(
"""``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。""" client: httpx.AsyncClient,
resp = await client.post(f"{base}/api/threads", json={}, headers=headers) base: str,
headers: dict[str, str],
*,
metadata: dict[str, Any] | None = None,
) -> str:
"""``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。
``metadata`` 写入 thread 元数据,圆桌调用方传 ``_ROUNDTABLE_THREAD_METADATA``
把这些功能型线程标记成可被前端「最近的对话」过滤掉的系统会话。
"""
body: dict[str, Any] = {}
if metadata:
body["metadata"] = metadata
resp = await client.post(f"{base}/api/threads", json=body, headers=headers)
resp.raise_for_status() resp.raise_for_status()
return resp.json()["thread_id"] return resp.json()["thread_id"]
@@ -323,6 +346,181 @@ async def _create_agent(
return False, resp.text return False, resp.text
# ---------------------------------------------------------------------------
# 同进程直调 helpers(仅供 init_multi_agent 用,不要扩散到其它地方)
#
# init 阶段串了 N+1 个 thread create + N 个 GET /api/agents + 1 个 POST /api/agents,
# 用 HTTP loopback 串走每次都得 TCP + 鉴权中间件 + Pydantic 反序列化一遍。每个
# 调用 10-30ms,加起来对前端就是「点进 Step 2 后席位灯一颗一颗亮」的卡顿来源。
#
# 这里把这三个调用改成直接吃 app.state 里的 store/checkpointer —— 跑在同一个事
# 件循环里,省掉 TCP + 序列化 + 中间件链。鉴权 / 校验 / 文件副作用按需在直调
# helper 里显式做掉,不要"绕过"任何一项。
#
# 设计边界:这里只覆盖 init 阶段。SSE 长连接(/runs/stream / state)留在 HTTP
# loopback,因为它们本就是别的进程模型(LangGraph 子树),抽出来风险大收益小。
# ---------------------------------------------------------------------------
def _resolve_user_id(request: Request) -> str:
"""与 routers/agents._current_user_id 完全等价,直调路径里用。"""
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
async def _create_thread_direct(request: Request, *, metadata: dict[str, Any] | None = None) -> str:
"""同进程版的 ``POST /api/threads``。
完全镜像 ``routers/threads.create_thread`` 的写盘动作(thread_meta +
空 checkpoint),但跳过 TCP + Pydantic 反序列化。仅供 init 阶段批量建席位
线程用,失败时抛 HTTPException 由 SSE 帧上报。
``metadata`` 写入 thread 元数据;init 传 ``_ROUNDTABLE_THREAD_METADATA`` 把
席位 / 协调线程标记成系统会话,从前端「最近的对话」里隐藏。
"""
from app.gateway.deps import get_checkpointer, get_thread_store
checkpointer = get_checkpointer(request)
thread_store = get_thread_store(request)
thread_id = str(uuid.uuid4())
now = now_iso()
try:
await thread_store.create(thread_id, assistant_id=None, metadata=metadata or {})
except Exception:
logger.exception("[init-direct] thread_meta create failed for %s", thread_id)
raise HTTPException(status_code=500, detail="Failed to create thread")
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ckpt_metadata = {
"step": -1,
"source": "input",
"writes": None,
"parents": {},
"created_at": now,
}
try:
await checkpointer.aput(config, empty_checkpoint(), ckpt_metadata, {})
except Exception:
logger.exception("[init-direct] checkpoint create failed for %s", thread_id)
raise HTTPException(status_code=500, detail="Failed to create thread")
return thread_id
async def _get_agents_batch_direct(
request: Request,
agent_ids: list[str],
) -> dict[str, dict[str, Any]]:
"""同进程版的 N 个并行 ``GET /api/agents/{id}`` 合并为 1 次 DB 查询。
返回 ``{agent_id: {id, name, description}}``。**严格模式**:任一席位对当前
用户不可见就抛 404 —— 与原 HTTP loopback 的 ``_get_agent`` 行为一致(原路径
单查 404 会 raise,在外层被 except 捕获并 yield error 帧),不要悄悄回退。
"""
from app.gateway.deps import get_agent_store
store = get_agent_store(request)
user_id = _resolve_user_id(request)
records = await store.list_by_ids(agent_ids, user_id)
out: dict[str, dict[str, Any]] = {}
for r in records:
rid = str(r.get("id") or "").lower()
if not rid:
continue
out[rid] = {
"id": rid,
"name": str(r.get("name") or rid),
"description": str(r.get("description") or ""),
}
missing = [sid for sid in agent_ids if sid.lower() not in out]
if missing:
raise HTTPException(
status_code=404,
detail=f"Agent(s) not visible to current user: {', '.join(missing)}",
)
return out
async def _create_coordinator_direct(
request: Request,
*,
agent_id: str,
name: str,
description: str,
soul: str,
model: str | None,
skills: list[str] | None,
) -> tuple[bool, dict[str, Any] | str]:
"""同进程版的协调 agent 创建。
完整复刻 ``routers/agents.create_agent_endpoint`` 的关键副作用:
- ``agents_api.enabled`` master switch
- 显示名校验(空白则 422 → 这里转 (False, msg))
- 重复 id 检查(409 → (False, msg))
- 写 ``config.yaml`` + ``SOUL.md``(用 ``asyncio.to_thread`` 防同步 IO 阻塞)
- ``agent_store.create`` 入表
返回 ``(True, record)`` 或 ``(False, error_text)``;调用方决定要不要发 SSE error 帧。
"""
from app.gateway.deps import get_agent_store
from app.gateway.routers.agents import (
_require_agents_api_enabled,
_write_agent_config,
_write_agent_soul,
)
from deerflow.config.paths import get_paths
_require_agents_api_enabled()
normalized_name = (name or "").strip()
if not normalized_name:
return False, "Agent name cannot be empty"
store = get_agent_store(request)
if await store.get_any(agent_id) is not None:
return False, f"Agent id '{agent_id}' already exists"
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
return False, f"Agent id '{agent_id}' already exists"
try:
# config.yaml / SOUL.md 是同步 IO —— 全部丢线程池,不阻塞 init SSE 的发帧节奏。
await asyncio.to_thread(
_write_agent_config,
agent_id,
name=normalized_name,
description=description or "",
skills=skills,
model=model,
tool_groups=None,
)
await asyncio.to_thread(_write_agent_soul, agent_id, soul)
record = await store.create(
{
"id": agent_id,
"name": normalized_name,
"description": description or "",
"user_id": _resolve_user_id(request),
"published": False,
}
)
return True, record
except Exception as exc:
# 与 routers/agents 失败回滚一致 —— 文件已写出但 DB 失败时清掉 dir,避免下次
# `agent_dir.exists()` 误判为"已存在"导致后续 init 永远 409。
try:
if agent_dir.exists():
import shutil
await asyncio.to_thread(shutil.rmtree, agent_dir)
except Exception:
logger.exception("[init-direct] cleanup after coordinator create failure")
logger.exception("[init-direct] coordinator create failed")
return False, str(exc)
async def _append_thread_message( async def _append_thread_message(
client: httpx.AsyncClient, client: httpx.AsyncClient,
base: str, base: str,
@@ -456,6 +654,8 @@ def _run_payload(
new_message: str, new_message: str,
excluded_tools: list[str], excluded_tools: list[str],
skill_stop_names: list[str], skill_stop_names: list[str],
thinking_enabled: bool = True,
reasoning_effort: str = "medium",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。 """构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
@@ -463,6 +663,10 @@ def _run_payload(
- ``excluded_tools``:按路由裁剪工具面(intent 仅澄清、special 禁派活等)。 - ``excluded_tools``:按路由裁剪工具面(intent 仅澄清、special 禁派活等)。
- ``skill_stop_names``:leader 侧配合 SkillStopMiddleware 在 ``agent_orchestration`` - ``skill_stop_names``:leader 侧配合 SkillStopMiddleware 在 ``agent_orchestration``
调用后截断图,以便网关从 AIMessage.tool_calls 解析派活列表。 调用后截断图,以便网关从 AIMessage.tool_calls 解析派活列表。
- ``thinking_enabled`` / ``reasoning_effort``:默认开 CoT + medium,适合 Step 1 澄清
与子智能体交付这类需要推理的场景。**推荐弹窗与总控派活**是短决策任务,调用方
应传 ``thinking_enabled=False`` + ``reasoning_effort="low"``,省掉模型先吐
reasoning tokens 才开口的时间,显著降低前端"调度中"首字节延迟。
""" """
return { return {
"input": { "input": {
@@ -479,11 +683,17 @@ def _run_payload(
"agent_name": agent_name, "agent_name": agent_name,
"model_name": model_name, "model_name": model_name,
"mode": "pro", "mode": "pro",
"reasoning_effort": "medium", "reasoning_effort": reasoning_effort,
"thinking_enabled": True, "thinking_enabled": thinking_enabled,
"is_plan_mode": False, "is_plan_mode": False,
"subagent_enabled": False, "subagent_enabled": False,
"thread_id": thread_id, "thread_id": thread_id,
# 圆桌四个 router(intent / recommend / leader / special)都是临时任务,
# 用户个人记忆与本次研讨无关。MemoryMiddleware 的 Hindsight recall 是
# 同步阻塞 HTTP 调用(单次 timeout 15s),如果记忆服务慢/不通会让前端
# "调度中"卡很久;这里默认跳过,builtin USER.md/MEMORY.md 仍通过系统
# 提示注入不受影响。
"memory_recall_disabled": True,
}, },
"stream_mode": ["messages-tuple", "values"], "stream_mode": ["messages-tuple", "values"],
"stream_subgraphs": True, "stream_subgraphs": True,
@@ -534,111 +744,132 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
_validate_agent_id(name, "agent_names entry") _validate_agent_id(name, "agent_names entry")
_validate_agent_id(main_agent_name, "main_agent_name") _validate_agent_id(main_agent_name, "main_agent_name")
base = _loopback_base(request)
headers = _auth_headers(request)
def _frame(payload: dict[str, Any]) -> str: def _frame(payload: dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
async def stream_generator() -> AsyncIterator[str]: async def stream_generator() -> AsyncIterator[str]:
# TCP loopback 到 127.0.0.1:<gateway port>。client 配置见 _make_loopback_client: # init 阶段 fast path:全部走同进程直调(_create_thread_direct /
# trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。 # _get_agents_batch_direct / _create_coordinator_direct),不再走 HTTP
async with _make_loopback_client(request) as client: # loopback —— TCP + 鉴权中间件 + Pydantic 反序列化每次 10-30ms,N+1 个席位
try: # 累计就是"点进 Step 2 后席位一颗颗亮"的卡顿来源。
# Per-seat prepare task — same body as before, but we no longer try:
# await them with asyncio.gather. Instead, asyncio.as_completed t_init_start = time.perf_counter()
# surfaces them in REAL completion order so we can emit a # 1) 一次 DB 查询拿到所有席位的 name/description,替代之前的 N 个并行
# seat_ready frame the moment each finishes. # GET /api/agents/{id}。SOUL 拼接需要描述,这里一次性拿齐。
async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str, str]: agent_meta = await _get_agents_batch_direct(request, agent_names)
info = await _get_agent(client, base, headers, sub_id) t_meta_done = time.perf_counter()
display_name = str(info.get("name") or sub_id)
description = info.get("description", "") or ""
thread_id = await _create_thread(client, base, headers)
return sub_id.lower(), thread_id, display_name, description
tasks = [asyncio.create_task(_prepare_sub_agent(sid)) for sid in agent_names] # 2) 每个席位 thread 并行创建(直调 thread_store + checkpointer),
prepared: list[tuple[str, str, str, str]] = [] # 用 asyncio.as_completed 保持"先完先发 seat_ready"的体验。
try: async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str, str]:
for fut in asyncio.as_completed(tasks): thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
sid, thread_id, display_name, description = await fut meta = agent_meta.get(sub_id.lower(), {})
prepared.append((sid, thread_id, display_name, description)) return (
yield _frame({ sub_id.lower(),
"event": "seat_ready", thread_id,
"agent_name": sid, str(meta.get("name") or sub_id),
"thread_id": thread_id, str(meta.get("description") or ""),
"display_name": display_name,
"description": description,
})
except Exception:
# Cancel any still-pending sibling so we don't leak tasks
# past the AsyncClient close.
for t in tasks:
if not t.done():
t.cancel()
raise
agent_threads: dict[str, str] = {sid: thread_id for sid, thread_id, _, _ in prepared}
descriptions: list[str] = [
f"- agent_name: {sid}({display_name}):{description}"
for sid, _, display_name, description in prepared
]
# 动态 SOUL:把本次 init 的席位列表写进总控 prompt,供 agent_orchestration 派活。
soul = (
"#核心定位:\n"
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
"当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
"#调度方法:\n"
"通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活。"
"注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence,"
"或形如 02714cbe6e64406a8a4659f0e3d9c0b8 这样的 UUID),"
"不要把括号里的中文显示名拼进 agent_name 里。\n"
"#关键约束(必读):\n"
"1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,"
"如果某个子智能体已经针对你正要派的任务完成过交付("
"看到形如\"子智能体[名称]完成XX工作,交付内容为:...\"的 human 消息),"
"就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题。\n"
"2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,"
"你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),"
"让讨论收敛。\n"
"3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论。\n"
"#可调度智能体:\n"
+ "\n".join(descriptions)
) )
coord_thread = await _create_thread(client, base, headers) tasks = [asyncio.create_task(_prepare_sub_agent(sid)) for sid in agent_names]
agent_threads[main_agent_name] = coord_thread prepared: list[tuple[str, str, str, str]] = []
ok, result = await _create_agent( try:
client, for fut in asyncio.as_completed(tasks):
base, sid, thread_id, display_name, description = await fut
headers, prepared.append((sid, thread_id, display_name, description))
yield _frame({
"event": "seat_ready",
"agent_name": sid,
"thread_id": thread_id,
"display_name": display_name,
"description": description,
})
except Exception:
# Cancel any still-pending sibling so partially-prepared seats
# don't keep running past the stream close.
for t in tasks:
if not t.done():
t.cancel()
raise
t_seats_done = time.perf_counter()
agent_threads: dict[str, str] = {sid: thread_id for sid, thread_id, _, _ in prepared}
descriptions: list[str] = [
f"- agent_name: {sid}({display_name}):{description}"
for sid, _, display_name, description in prepared
]
# 动态 SOUL:把本次 init 的席位列表写进总控 prompt,供 agent_orchestration 派活。
soul = (
"#核心定位:\n"
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
"当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
"#调度方法:\n"
"通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活。"
"注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence,"
"或形如 02714cbe6e64406a8a4659f0e3d9c0b8 这样的 UUID),"
"不要把括号里的中文显示名拼进 agent_name 里。\n"
"#关键约束(必读):\n"
"1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,"
"如果某个子智能体已经针对你正要派的任务完成过交付("
"看到形如\"子智能体[名称]完成XX工作,交付内容为:...\"的 human 消息),"
"就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题。\n"
"2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,"
"你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),"
"让讨论收敛。\n"
"3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论。\n"
"#可调度智能体:\n"
+ "\n".join(descriptions)
)
# 3) coordinator thread + agent 并行创建。thread 与 agent 之间无依赖,
# asyncio.gather 比串行省一个直调往返的开销。
coord_thread_task = asyncio.create_task(
_create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
)
coord_agent_task = asyncio.create_task(
_create_coordinator_direct(
request,
agent_id=main_agent_name,
name=main_agent_name, name=main_agent_name,
description="你是总控控制协调智能体,负责协调其他智能体干活", description="你是总控控制协调智能体,负责协调其他智能体干活",
soul=soul,
model=model, model=model,
skills=["agent_orchestration"], skills=["agent_orchestration"],
soul=soul,
agent_id=main_agent_name,
) )
if not ok and isinstance(result, str) and "exists" not in result: )
yield _frame({"event": "error", "detail": f"create coordinator agent failed: {result}"}) coord_thread = await coord_thread_task
return agent_threads[main_agent_name] = coord_thread
ok, result = await coord_agent_task
if not ok and isinstance(result, str) and "exists" not in result:
yield _frame({"event": "error", "detail": f"create coordinator agent failed: {result}"})
return
t_coord_done = time.perf_counter()
logger.info(
"[init-direct] meta=%.3fs seats=%.3fs coord=%.3fs total=%.3fs seats_count=%d",
t_meta_done - t_init_start,
t_seats_done - t_meta_done,
t_coord_done - t_seats_done,
t_coord_done - t_init_start,
len(prepared),
)
yield _frame({ yield _frame({
"event": "coordinator_ready", "event": "coordinator_ready",
"main_agent_name": main_agent_name, "main_agent_name": main_agent_name,
"thread_id": coord_thread, "thread_id": coord_thread,
}) })
yield _frame({ yield _frame({
"event": "init_done", "event": "init_done",
"main_agent_name": main_agent_name, "main_agent_name": main_agent_name,
"thread_ids": agent_threads, "thread_ids": agent_threads,
}) })
except HTTPException as exc: except HTTPException as exc:
logger.warning("multi-agent init HTTPException: %s %s", exc.status_code, exc.detail) logger.warning("multi-agent init HTTPException: %s %s", exc.status_code, exc.detail)
yield _frame({"event": "error", "detail": f"[{exc.status_code}] {exc.detail}"}) yield _frame({"event": "error", "detail": f"[{exc.status_code}] {exc.detail}"})
except Exception as exc: except Exception as exc:
logger.exception("multi-agent init unexpected failure") logger.exception("multi-agent init unexpected failure")
yield _frame({"event": "error", "detail": f"unexpected: {exc!r}"}) yield _frame({"event": "error", "detail": f"unexpected: {exc!r}"})
return StreamingResponse( return StreamingResponse(
stream_generator(), stream_generator(),
@@ -825,6 +1056,11 @@ async def _leader_run(
new_message=new_message, new_message=new_message,
excluded_tools=["web_search"], excluded_tools=["web_search"],
skill_stop_names=effective_stop_names, skill_stop_names=effective_stop_names,
# 总控派活是"下一步派谁、什么任务"的短决策,不需要 CoT。
# 关掉 thinking + low reasoning 让"调度中"几乎瞬间转入"正在派活",
# 子智能体仍保留 thinking(在 _special_run 里默认 True),保证交付质量。
thinking_enabled=False,
reasoning_effort="low",
) )
t_payload_built = time.perf_counter() t_payload_built = time.perf_counter()
logger.info( logger.info(
@@ -49,6 +49,7 @@ from fastapi.responses import StreamingResponse
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
from app.gateway.routers.multi_agent import ( from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers, _auth_headers,
_create_thread, _create_thread,
_flatten_content, _flatten_content,
@@ -237,7 +238,7 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
async with _make_loopback_client(request) as client: async with _make_loopback_client(request) as client:
try: try:
# 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。 # 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。
thread_id = await _create_thread(client, base, headers) thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
body = _run_payload( body = _run_payload(
agent_name=RECOMMENDER_AGENT_ID, agent_name=RECOMMENDER_AGENT_ID,
@@ -253,6 +254,12 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
"ask_clarification", "ask_clarification",
], ],
skill_stop_names=[], skill_stop_names=[],
# 推荐是"从候选池挑 2-6 个"的短分类任务,不需要 CoT。
# 关掉 thinking + 用 low reasoning,避免模型先吐数千 reasoning
# tokens 才开始输出 [RECOMMEND_READY],前端"等待推荐结果..."
# 从 10-30s 降到 2-5s。
thinking_enabled=False,
reasoning_effort="low",
) )
captured: list[str] = [] captured: list[str] = []
@@ -137,7 +137,17 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
召回结果包在 ``<memory-context>`` 标签里并带系统提示,告诉模型这是 召回结果包在 ``<memory-context>`` 标签里并带系统提示,告诉模型这是
背景信息而非用户的新输入。 背景信息而非用户的新输入。
**跳过开关**:若 ``runtime.context.memory_recall_disabled`` 为真,直接
返回 None。圆桌规划等临时性任务(intent / recommend / leader / special)
不需要从用户个人记忆里召回,Hindsight HTTP 同步阻塞会显著增加 LLM 首
字节延迟(前端"调度中"卡顿);由路由层通过 ``_run_payload`` 的 context
字段统一关闭。
""" """
runtime = getattr(request, "runtime", None)
if runtime is not None and getattr(runtime, "context", None):
if runtime.context.get("memory_recall_disabled"):
return None
if not self._injection_enabled: if not self._injection_enabled:
return None return None
config = self._memory_config or get_memory_config() config = self._memory_config or get_memory_config()
@@ -1,7 +1,9 @@
"""Configuration and loaders for custom agents.""" """Configuration and loaders for custom agents."""
import logging import logging
import os
import re import re
import threading
from typing import Any from typing import Any
import yaml import yaml
@@ -13,6 +15,25 @@ logger = logging.getLogger(__name__)
SOUL_FILENAME = "SOUL.md" SOUL_FILENAME = "SOUL.md"
AGENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") AGENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
# Per-file mtime caches. These are read on every /api/agents list/detail request
# (one read per agent) — caching keyed by (path, mtime_ns, size) is correct
# because any edit to the file updates mtime, and an external tool re-writing
# the same bytes doesn't actually change the result we'd return. Cache size is
# unbounded but lookup is O(1) and entries are tiny (one parsed dict / string
# per agent file); for the order of N≤10⁴ agents the memory cost is negligible.
_config_cache: dict[str, tuple[tuple[int, int], "AgentConfig | None"]] = {}
_soul_cache: dict[str, tuple[tuple[int, int], str | None]] = {}
_cache_lock = threading.Lock()
def _file_stat_key(path) -> tuple[int, int] | None:
"""Return (mtime_ns, size) for cache invalidation, or None when missing."""
try:
st = os.stat(path)
except OSError:
return None
return (st.st_mtime_ns, st.st_size)
# Backward-compatible alias. Historically this validated the display name; # Backward-compatible alias. Historically this validated the display name;
# after the DB-backed agent table, display names can be Chinese and duplicated. # after the DB-backed agent table, display names can be Chinese and duplicated.
# The pattern now applies only to storage/runtime ids. # The pattern now applies only to storage/runtime ids.
@@ -94,6 +115,18 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None:
) )
return None return None
# mtime-based cache. The config file is rewritten every time the agent is
# edited via PUT /api/agents/{id}, which bumps mtime — so the next read
# picks up the change automatically. Missing config_file = key None below,
# bypassing the cache (cheap path: just return AgentConfig with id/name).
cache_path_key = str(config_file)
stat_key = _file_stat_key(config_file) if config_file.exists() else None
if stat_key is not None:
with _cache_lock:
cached = _config_cache.get(cache_path_key)
if cached is not None and cached[0] == stat_key:
return cached[1]
data: dict[str, Any] = {} data: dict[str, Any] = {}
if config_file.exists(): if config_file.exists():
try: try:
@@ -112,7 +145,11 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None:
known_fields = set(AgentConfig.model_fields.keys()) known_fields = set(AgentConfig.model_fields.keys())
data = {k: v for k, v in data.items() if k in known_fields} data = {k: v for k, v in data.items() if k in known_fields}
return AgentConfig(**data) result = AgentConfig(**data)
if stat_key is not None:
with _cache_lock:
_config_cache[cache_path_key] = (stat_key, result)
return result
def load_agent_soul(agent_id: str | None) -> str | None: def load_agent_soul(agent_id: str | None) -> str | None:
@@ -131,8 +168,21 @@ def load_agent_soul(agent_id: str | None) -> str | None:
soul_path = agent_dir / SOUL_FILENAME soul_path = agent_dir / SOUL_FILENAME
if not soul_path.exists(): if not soul_path.exists():
return None return None
# mtime-based cache, same rationale as load_agent_config: SOUL.md is
# rewritten on edit (PUT /api/agents/{id}), which bumps mtime.
cache_path_key = str(soul_path)
stat_key = _file_stat_key(soul_path)
if stat_key is not None:
with _cache_lock:
cached = _soul_cache.get(cache_path_key)
if cached is not None and cached[0] == stat_key:
return cached[1]
content = soul_path.read_text(encoding="utf-8").strip() content = soul_path.read_text(encoding="utf-8").strip()
return content or None result = content or None
if stat_key is not None:
with _cache_lock:
_soul_cache[cache_path_key] = (stat_key, result)
return result
def list_custom_agents() -> list[AgentConfig]: def list_custom_agents() -> list[AgentConfig]:
+383
View File
@@ -0,0 +1,383 @@
#root {
margin: 0 auto;
}
:root {
/* 深色主题(默认) */
--bg: #0f1117;
--surface: #161b22;
--card: #1c2128;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--purple: #8957e5;
--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);
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
/* 多智能体检索 */
/* 浅色主题 */
[data-theme="light"] {
--bg: #f5f7fa;
--surface: #ffffff;
--card: #f0f2f5;
--border: #e2e8f0;
--text: #1a202c;
--text-muted: #718096;
--accent: #3182ce;
--green: #38a169;
--yellow: #d69e2e;
--red: #e53e3e;
--purple: #805ad5;
}
body {
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* App layout */
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Idle: centered form */
.app-idle {
position: relative;
display: flex;
align-items: flex-start;
justify-content: center;
min-height: 100vh;
padding: 24px;
background: var(--bg);
}
.app-idle-header {
position: absolute;
top: 16px;
right: 16px;
}
.app-idle-scroll {
align-items: flex-start;
overflow-y: auto;
}
/* Running / done: two-column */
.app-running {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.app-header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.app-header-logo {
font-weight: 700;
/* font-size: 15px; */
color: var(--accent);
letter-spacing: 0.5px;
}
.app-header-task {
flex: 1;
color: var(--text-muted);
/* font-size: 13px; */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* 主题切换按钮 */
.theme-toggle {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
/* font-size: 12px; */
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
}
.theme-toggle:hover {
color: var(--text);
border-color: var(--text-muted);
background: var(--card);
}
.app-body {
display: flex;
flex: 1;
overflow: hidden;
}
.app-sidebar {
width: 240px;
flex-shrink: 0;
overflow-y: auto;
border-right: 1px solid var(--border);
background: var(--surface);
}
.app-main {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.app-progress-sidebar {
width: 300px;
flex-shrink: 0;
overflow-y: auto;
border-left: 1px solid var(--border);
background: var(--surface);
}
/* Buttons */
button {
cursor: pointer;
border: none;
border-radius: 6px;
padding: 7px 16px;
/* font-size: 13px; */
font-family: inherit;
transition: opacity 0.15s, background 0.15s;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
color: #000;
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.85;
}
.btn-danger {
background: transparent;
color: var(--red);
border: 1px solid var(--red);
}
.btn-danger:hover:not(:disabled) {
background: rgba(248, 81, 73, 0.1);
}
.btn-secondary {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn-secondary:hover:not(:disabled) {
color: var(--text);
border-color: var(--text-muted);
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
padding: 4px 8px;
/* font-size: 12px; */
}
.btn-ghost:hover {
color: var(--text);
}
/* Badges */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
/* font-size: 11px; */
font-weight: 600;
letter-spacing: 0.3px;
}
.badge-starting { background: rgba(88, 166, 255, 0.15); color: var(--accent); }
.badge-diverging { background: rgba(210, 153, 34, 0.15); color: var(--yellow); }
.badge-closing { background: rgba(63, 185, 80, 0.15); color: var(--green); }
.badge-done { background: rgba(137, 87, 229, 0.15); color: var(--purple); }
.badge-default { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); }
/* Spinner */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
display: inline-block;
animation: spin 1s linear infinite;
}
/* Reference cite */
.ref-cite {
display: inline-block;
background: rgba(88, 166, 255, 0.2);
color: var(--accent);
border-radius: 4px;
padding: 0 3px;
font-family: 'SFMono-Regular', Consolas, monospace;
/* font-size: 11px; */
cursor: pointer;
position: relative;
}
.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 */
.error-banner {
background: rgba(248, 81, 73, 0.1);
border: 1px solid var(--red);
border-radius: 6px;
padding: 8px 12px;
color: var(--red);
/* font-size: 13px; */
margin-bottom: 12px;
}
@media (max-width: 1200px) {
.app-progress-sidebar {
width: 260px;
}
}
@media (max-width: 960px) {
.app-body {
flex-direction: column;
}
.app-sidebar,
.app-progress-sidebar {
width: 100%;
border-right: none;
border-left: none;
border-bottom: 1px solid var(--border);
max-height: 260px;
}
.app-main {
padding: 14px;
}
}
@keyframes qa-typing-dot {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.35;
}
40% {
transform: translateY(-5px);
opacity: 1;
}
}
+798
View File
@@ -0,0 +1,798 @@
import {
Routes,
Route,
useLocation,
useParams,
Navigate,
useNavigate,
useSearchParams,
} from "react-router-dom";
import "./App.css";
import { ThemeProvider } from "./contexts/ThemeContext";
import { ChatProvider } from "./contexts/ChatContext";
import Sidebar, { PageType } from "./components/Sidebar";
import Header from "./components/Header";
import SceneList from "./pages/SceneList";
import SceneDetail from "./pages/SceneDetail";
import AgentList from "./pages/AgentList";
import AgentDetail from "./pages/AgentDetail";
import AgentTrial from "./pages/AgentTrial";
import TaskList from "./components/taskManagement/TaskList";
import TaskCreate from "./components/taskManagement/TaskCreate";
import TaskSingle from "./components/taskManagement/TaskSingle";
import TaskWorkspace from "./components/taskManagement/TaskWorkspace";
import TaskConsultCreate from "./components/taskManagement/TaskConsultCreate";
import TaskConsult from "./components/taskManagement/TaskConsult";
import TaskConsultResult from "./components/taskManagement/TaskConsultResult";
import StrategyResult from "./pages/StrategyResult";
import FinalPlan from "./pages/FinalPlan";
import SystemSettings from "./pages/SystemSettings";
import TaskDashboard from "./components/taskManagement/TaskDashboard";
import TaskListPage from "./components/taskManagement/TaskListPage";
import QA from "./pages/QA";
import QARecords from "./pages/QARecords";
import ResultManagement from "./pages/ResultManagement";
import ResultDetail from "./pages/ResultDetail";
import ZZResourceManagement from "./pages/ZZResourceManagement";
import NetworkActorResourceManagement from "./pages/NetworkActorResourceManagement";
import KnowledgeBaseResourceManagement from "./pages/KnowledgeBaseResourceManagement";
import EvaluationIndexResourceManagement from "./pages/EvaluationIndexResourceManagement";
import LightAppSquare from "./pages/LightAppSquare";
import LightAppRun from "./pages/LightAppRun";
import LightAppRecords from "./pages/LightAppRecords";
import { LightAppType } from "./pages/LightAppSquare";
import { MessagePlugin, Button, Space } from "tdesign-react";
import { useState, useEffect } from "react";
import TaskManagement from "./pages/TaskManagement";
import TaskAnalyzes from "./components/taskManagement/TaskAnalyzes";
import SentimentAnalyzes from "./components/taskManagement/SentimentAnalyzes";
import { ActionAnalyzes } from "./components/taskManagement/ActionAnalyzes";
import MopeChat from "./components/qa/MopeChat";
import QACenterPage from "./components/qa/QACenterPage";
import GeneralQA from "./components/qa/GeneralQa";
import AgentQA from "./components/qa/AgentQA";
import CollaborativeQA from "./components/qa/CollaborativeQA";
import ResultManagementLayout from "./components/result/ResultManagementLayout";
import ResultPlanning from "./components/result/ResultPlanning";
import ResultMnty from "./components/result/ResultMnty";
import ResultEffectPg from "./components/result/ResultEffectPg";
import ResultXk from "./components/result/ResultXk";
import ResultDq from "./components/result/ResultDq";
import ResultOther from "./components/result/ResultOther";
import ExpertsAgentsPage from "./pages/AgentListExperts";
import AgentExpertCenterPage from "./components/agent/AgentExpertCenterPage";
import AgentExpertEditPage from "./components/agent/AgentExpertEditPage";
import AgentChatPage from "./components/agent/AgentChatPage";
import SkillsWorkshop from "./components/agent/SkillsWorkshop";
import SkillCreatorPage from "./components/agent/SkillCreatorPage";
import TemplateEditor from "./components/qa/TemplateEditor";
import Template6BEditor from "./components/qa/Template6BEditor";
import SixStepChatPage from "./components/angentchat/SixStepChatPage";
import TopicResearch from "./components/qa/TopicResearch";
// import Login from './pages/Login';
import TaskChat from "./components/qa/TaskChat";
import ActionChat from "./components/qa/ActionChat";
import GAFromQA from "./components/qa/GAFrom";
import XingdongTyPage from "./components/taskManagement/XingdongTy";
import FanganTyPage from "./components/taskManagement/FanganTy";
import XXTyPage from "./components/taskManagement/XxTy";
import ObservabilityPage from './pages/ObservabilityPage';
import AgentCardList from './components/angentchat/AgentCardList';
import AgentEditPage from './components/angentchat/AgentEditPage';
import FileSkillsWorkshop from './components/agent/FileSkillsWorkshop';
import TaskPgFrom from './components/taskManagement/TaskPgFrom';
import Login from './pages/Login';
import TaskXkFrom from "./components/taskManagement/TaskXkFrom";
import GAFromNewQA from "./components/qa/GAFromNew";
function AppContent() {
const location = useLocation();
const params = useParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// 从查询参数中获取appId
const appIdFromQuery = searchParams.get("appId");
// 页面初始化时打印params
useEffect(() => {
console.log("页面初始化,当前params:", params);
console.log("查询参数appId:", appIdFromQuery);
}, []);
// 从路径中提取当前页面ID,忽略层级结构
const getCurrentPageFromPath = (path: string): PageType => {
console.log(path, "path");
const parts = path.split("/").filter(Boolean);
if (parts.length === 0) return "dashboard";
// 提取最后一个部分作为页面ID
let pageId = parts[parts.length - 1];
// 处理特殊情况
if (pageId === "index") {
pageId = parts[parts.length - 2] || "dashboard";
}
return pageId as PageType;
};
const currentPage = getCurrentPageFromPath(location.pathname);
const getTaskMenuConfig = (page: PageType) => {
const configs: Record<
string,
{ title: string; category: string; subCategory: string }
> = {
"xk-task-jc": {
title: "任务JC",
category: "XK任务",
subCategory: "XK任务",
},
"xk-task-dispatch": {
title: "任务调度",
category: "XK任务",
subCategory: "XK任务",
},
"plan-task-fx": {
title: "任务FX",
category: "规划任务",
subCategory: "规划任务",
},
"plan-task-action": {
title: "行动CE",
category: "规划任务",
subCategory: "规划任务",
},
"mnty-task-mechanism": {
title: "XDTY任务",
category: "MNTY任务",
subCategory: "MNTY任务",
},
"mnty-task-plan": {
title: "方案TY任务",
category: "MNTY任务",
subCategory: "MNTY任务",
},
"mnty-task-zsty": {
title: "XXTY任务",
category: "MNTY任务",
subCategory: "MNTY任务",
},
"effect-task-pg": {
title: "任务PG",
category: "效果PG任务",
subCategory: "效果PG任务",
},
"effect-action-pg": {
title: "行动PG",
category: "效果PG任务",
subCategory: "效果PG任务",
},
"effect-info-pg": {
title: "信息PG",
category: "效果PG任务",
subCategory: "效果PG任务",
},
"effect-account-pg": {
title: "账号PG",
category: "效果PG任务",
subCategory: "效果PG任务",
},
};
return (
configs[page] || { title: "任务管理", category: "", subCategory: "" }
);
};
const isTaskSubMenuPage = (page: PageType): boolean => {
return [
"xk-task-jc",
"xk-task-dispatch",
"plan-task-fx",
"plan-task-action",
"mnty-task-mechanism",
"mnty-task-plan",
"mnty-task-zsty",
"effect-task-pg",
"effect-action-pg",
"effect-info-pg",
"effect-account-pg",
].includes(page);
};
const handleNavigate = (page: PageType, appId?: string) => {
console.log("导航到:", page, appId);
// 根据页面ID构建正确的导航路径
let path = `/${page}`;
// 任务管理下的子页面
if (
page.startsWith("xk-task") ||
page.startsWith("plan-task") ||
page.startsWith("mnty-task") ||
page.startsWith("effect-task")
) {
const taskType = page.split("-")[0] + "-" + page.split("-")[1];
path = `/task-management/${taskType}/${page}`;
}
// 任务管理下的其他页面
else if (
[
"dashboard",
"task-create",
"task-single",
"task-scene",
"task-consult-create",
"task-consult",
"task-consult-result",
"task-list",
].includes(page)
) {
path = `/task-management/${page}`;
}
// 问答中心下的子页面
else if (
[
"new-qa",
"general-qa",
"agent-qa",
"multi-agent-qa",
"qa-records",
].includes(page)
) {
path = `/qa-center/${page}`;
}
// 轻应用中心下的子页面
else if (
["light-app-square", "light-app-records", "light-app-run"].includes(page)
) {
path = `/light-app-center/${page}`;
// 如果是light-app-run且有appId,添加到路径
if (page === "light-app-run" && appId) {
path = `/light-app-center/light-app-run?appId=${appId}`;
}
}
// 智能体管理下的子页面
else if (["agent-trial"].includes(page)) {
path = `/agent-list/${page}`;
}
// 结果管理下的子页面
else if (
[
"result-planning",
"result-mnty",
"result-effect-pg",
"result-xk",
"result-dq",
"result-other",
"result-detail",
].includes(page)
) {
path = `/result-management/${page}`;
}
// 资源管理下的子页面
else if (
[
"resource-zz",
"resource-network-actor",
"resource-knowledge-base",
"resource-evaluation-index",
].includes(page)
) {
path = `/resource-management/${page}`;
}
navigate(path);
};
return (
<div className="min-h-screen theme-bg theme-transition">
<Sidebar currentPage={currentPage} />
<div className="ml-64">
<Header />
<main className="p-6 theme-transition">
{appIdFromQuery}
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* 问答中心 */}
{/* <Route path="/qa-center" element={<QA onNavigate={handleNavigate} initialMode="new" />} />
<Route path="/qa-center/new-qa" element={<QA onNavigate={handleNavigate} initialMode="new" />} />
<Route path="/qa-center/general-qa" element={<QA onNavigate={handleNavigate} />} />
<Route path="/qa-center/agent-qa" element={<QA onNavigate={handleNavigate} />} />
<Route path="/qa-center/multi-agent-qa" element={<QA onNavigate={handleNavigate} initialMode="multi-agent" />} />
<Route path="/qa-center/qa-records" element={<QARecords onNavigate={handleNavigate} />} /> */}
{/* 问答中心 - 嵌套路由 */}
<Route path="/qa-center" element={<QACenterPage />}>
{/* 默认重定向到通用问答页面 */}
<Route
index
element={<Navigate to="/qa-center/general-qa" replace />}
/>
{/* 新建问答页面 - 初始化模式为新建 */}
<Route
path="new-qa"
element={
<GeneralQA onNavigate={handleNavigate} initialMode="new" />
}
/>
{/* 通用问答页面 - 初始化模式为通用 */}
<Route
path="general-qa"
element={
<GeneralQA
onNavigate={handleNavigate}
initialMode="general"
/>
}
/>
{/* 智能体问答页面 - 基于AI智能体的问答功能 */}
<Route
path="agent-qa"
element={<AgentQA onNavigate={handleNavigate} />}
/>
{/* 多智能体协作问答页面 - 多个AI智能体协同回答问题 */}
<Route
path="multi-agent-qa"
element={<CollaborativeQA onNavigate={handleNavigate} />}
/>
{/* 问答记录 */}
<Route
path="qa-records"
element={<QARecords onNavigate={handleNavigate} />}
/>
<Route path="mope-chat" element={<MopeChat />} />
<Route path="template-editor" element={<TemplateEditor />} />
<Route path="template-6b-editor" element={<Template6BEditor />} />
<Route path="sixstep-chat" element={<SixStepChatPage />} />
{/* <Route
path="topic-research"
element={
<TopicResearch
onNavigate={handleNavigate}
initialMode="new"
/>
}
/> */}
<Route
path="topic-research"
element={
<GAFromNewQA gopath="/page/canvas"/>
}
/>
<Route
path="task-chat"
element={
<TaskChat onNavigate={handleNavigate} initialMode="new" />
}
/>
<Route
path="action-chat"
element={
<ActionChat onNavigate={handleNavigate} initialMode="new" />
}
/>
<Route path="sixstep-chat" element={<SixStepChatPage />} />
<Route path="qa-from" element={<GAFromQA gopath="/page/workspace/chats/new"/>} />
<Route path="qa-from-6bf" element={<GAFromQA gopath="/page/strategy/agents/6bf/chats/new"/>} />
<Route path="skills-workshop" element={<SkillsWorkshop/>} />
<Route path="skill-creator" element={<SkillCreatorPage />} />
</Route>
{/* 轻应用中心 */}
<Route
path="/light-app-center"
element={<LightAppSquare onNavigate={handleNavigate} />}
/>
<Route
path="/light-app-center/light-app-square"
element={<LightAppSquare onNavigate={handleNavigate} />}
/>
<Route
path="/light-app-center/light-app-records"
element={<LightAppRecords onNavigate={handleNavigate} />}
/>
<Route
path="/light-app-center/light-app-run"
element={
<LightAppRun
onNavigate={handleNavigate}
appId={appIdFromQuery as any}
/>
}
/>
{/* 任务管理 */}
<Route path="/task-management" element={<TaskManagement />}>
<Route
path="dashboard"
element={<TaskDashboard onNavigate={handleNavigate} />}
/>
<Route
path="task-create"
element={<TaskCreate onNavigate={handleNavigate} />}
/>
<Route
path="task-single"
element={<TaskSingle onNavigate={handleNavigate} />}
/>
<Route
path="task-scene/:taskId"
element={<TaskWorkspace onNavigate={handleNavigate} />}
/>
<Route
path="task-scene"
element={<TaskWorkspace onNavigate={handleNavigate} />}
/>
<Route
path="task-consult-create"
element={<TaskConsultCreate onNavigate={handleNavigate} />}
/>
<Route
path="task-consult"
element={<TaskConsult onNavigate={handleNavigate} />}
/>
<Route
path="task-consult-result"
element={<TaskConsultResult onNavigate={handleNavigate} />}
/>
<Route
path="task-list"
element={
<TaskList
onViewWorkspace={(id) =>
navigate(`/task-management/task-scene/${id}`)
}
onNavigate={handleNavigate}
/>
}
/>
{/* XK任务 */}
<Route
path="xk-task/xk-task-jc"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageHome/pageSwrw'}
/>
}
/>
<Route
path="xk-task/xk-task-dispatch"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageJczl'}
/>
}
/>
{/* 规划任务 */}
<Route
path="plan-task/plan-task-fx"
element={
<TaskAnalyzes
title="任务FX"
category="规划任务"
subCategory="规划任务"
onNavigate={handleNavigate}
/>
}
/>
<Route
path="plan-task/plan-task-sentiment"
element={
<SentimentAnalyzes
title="任务FX"
category="规划任务"
subCategory="规划任务"
onNavigate={handleNavigate}
/>
}
/>
<Route
path="plan-task/plan-task-action"
element={
<ActionAnalyzes
title="行动CE"
category="规划任务"
subCategory="规划任务"
onNavigate={handleNavigate}
/>
}
/>
{/* MNTY任务 */}
<Route
path="mnty-task/mnty-task-mechanism"
element={
<XingdongTyPage
title="XDTY任务"
category="MNTY任务"
subCategory="MNTY任务"
onNavigate={handleNavigate}
/>
}
/>
<Route
path="mnty-task/mnty-task-plan"
element={
<FanganTyPage
title="方案TY任务"
category="MNTY任务"
subCategory="MNTY任务"
onNavigate={handleNavigate}
/>
}
/>
<Route
path="mnty-task/mnty-task-zsty"
element={
<XXTyPage
title="XXTY任务"
category="MNTY任务"
subCategory="MNTY任务"
onNavigate={handleNavigate}
/>
}
/>
{/* 效果PG任务 */}
<Route
path="effect-task/effect-task-pg"
element={
<TaskPgFrom
gopath="task"
/>
}
/>
<Route
path="effect-task/effect-action-pg"
element={
<TaskPgFrom
gopath="xd"
/>
}
/>
<Route
path="effect-task/effect-info-pg"
element={
<TaskPgFrom
gopath="info"
/>
}
/>
<Route
path="effect-task/effect-account-pg"
element={
<TaskListPage
title="账号PG"
category="效果PG任务"
subCategory="效果PG任务"
onNavigate={handleNavigate}
/>
}
/>
</Route>
{/* 场景管理 */}
<Route
path="/scene-list"
element={
<SceneList
onViewDetail={(id) => navigate(`/scene-detail/${id}`)}
onNavigate={handleNavigate}
/>
}
/>
<Route
path="/scene-detail/:sceneId"
element={
<SceneDetail
sceneId={params.sceneId || null}
onBack={() => navigate("/scene-list")}
/>
}
/>
{/* 智能体管理 */}
<Route
path="/agent-list"
element={
<AgentList
onViewDetail={(id) => navigate(`/agent-detail/${id}`)}
onNavigate={handleNavigate}
/>
}
/>
<Route
path="/agent-list/agent-trial"
element={<AgentTrial onNavigate={handleNavigate} />}
/>
<Route
path="/agent-detail/:agentId"
element={
<AgentDetail
agentId={params.agentId || null}
onBack={() => navigate("/agent-list")}
/>
}
/>
<Route
path="/agent-list/agent-experts"
element={<ExpertsAgentsPage />}
></Route>
{/* <GAFromQA gopath="/page/strategy/agents/6bf/chats/new"/> */}
{/* <Route
path="/agent-expert-center"
element={<AgentExpertCenterPage />}
/> */}
<Route
path="/agent-expert-center"
element={<GAFromQA gopath="/page/strategy/agents"/>}
/>
<Route
path="/agent-expert-edit"
element={<AgentExpertEditPage agentId={searchParams.get("id")} />}
/>
<Route path="/agent-chat" element={<AgentChatPage />} />
<Route path="/skills-workshop" element={<SkillsWorkshop />} />
<Route path="/skill-creator" element={<SkillCreatorPage />} />
<Route
path="sixstep-agents"
element={<AgentCardList onEdit={(key) => navigate(`/sixstep-agent-edit?key=${key}`)} />}
/>
<Route
path="sixstep-agent-edit"
element={
<AgentEditPage
agentKey={searchParams.get('key') || 'purpose'}
onBack={() => navigate('/sixstep-agents')}
/>
}
/>
<Route path="file-skills" element={<FileSkillsWorkshop />} />
{/* 结果管理 */}
{/* <Route path="/result-management" element={<ResultManagement onNavigate={handleNavigate} initialCategory="planning" />} />
<Route path="/result-management/result-planning" element={<ResultManagement onNavigate={handleNavigate} initialCategory="planning" />} />
<Route path="/result-management/result-mnty" element={<ResultManagement onNavigate={handleNavigate} initialCategory="mnty" />} />
<Route path="/result-management/result-effect-pg" element={<ResultManagement onNavigate={handleNavigate} initialCategory="effect-pg" />} />
<Route path="/result-management/result-xk" element={<ResultManagement onNavigate={handleNavigate} initialCategory="xk" />} />
<Route path="/result-management/result-dq" element={<ResultManagement onNavigate={handleNavigate} initialCategory="dq-research" />} />
<Route path="/result-management/result-other" element={<ResultManagement onNavigate={handleNavigate} initialCategory="other" />} />
<Route path="/result-management/result-detail/:taskId" element={<ResultDetail taskId={params.taskId} onNavigate={handleNavigate} onBack={() => navigate('/result-management')} />} />
<Route path="/result-management/result-detail" element={<ResultDetail taskId={undefined} onNavigate={handleNavigate} onBack={() => navigate('/result-management')} />} /> */}
{/* 结果管理 - 嵌套路由 */}
<Route
path="/result-management"
element={<ResultManagementLayout />}
>
<Route
index
element={
<Navigate to="/result-management/result-planning" replace />
}
/>
<Route
path="result-planning"
element={<ResultPlanning onNavigate={handleNavigate} />}
/>
<Route
path="result-mnty"
element={<ResultMnty onNavigate={handleNavigate} />}
/>
<Route
path="result-effect-pg"
element={<ResultEffectPg onNavigate={handleNavigate} />}
/>
<Route
path="result-xk"
element={<ResultXk onNavigate={handleNavigate} />}
/>
<Route
path="result-dq"
element={<ResultDq onNavigate={handleNavigate} />}
/>
<Route
path="result-other"
element={<ResultOther onNavigate={handleNavigate} />}
/>
<Route
path="result-detail"
element={
<ResultDetail
taskId={undefined}
onNavigate={handleNavigate}
onBack={() => navigate("/result-management")}
/>
}
/>
<Route
path="result-detail/:taskId"
element={
<ResultDetail
taskId={params.taskId}
onNavigate={handleNavigate}
onBack={() => navigate("/result-management")}
/>
}
/>
</Route>
{/* 资源管理 */}
<Route
path="/resource-management"
element={<ZZResourceManagement onNavigate={handleNavigate} />}
/>
<Route
path="/resource-management/resource-zz"
element={<ZZResourceManagement onNavigate={handleNavigate} />}
/>
<Route
path="/resource-management/resource-network-actor"
element={
<NetworkActorResourceManagement onNavigate={handleNavigate} />
}
/>
<Route
path="/resource-management/resource-knowledge-base"
element={
<KnowledgeBaseResourceManagement onNavigate={handleNavigate} />
}
/>
{/* <Route
path="/resource-management/resource-evaluation-index"
element={
<EvaluationIndexResourceManagement
onNavigate={handleNavigate}
/>
}
/> */}
<Route
path="/resource-management/resource-evaluation-index"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageZyjc/pageZyzl'}
/>
}
/>
{/* 其他路由 */}
<Route
path="/dashboard"
element={<TaskDashboard onNavigate={handleNavigate} />}
/>
<Route
path="/strategy-result"
element={<StrategyResult onNavigate={handleNavigate} />}
/>
<Route
path="/final-plan"
element={<FinalPlan onNavigate={handleNavigate} />}
/>
<Route
path="/system-settings"
element={<SystemSettings onNavigate={handleNavigate} />}
/>
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/login" element={<Login/>}/>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
</div>
</div>
);
}
function App() {
return (
<ThemeProvider>
<ChatProvider>
<AppContent />
</ChatProvider>
</ThemeProvider>
);
}
export default App;
+17
View File
@@ -0,0 +1,17 @@
<svg id="轻应用logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="40" height="40" viewBox="0 0 40 40">
<defs>
<linearGradient id="linear-gradient" x1="-0.025" x2="1" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#006fea"/>
<stop offset="1" stop-color="#004ebd"/>
</linearGradient>
<linearGradient id="linear-gradient-2" x2="1" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#00c7ff"/>
<stop offset="1" stop-color="#92eeff"/>
</linearGradient>
</defs>
<rect id="矩形_152188" data-name="矩形 152188" width="40" height="40" rx="8" fill="url(#linear-gradient)"/>
<g id="应用_application-one" transform="translate(8 8)">
<rect id="矩形_153908" data-name="矩形 153908" width="24" height="24" fill="#fff" opacity="0"/>
<path id="联合_1324" data-name="联合 1324" d="M-8800,20219.859l-8.5-5a.994.994,0,0,1-.5-.863v-10a.994.994,0,0,1,.5-.863l8.5-5a.988.988,0,0,1,.5-.143,1.008,1.008,0,0,1,.508.143l8.5,5a.989.989,0,0,1,.492.863v10a.989.989,0,0,1-.492.863l-8.5,5a.977.977,0,0,1-.506.141A.976.976,0,0,1-8800,20219.859Zm-7-15.285v8.855l7.5,4.41,7.5-4.41v-8.855l-7.5-4.414Zm6.5,8.926v-3.945l-3.526-2.209a1,1,0,0,1-.32-1.381,1,1,0,0,1,1.381-.314l3.466,2.172,3.471-2.172a1,1,0,0,1,1.375.314,1,1,0,0,1-.314,1.381l-3.531,2.207v3.947a1,1,0,0,1-1,1A1,1,0,0,1-8800.5,20213.5Z" transform="translate(8811.5 -20197)" fill="url(#linear-gradient-2)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="40" height="40" viewBox="0 0 40 40"><defs><style>.a{fill:url(#a);}.b{fill:#fff;opacity:0;}.c{fill:url(#b);}</style><linearGradient id="a" x1="-0.025" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#006fea"/><stop offset="1" stop-color="#004ebd"/></linearGradient><linearGradient id="b" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#00c7ff"/><stop offset="1" stop-color="#92eeff"/></linearGradient></defs><rect class="a" width="40" height="40" rx="8"/><g transform="translate(8 8)"><rect class="b" width="24" height="24"/><path class="c" d="M-8800,20219.859l-8.5-5a.994.994,0,0,1-.5-.863v-10a.994.994,0,0,1,.5-.863l8.5-5a.988.988,0,0,1,.5-.143,1.008,1.008,0,0,1,.508.143l8.5,5a.989.989,0,0,1,.492.863v10a.989.989,0,0,1-.492.863l-8.5,5a.977.977,0,0,1-.506.141A.976.976,0,0,1-8800,20219.859Zm-7-15.285v8.855l7.5,4.41,7.5-4.41v-8.855l-7.5-4.414Zm6.5,8.926v-3.945l-3.526-2.209a1,1,0,0,1-.32-1.381,1,1,0,0,1,1.381-.314l3.466,2.172,3.471-2.172a1,1,0,0,1,1.375.314,1,1,0,0,1-.314,1.381l-3.531,2.207v3.947a1,1,0,0,1-1,1A1,1,0,0,1-8800.5,20213.5Z" transform="translate(8811.5 -20197)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

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