# 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
# 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)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -3,5 +3,18 @@
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
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
+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_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_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 { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import {
useAgent,
useDeleteAgent,
useSetAgentFeatured,
useSetAgentPrivate,
@@ -81,6 +82,13 @@ export function AgentCard({ agent }: AgentCardProps) {
);
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 currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
@@ -105,7 +113,6 @@ export function AgentCard({ agent }: AgentCardProps) {
}
setEditName(agent.name);
setEditDescription(agent.description ?? "");
setEditSoul(agent.soul ?? "");
setEditPublished(agent.published);
setEditIsPrivate(agent.is_private);
setSelectedSkills(agent.skills ?? []);
@@ -115,10 +122,23 @@ export function AgentCard({ agent }: AgentCardProps) {
agent.name,
agent.published,
agent.skills,
agent.soul,
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() {
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
}
@@ -563,7 +583,11 @@ export function AgentCard({ agent }: AgentCardProps) {
<Textarea
value={editSoul ?? ""}
onChange={(e) => setEditSoul(e.target.value)}
placeholder="输入 SOUL.md 内容"
placeholder={
isAgentDetailLoading && !agentDetail
? "正在加载 SOUL.md..."
: "输入 SOUL.md 内容"
}
className="min-h-40 font-mono text-xs"
/>
</div>
@@ -32,7 +32,10 @@ interface 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 [autoSelect, setAutoSelect] = useState(true);
const [open, setOpen] = useState(
@@ -163,7 +163,8 @@ export function RecentChatList() {
const visibleThreads = threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap",
thread.metadata?.thread_type !== "bootstrap" &&
thread.metadata?.thread_type !== "roundtable",
);
if (visibleThreads.length === 0) {
+1
View File
@@ -34,6 +34,7 @@ export default function ChatsPage() {
return threads?.filter((thread) => {
return (
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable" &&
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 { PageSidebar } from "@/components/page-sidebar";
import { PageSidebarDark } from "@/components/page-sidebar-dark";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { useTheme } from "@/contexts/ThemeContext";
import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes";
@@ -13,16 +15,25 @@ import RoundtableRoutes from "./RoundtableRoutes";
function PageLayout() {
const [collapsed, setCollapsed] = useState(false);
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
const { theme } = useTheme();
const isDark = theme === "dark";
return (
<div className="h-screen flex flex-col bg-muted/20">
{sidebarEnabled && <Header collapsed={collapsed} />}
<div className="flex flex-1 overflow-hidden">
{sidebarEnabled && (
<PageSidebar
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
isDark ? (
<PageSidebarDark
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
) : (
<PageSidebar
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
)
)}
<main
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 LightAppNoteListPage from "./LightAppNoteListPage";
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() {
return (
<RouteBaseProvider value="/page/strategy">
<SidebarProvider>
<div className="h-full w-full overflow-hidden">
<Routes>
<Route index element={<Navigate to="qa/general" replace />} />
<SidebarProvider>
<div className="h-full w-full overflow-hidden">
<Routes>
<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="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 对话 */}
<Route path="chats" element={<ChatsPage />} />
<Route
path="chats/:thread_id"
element={
<ChatRuntime>
<ChatPage />
</ChatRuntime>
}
/>
{/* 对话 */}
<Route path="chats" element={<ChatsPage />} />
<Route
path="chats/:thread_id"
element={
<ChatRuntime>
<ChatPage />
</ChatRuntime>
}
/>
{/* 智能体管理 */}
<Route path="agents" element={<AgentsPage />} />
<Route path="agents/new" element={<NewAgentPage />} />
<Route
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="agents" element={<AgentsPage />} />
<Route path="agents/new" element={<NewAgentPage />} />
<Route
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 path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
{/* 技能 / 记忆 */}
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
{/* 定时任务 */}
<Route
path="scheduled"
element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
/>
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} />
<Route
path="scheduled/runs/:run_id"
element={<ScheduledTaskRunDetailPage />}
/>
{/* 定时任务 */}
<Route
path="scheduled"
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
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === "admin" ? (
<RecommendedQuestionsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === "admin" ? (
<PromptPrefixSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/private-square"
element={
getStoredAuth()?.system_role === "admin" ? (
<PrivateSquareSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === "admin" ? (
<LlmMetricsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/users"
element={
getStoredAuth()?.system_role === "admin" ? (
<UsersPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminScheduledTasksPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/user-activity"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminUserActivityPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<TagsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</div>
</SidebarProvider>
<Toaster position="top-center" />
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === "admin" ? (
<RecommendedQuestionsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === "admin" ? (
<PromptPrefixSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/private-square"
element={
getStoredAuth()?.system_role === "admin" ? (
<PrivateSquareSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === "admin" ? (
<LlmMetricsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/users"
element={
getStoredAuth()?.system_role === "admin" ? (
<UsersPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminScheduledTasksPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/user-activity"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminUserActivityPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<TagsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</div>
</SidebarProvider>
<Toaster position="top-center" />
</RouteBaseProvider>
);
}
@@ -27,7 +27,7 @@ export interface StreamRecommendResult {
* `done` → picks parsed and validated, ready to populate the dialog as
* default-checked selections.
* `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.
*/
status: "done" | "asking" | "error";
@@ -202,6 +202,8 @@ export interface ReasoningBlockProps {
/** 用于显示「思考用时」的可选时长(秒)。流式期间不显示。 */
durationSec?: number;
className?: string;
/** 展开内容区额外 class,例如 max-h + overflow-y-auto。 */
contentClassName?: string;
}
const REASONING_AUTO_CLOSE_DELAY_MS = 1000;
@@ -211,6 +213,7 @@ export function ReasoningBlock({
streaming = false,
durationSec,
className,
contentClassName,
}: ReasoningBlockProps) {
// open 初始值跟 streaming 同步:流式中默认展开方便观察思考过程,完成后会自动收起一次。
const [open, setOpen] = useState<boolean>(streaming);
@@ -260,7 +263,12 @@ export function ReasoningBlock({
/>
</button>
{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>
</div>
)}
@@ -46,6 +46,8 @@ export interface MessageStepsCardProps {
className?: string;
/** 点击 write_file / present_files 等产物相关步骤时打开沙箱。 */
onArtifactStepClick?: (step: BubbleStep) => void;
/** 把 agent_orchestration 派活的 agent id 解析成中文席位名(用于 chip 显示)。 */
resolveAgentName?: (idOrName: string) => string;
}
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
@@ -58,6 +60,7 @@ export function MessageStepsCard({
streaming,
className,
onArtifactStepClick,
resolveAgentName,
}: MessageStepsCardProps) {
// 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` /
// `lastReasoningStep` 划分对齐。
@@ -131,12 +134,14 @@ export function MessageStepsCard({
step={s}
status="complete"
onArtifactStepClick={onArtifactStepClick}
resolveAgentName={resolveAgentName}
/>
))}
<ToolStep
step={lastToolStep}
status={streaming && trailingReasoning.length === 0 ? "active" : "complete"}
onArtifactStepClick={onArtifactStepClick}
resolveAgentName={resolveAgentName}
/>
</ChainOfThoughtContent>
)}
@@ -155,18 +160,23 @@ function ToolStep({
step,
status,
onArtifactStepClick,
resolveAgentName,
}: {
step: BubbleStep;
status: "complete" | "active";
onArtifactStepClick?: (step: BubbleStep) => void;
resolveAgentName?: (idOrName: string) => string;
}) {
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(":");
// read_file 也可点 —— 验证类「查看文件…」步骤常出现在最后一条,点击后在沙箱里
// 查看该文件内容(走虚拟预览或 outputs HTTP)。主聊天只让 write 类可点,这里增强。
const isArtifactStep =
toolName === "write_file" ||
toolName === "str_replace" ||
toolName === "present_files";
toolName === "present_files" ||
toolName === "read_file";
return (
<ChainOfThoughtStep
@@ -62,6 +62,20 @@ export interface RecommendAgentsDialogProps {
const MIN_SELECTED = 2;
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 =
| "idle"
| "loading_agents"
@@ -305,17 +319,12 @@ export function RecommendAgentsDialog({
return;
}
// Fallback: status === "asking" or picks too few — pick from live pool.
const seed = candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
if (seed.length < MIN_SELECTED) {
setState((prev) => ({
...prev,
phase: "error",
streamPhase: null,
errorMessage: `候选智能体不足 ${MIN_SELECTED} 个,无法进入圆桌研讨。请先发布更多智能体。`,
}));
return;
}
// Fallback: status === "asking" or picks too few. Use the 6 built-in
// roundtable seats, intersected against the live pool.
const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates);
const seed = fallback.length >= MIN_SELECTED
? fallback
: candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
setSelectedIds(new Set(seed));
const { rationale } = parseRecommendStreamContent(result.content || "");
setState((prev) => ({
@@ -325,7 +334,7 @@ export function RecommendAgentsDialog({
recommendedIds: seed,
reasons,
streamPhase: null,
warning: "智能推荐未生成有效结果,已为你勾选候选池中的前几位智能体,可手动调整。",
warning: "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。",
}));
} catch (err) {
if (myGen !== generationRef.current) return;
@@ -421,8 +430,8 @@ export function RecommendAgentsDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col overflow-hidden gap-3">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-5 text-purple-500" />
选择参会智能体
@@ -457,7 +466,7 @@ export function RecommendAgentsDialog({
<CheckIcon className="size-3 text-emerald-500" />
推荐参会智能体
</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" ? (
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
{state.errorMessage ?? "未知错误"}
@@ -527,7 +536,7 @@ export function RecommendAgentsDialog({
</div>
</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">
<span
className={cn(
@@ -635,14 +644,14 @@ function CollapsibleModelOutput({
})();
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
type="button"
onClick={() => {
userTouchedRef.current = true;
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 ? (
<Loader2Icon className="size-3 text-purple-500 shrink-0 animate-spin" />
@@ -667,7 +676,11 @@ function CollapsibleModelOutput({
)}
/>
</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>
);
}
@@ -736,7 +749,12 @@ function RecommenderOutput({ state }: { state: InternalState }) {
)}
{hasReasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning!}</ReasoningBlock>
<ReasoningBlock
streaming={isStreaming}
contentClassName="max-h-48 overflow-y-auto custom-scrollbar"
>
{reasoning!}
</ReasoningBlock>
)}
{!hasRationale && !hasReasoning && isStreaming && (
@@ -744,7 +762,7 @@ function RecommenderOutput({ state }: { state: InternalState }) {
)}
{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>
{isStreaming && <StreamingCursor />}
</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">
{introText && <MarkdownContent>{introText}</MarkdownContent>}
{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 />}
{isPendingClarification && clarification && (
<IntentClarificationCard
@@ -15,7 +15,7 @@
*
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import {
Cpu,
FileIcon,
@@ -67,6 +67,11 @@ export interface Step2PanelProps {
agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null;
roles: RoleEntry[];
/** agent id(小写)→ 席位元数据,用于把派活 chip 的 id 显示成中文席位名。 */
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
selectedModel: string;
availableModels: ModelOption[];
modelsLoading: boolean;
@@ -89,7 +94,7 @@ export interface Step2PanelProps {
// ─── Handlers ─────────────────────────────────────────────────
onPauseRoundtable: () => void;
onResumeRoundtable: () => void;
onRecordInjectMessage: () => void;
onHandleInjectToRoundtable: () => void;
onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void;
onProceedToStep3: () => void;
@@ -109,6 +114,7 @@ export function Step2Panel(props: Step2PanelProps) {
agentConfigs,
selectedAgentId,
roles,
agentNameToRole,
selectedModel,
availableModels,
modelsLoading,
@@ -123,7 +129,7 @@ export function Step2Panel(props: Step2PanelProps) {
setSelectedAgentId,
onPauseRoundtable,
onResumeRoundtable,
onRecordInjectMessage,
onHandleInjectToRoundtable,
onSubmitClarification,
onSavePersona,
onProceedToStep3,
@@ -134,24 +140,20 @@ export function Step2Panel(props: Step2PanelProps) {
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
// 干预研审 → 暂停会商:与右上角按钮共用 onPauseRoundtable;乐观 UI 避免等父 state 刷新。
const [pauseFlash, setPauseFlash] = useState(false);
const [optimisticPaused, setOptimisticPaused] = useState(false);
const showPausedUi = !isRoundTableRunning || optimisticPaused;
const resolveAgentName = useCallback(
(idOrName: string) =>
agentNameToRole[idOrName.toLowerCase()]?.displayName ?? idOrName,
[agentNameToRole],
);
useEffect(() => {
if (!isRoundTableRunning) {
setOptimisticPaused(false);
}
}, [isRoundTableRunning]);
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
const [pauseFlash, setPauseFlash] = useState(false);
const handleInject = useCallback(() => {
setOptimisticPaused(true);
onPauseRoundtable();
onRecordInjectMessage();
onHandleInjectToRoundtable();
setPauseFlash(true);
window.setTimeout(() => setPauseFlash(false), 1600);
}, [onPauseRoundtable, onRecordInjectMessage]);
}, [onHandleInjectToRoundtable]);
const isSandboxStreaming =
isOrchestrating ||
@@ -196,7 +198,7 @@ export function Step2Panel(props: Step2PanelProps) {
</option>
))}
</select>
{showPausedUi ? (
{!isRoundTableRunning ? (
<button
type="button"
onClick={onResumeRoundtable}
@@ -255,6 +257,7 @@ export function Step2Panel(props: Step2PanelProps) {
<Step2Message
key={msg.id}
msg={msg}
resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) {
@@ -354,10 +357,10 @@ export function Step2Panel(props: Step2PanelProps) {
<button
type="button"
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"
>
干预研审
干预并发送
</button>
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
@@ -406,6 +409,8 @@ export function Step2Panel(props: Step2PanelProps) {
*/
interface Step2MessageProps {
msg: Step2Dialogue;
/** 把派活 chip 的 agent id 解析成中文席位名。 */
resolveAgentName?: (idOrName: string) => string;
onArtifactStepClick?: (
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
@@ -415,6 +420,7 @@ interface Step2MessageProps {
function Step2Message({
msg,
resolveAgentName,
onArtifactStepClick,
onFileCardClick,
}: Step2MessageProps) {
@@ -445,6 +451,7 @@ function Step2Message({
steps={msg.steps}
streaming={isStreaming}
onArtifactStepClick={onArtifactStepClick}
resolveAgentName={resolveAgentName}
/>
)}
{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(
steps: Step2Dialogue["steps"],
@@ -478,14 +490,22 @@ function collectPresentedFiles(
if (!Array.isArray(steps) || steps.length === 0) return [];
const out: 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) {
if (step.key !== "tool_calling:present_files") continue;
const fps = step.args?.filepaths;
if (!Array.isArray(fps)) continue;
for (const fp of fps) {
if (typeof fp !== "string" || !fp || seen.has(fp)) continue;
seen.add(fp);
out.push(fp);
if (step.key === "tool_calling:present_files") {
const fps = step.args?.filepaths;
if (Array.isArray(fps)) fps.forEach(add);
continue;
}
if (
step.key === "tool_calling:write_file" ||
step.key === "tool_calling:str_replace"
) {
add(step.args?.path);
}
}
return out;
@@ -50,11 +50,12 @@ export function Step2SandboxLayout({
}: Step2SandboxLayoutProps) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const {
artifacts,
artifacts: rawArtifacts,
open: artifactsOpen,
setOpen: setArtifactsOpen,
selectedArtifact,
} = useArtifacts();
const artifacts = rawArtifacts ?? [];
const artifactPanelOpen = artifactsOpen;
@@ -26,6 +26,7 @@ import {
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import {
DEFAULT_SELECTED_AGENTS,
MAX_CYCLES,
buildAgentNameToRole,
buildRoleTemplates,
@@ -98,8 +99,18 @@ export type Step2Snapshot = {
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 {
/** 当前页面 currentStep,===2 触发自动 init。 */
@@ -164,11 +175,11 @@ export interface UseStep2OrchestrationReturn {
pauseRoundtable: () => void;
/** 与 Step2Panel「恢复会商」按钮相同 —— 恢复自动研讨(含待处理的干预指令)。 */
resumeRoundtable: () => void;
/** 记录干预指令(不暂停);通常由 Step2Panel 在 pauseRoundtable 之后调用。 */
/** 记录干预指令(不暂停不恢复);通常由 Step2Panel 在 pauseRoundtable 之后调用。 */
recordInjectMessage: () => void;
/** 中断所有 in-flight SSE(给新建任务 / 加载草稿用)。 */
abortAllInFlight: () => void;
/** @deprecated 请用 pauseRoundtable + recordInjectMessage;保留兼容组合调用。 */
/** 一键干预:记录指令 + 自动恢复会商 + 触发新一轮编排(替代 pause + record + resume 组合)。 */
handleInjectToRoundtable: () => void;
/** 总控澄清回复 —— 把回答拼到 prompt 里再起一轮编排。 */
submitClarification: (rawAnswer: string) => void;
@@ -223,7 +234,7 @@ export function useStep2Orchestration(
} = useArtifacts();
const artifactPathsRef = useRef<string[]>([]);
useEffect(() => {
artifactPathsRef.current = artifacts;
artifactPathsRef.current = artifacts ?? [];
}, [artifacts]);
// 用户手动关掉沙箱后,autoOpen 会被 ArtifactsProvider 置 false —— 把它们打到 ref
// 上,让 openSeatVirtualArtifact 不用进 useCallback deps,避免每次 toggle 重建函数。
@@ -242,7 +253,9 @@ export function useStep2Orchestration(
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 [coordinatorName, setCoordinatorName] = useState<string | null>(null);
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 加载。
* 沙箱 write_file 实时预览请用 `openSeatVirtualArtifact`。
@@ -428,7 +486,27 @@ export function useStep2Orchestration(
const openArtifactPreview = useCallback(
(threadId: string, path: string, options?: { auto?: boolean }) => {
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);
const prev = artifactPathsRef.current;
@@ -449,7 +527,14 @@ export function useStep2Orchestration(
openPanel();
}
},
[selectArtifact, setArtifacts, setArtifactsOpen],
[
findSeatArtifactSource,
buildWriteFileVirtualUrl,
openSeatVirtualArtifact,
selectArtifact,
setArtifacts,
setArtifactsOpen,
],
);
const openArtifactFromStep = useCallback(
@@ -472,6 +557,16 @@ export function useStep2Orchestration(
openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
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") {
const filepaths = step.args?.filepaths;
if (Array.isArray(filepaths)) {
@@ -1166,13 +1261,41 @@ export function useStep2Orchestration(
confidence: "最高指令",
});
pendingInjectPromptRef.current = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
triggerToast("干预指令已记录,点击「恢复会商」后执行");
triggerToast("干预指令已记录,会商已自动恢复执行");
}, [appendDialogue, customAgentInput, triggerToast]);
const handleInjectToRoundtable = useCallback(() => {
pauseRoundtable();
recordInjectMessage();
}, [pauseRoundtable, recordInjectMessage]);
const userText = customAgentInput.trim();
if (!userText) return;
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(
(rawAnswer: string) => {
@@ -24,7 +24,23 @@ export type RoleEntry = {
/** 用户在推荐弹窗选定的一个研讨席位。 */
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 = [
"blue",
"purple",
@@ -33,12 +49,28 @@ export const FALLBACK_AVATAR_TYPES = [
"pink",
] 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 {
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*```/;
/**
* 把 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] 标记的气泡正文拆成前 / 摘要 / 后三段。
* 摘要 JSON 格式异常时整体保留在 `before`,用户仍能看到原文。
*
* 提取不再依赖严格的 ```json 围栏正则:先定位 [INTENT_READY](大小写不敏感),
* 再用括号配对抓出后面的第一个完整 JSON 对象,最后多级降级 parse。这样模型漏写
* 代码围栏、JSON 里有裸换行 / 尾随逗号、字段类型写偏等情况都能尽量恢复。
*/
export function splitIntentReadyBlock(text: string): {
before: string;
@@ -118,20 +323,16 @@ export function splitIntentReadyBlock(text: string): {
after: string;
} {
if (!text) return { before: text, summary: null, after: "" };
const m = INTENT_READY_RE.exec(text);
if (!m) return { before: text, summary: null, after: "" };
let summary: IntentSummary | null = null;
try {
const parsed = JSON.parse(m[1]);
if (parsed && typeof parsed === "object" && typeof parsed.objective === "string") {
summary = parsed as IntentSummary;
}
} catch {
return { before: text, summary: null, after: "" };
}
const marker = INTENT_READY_MARKER_RE.exec(text);
if (!marker) return { before: text, summary: null, after: "" };
const block = extractBalancedJson(text, marker.index + marker[0].length);
if (!block) return { before: text, summary: null, after: "" };
const summary = parseIntentSummary(block.json);
if (!summary) return { before: text, summary: null, after: "" };
return {
before: text.slice(0, m.index).trimEnd(),
before: text.slice(0, marker.index).trimEnd(),
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";
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 / 命令代码块等)。
@@ -34,6 +36,7 @@ import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-th
export function renderStepArgs(
key: string,
args?: Record<string, unknown>,
resolveAgentName?: (idOrName: string) => string,
): React.ReactNode {
if (!args) return null;
const [, toolName] = key.split(":");
@@ -43,15 +46,18 @@ export function renderStepArgs(
const agentName = typeof args.agent_name === "string" ? args.agent_name : "";
const task = typeof args.task === "string" ? args.task : "";
if (!agentName && !task) return null;
// 派活 chip 显示中文席位名而非 agent id(客户自建 agent 的 id 常是一串 hex)。
const displayName =
agentName && resolveAgentName ? resolveAgentName(agentName) : agentName;
return (
<>
{agentName && (
<ChainOfThoughtSearchResult>{agentName}</ChainOfThoughtSearchResult>
{displayName && (
<ChainOfThoughtSearchResult>{displayName}</ChainOfThoughtSearchResult>
)}
{task && (
<span className="block text-[11px] text-slate-600 line-clamp-3 mt-0.5">
{task}
</span>
<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">
<MarkdownContent>{task}</MarkdownContent>
</div>
)}
</>
);
@@ -75,9 +81,14 @@ export function renderStepArgs(
case "bash": {
const command = typeof args.command === "string" ? args.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">
{command}
</code>
<div className="mt-1 max-h-60 overflow-auto rounded-md border border-slate-200 [&_pre]:px-2.5! [&_pre]:py-1.5!">
<CodeBlock
className="rounded-none border-none"
showLineNumbers={false}
language="bash"
code={command}
/>
</div>
) : null;
}
case "write_todos": {
@@ -62,6 +62,7 @@ import {
import { useStep2Orchestration } from "@/roundtable-planning/hooks/useStep2Orchestration";
import { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence";
import {
BUILTIN_AGENT_META,
getAvatarTypeFor,
getDisplayNameFor,
makeRolesFromTemplates,
@@ -259,7 +260,8 @@ export default function RoundtablePlanningPage() {
(agents: Array<{ agent_id: string; name: string }>) => {
const normalized: SelectedAgent[] = agents.map(({ agent_id, name }) => ({
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.resetInitGate();
@@ -486,6 +488,7 @@ export default function RoundtablePlanningPage() {
agentConfigs={step2.agentConfigs}
selectedAgentId={step2.selectedAgentId}
roles={roles}
agentNameToRole={step2.agentNameToRole}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
@@ -496,7 +499,7 @@ export default function RoundtablePlanningPage() {
openArtifactPreview={step2.openArtifactPreview}
onPauseRoundtable={step2.pauseRoundtable}
onResumeRoundtable={step2.resumeRoundtable}
onRecordInjectMessage={step2.recordInjectMessage}
onHandleInjectToRoundtable={step2.handleInjectToRoundtable}
setClarificationDraft={step2.setClarificationDraft}
setCustomAgentInput={step2.setCustomAgentInput}
setAgentConfigs={step2.setAgentConfigs}
@@ -893,7 +896,9 @@ function LeftSidebar({
...selectedAgents.map((agent, i) => ({
id: agent.agent_id,
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"),
})),
].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:
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 调度器:常驻后台任务,每小时检查一次需不需要进化
# (per-user interval_hours 仍是真正决定要不要跑进化的闸)。
try:
@@ -406,7 +406,7 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
> 以下两个示例覆盖**不同任务类型**,目的是教你「思路要呼应任务本身」,**不要**把任何一个示例的具体职能链当模板。
### 示例 A:合规改造任务(领域候选池)
### 示例 A:合规改造任务(职能型候选池)
**用户输入**:
```
@@ -415,11 +415,12 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
关键假设:现有系统具备基础数字化能力;允许预定窗口停机
候选智能体池:
- compliance-research (合规调研):检索同类历史案例、行业最佳实践与监管口径
- infra-capacity (基础设施评估):评估硬件容量与系统量化承载力
- solution-architect (方案架构):产出主备方案与关键路径
- risk-auditor (风险审查):合规红线检查与收敛建议
- execution-planner (执行规划):里程碑与资源排布
- roundtable-intelligence (情报收集):检索同类历史案例、行业最佳实践与外部情报
- roundtable-environment (环境评估):评估硬件容量与系统量化承载力
- roundtable-solution-design (方案设计):产出主备方案与关键路径
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
- roundtable-execution-plan (执行规划):里程碑与资源排布
- roundtable-summary (总结陈述):归纳冲突标记与盖印
- my-cost-calc (成本测算):基于预算和资源单价计算总成本
```
@@ -430,10 +431,10 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
```json
{
"picks": [
{"agent_id": "compliance-research", "reason": "检索同类等保 2.0 改造案例与监管口径,直接支撑本次合规对标"},
{"agent_id": "solution-architect", "reason": "在 6 个月工期约束下产出主备改造方案与关键路径"},
{"agent_id": "risk-auditor", "reason": "对照等保 2.0 合规红线审查方案,避免后期返工"},
{"agent_id": "execution-planner", "reason": "把改造拆成可在 6 个月内交付的里程碑与资源排布"},
{"agent_id": "roundtable-intelligence", "reason": "检索同类等保 2.0 改造案例与监管口径,直接支撑本次合规对标"},
{"agent_id": "roundtable-solution-design", "reason": "在 6 个月工期约束下产出主备改造方案与关键路径"},
{"agent_id": "roundtable-risk-review", "reason": "对照等保 2.0 合规红线审查方案,避免后期返工"},
{"agent_id": "roundtable-execution-plan", "reason": "把改造拆成可在 6 个月内交付的里程碑与资源排布"},
{"agent_id": "my-cost-calc", "reason": "校验整体方案落在 500 万元预算硬约束内"}
]
}
@@ -452,7 +453,7 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
- channel-planner (渠道规划师):按预算和受众设计跨平台投放组合
- audience-insight (受众洞察):画像、关键词、情绪点研究
- creative-copy (创意文案):产出落地页与社媒文案变体
- ad-compliance (广告合规):审查文案与素材合规风险
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
```
**你的输出**:
@@ -470,8 +471,8 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
}
```
> **关键观察**:两个示例都从候选池里按任务语义挑选,**不要**套用固定职能链(如"调研→设计→风险→执行")。
> 候选池里的智能体能不能匹配任务,只看 description 和任务的实际语义。如果候选池里有"广告设计智能体"且任务是广告设计,**你必须挑它**,而不是机械复制示例 A 的链路。
> **关键观察**:示例 A 选了 `roundtable-intelligence / solution-design / execution-plan` 等"职能型"智能体,示例 B **完全没用任何 `roundtable-*` 内置**,而是从用户自建的领域智能体里挑。
> 这是因为:**候选池里的智能体能不能匹配任务,只看 description 和任务的实际语义,不看 id 是不是 `roundtable-` 开头**。如果候选池里有"广告设计智能体"且任务是广告设计,**你必须挑它**,而不是套用示例 A 的职能链。
"""
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import logging
import shutil
import time
from datetime import datetime
from typing import Any
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
``.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:
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")
# 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:
"""Lazily restore an agent's on-disk artifacts from its DB record.
@@ -262,14 +306,29 @@ def _record_to_response(
record: dict[str, Any],
*,
include_soul: bool = False,
include_extras: bool = True,
) -> 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
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:
soul = load_agent_soul(record["id"]) or ""
extras = _load_config_extras(record["id"])
return AgentResponse(
id=record["id"],
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]:
owned = await store.get_owned(agent_id, user_id)
if owned is not None:
@@ -349,7 +427,7 @@ async def list_agents(
try:
store = get_agent_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)
if admin:
if not _is_admin_user(request):
@@ -357,7 +435,14 @@ async def list_agents(
agents = await store.list_all()
else:
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])
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()
try:
store = get_agent_store(request)
await _sync_legacy_agents(store)
await _maybe_sync_legacy_agents(store)
user_id = _current_user_id(request)
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:
raise
except Exception as e:
@@ -427,12 +519,15 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
try:
store = get_agent_store(request)
await _sync_legacy_agents(store)
await _maybe_sync_legacy_agents(store)
user_id = _current_user_id(request)
record = await store.get_visible(agent_id, user_id)
if record is None:
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)
response.is_favorite = agent_id in favorite_ids
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)
return _record_to_response(record, include_soul=True)
return await _record_to_response_async(record, include_soul=True, include_extras=True)
except HTTPException:
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")
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:
raise
@@ -744,7 +839,7 @@ async def set_agent_featured(
try:
store = get_agent_store(request)
await _sync_legacy_agents(store)
await _maybe_sync_legacy_agents(store)
record = await store.get_any(agent_id)
if record is None:
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:
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)
return _record_to_response(updated, include_soul=True)
return await _record_to_response_async(updated, include_soul=True, include_extras=True)
except HTTPException:
raise
except Exception as e:
@@ -65,6 +65,7 @@ from fastapi.responses import StreamingResponse
# identical in connection handling, payload shape, and error mapping.
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
_create_thread,
_diagnose_exception,
@@ -133,6 +134,70 @@ def _normalize_options(raw: object) -> tuple[str, ...]:
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(
messages: list[dict[str, Any]],
*,
@@ -222,7 +287,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
# 看不到逐 token 增量。
try:
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:
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
raise
@@ -279,33 +344,70 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。
async with _make_loopback_client(request) as client:
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(
agent_name=INTENT_AGENT_ID,
model_name=model_name,
thread_id=thread_id,
new_message=message,
# Intent agent only ever needs ask_clarification. Block
new_message=effective_message,
# 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
# (e.g. saving a design doc with write_file) — the model
# tends to ignore SOUL prose, hard exclusion is reliable.
# 仅保留 ask_clarification;其余工具见模块 docstring「工具白名单」。
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",
],
# (e.g. saving a design doc with write_file) — SOUL prose is
# not reliable on its own, hard exclusion is.
excluded_tools=base_excluded_tools,
skill_stop_names=[],
)
@@ -338,7 +440,13 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
except json.JSONDecodeError as exc:
logger.warning("intent summary JSON parse failed: %s", exc)
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
if summary and ready_text:
final_frame = {
@@ -53,18 +53,28 @@ import os
import re
import time
import traceback
import uuid
from typing import Any, AsyncIterator
import httpx
from deerflow.config import get_app_config
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.responses import StreamingResponse
from langgraph.checkpoint.base import empty_checkpoint
logger = logging.getLogger(__name__)
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 共用)
# ---------------------------------------------------------------------------
@@ -285,9 +295,22 @@ def _auth_headers(request: Request) -> dict[str, str]:
return {"Authorization": token, "Content-Type": "application/json"}
async def _create_thread(client: httpx.AsyncClient, base: str, headers: dict[str, str]) -> str:
"""``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。"""
resp = await client.post(f"{base}/api/threads", json={}, headers=headers)
async def _create_thread(
client: httpx.AsyncClient,
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()
return resp.json()["thread_id"]
@@ -323,6 +346,181 @@ async def _create_agent(
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(
client: httpx.AsyncClient,
base: str,
@@ -456,6 +654,8 @@ def _run_payload(
new_message: str,
excluded_tools: list[str],
skill_stop_names: list[str],
thinking_enabled: bool = True,
reasoning_effort: str = "medium",
) -> dict[str, Any]:
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
@@ -463,6 +663,10 @@ def _run_payload(
- ``excluded_tools``:按路由裁剪工具面(intent 仅澄清、special 禁派活等)。
- ``skill_stop_names``:leader 侧配合 SkillStopMiddleware 在 ``agent_orchestration``
调用后截断图,以便网关从 AIMessage.tool_calls 解析派活列表。
- ``thinking_enabled`` / ``reasoning_effort``:默认开 CoT + medium,适合 Step 1 澄清
与子智能体交付这类需要推理的场景。**推荐弹窗与总控派活**是短决策任务,调用方
应传 ``thinking_enabled=False`` + ``reasoning_effort="low"``,省掉模型先吐
reasoning tokens 才开口的时间,显著降低前端"调度中"首字节延迟。
"""
return {
"input": {
@@ -479,11 +683,17 @@ def _run_payload(
"agent_name": agent_name,
"model_name": model_name,
"mode": "pro",
"reasoning_effort": "medium",
"thinking_enabled": True,
"reasoning_effort": reasoning_effort,
"thinking_enabled": thinking_enabled,
"is_plan_mode": False,
"subagent_enabled": False,
"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_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(main_agent_name, "main_agent_name")
base = _loopback_base(request)
headers = _auth_headers(request)
def _frame(payload: dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
async def stream_generator() -> AsyncIterator[str]:
# TCP loopback 到 127.0.0.1:<gateway port>。client 配置见 _make_loopback_client:
# trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。
async with _make_loopback_client(request) as client:
try:
# Per-seat prepare task — same body as before, but we no longer
# await them with asyncio.gather. Instead, asyncio.as_completed
# surfaces them in REAL completion order so we can emit a
# seat_ready frame the moment each finishes.
async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str, str]:
info = await _get_agent(client, base, headers, sub_id)
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
# init 阶段 fast path:全部走同进程直调(_create_thread_direct /
# _get_agents_batch_direct / _create_coordinator_direct),不再走 HTTP
# loopback —— TCP + 鉴权中间件 + Pydantic 反序列化每次 10-30ms,N+1 个席位
# 累计就是"点进 Step 2 后席位一颗颗亮"的卡顿来源。
try:
t_init_start = time.perf_counter()
# 1) 一次 DB 查询拿到所有席位的 name/description,替代之前的 N 个并行
# GET /api/agents/{id}。SOUL 拼接需要描述,这里一次性拿齐。
agent_meta = await _get_agents_batch_direct(request, agent_names)
t_meta_done = time.perf_counter()
tasks = [asyncio.create_task(_prepare_sub_agent(sid)) for sid in agent_names]
prepared: list[tuple[str, str, str, str]] = []
try:
for fut in asyncio.as_completed(tasks):
sid, thread_id, display_name, description = await fut
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 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)
# 2) 每个席位 thread 并行创建(直调 thread_store + checkpointer),
# 用 asyncio.as_completed 保持"先完先发 seat_ready"的体验。
async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str, str]:
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
meta = agent_meta.get(sub_id.lower(), {})
return (
sub_id.lower(),
thread_id,
str(meta.get("name") or sub_id),
str(meta.get("description") or ""),
)
coord_thread = await _create_thread(client, base, headers)
agent_threads[main_agent_name] = coord_thread
ok, result = await _create_agent(
client,
base,
headers,
tasks = [asyncio.create_task(_prepare_sub_agent(sid)) for sid in agent_names]
prepared: list[tuple[str, str, str, str]] = []
try:
for fut in asyncio.as_completed(tasks):
sid, thread_id, display_name, description = await fut
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,
description="你是总控控制协调智能体,负责协调其他智能体干活",
soul=soul,
model=model,
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}"})
return
)
coord_thread = await coord_thread_task
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({
"event": "coordinator_ready",
"main_agent_name": main_agent_name,
"thread_id": coord_thread,
})
yield _frame({
"event": "init_done",
"main_agent_name": main_agent_name,
"thread_ids": agent_threads,
})
except HTTPException as exc:
logger.warning("multi-agent init HTTPException: %s %s", exc.status_code, exc.detail)
yield _frame({"event": "error", "detail": f"[{exc.status_code}] {exc.detail}"})
except Exception as exc:
logger.exception("multi-agent init unexpected failure")
yield _frame({"event": "error", "detail": f"unexpected: {exc!r}"})
yield _frame({
"event": "coordinator_ready",
"main_agent_name": main_agent_name,
"thread_id": coord_thread,
})
yield _frame({
"event": "init_done",
"main_agent_name": main_agent_name,
"thread_ids": agent_threads,
})
except HTTPException as exc:
logger.warning("multi-agent init HTTPException: %s %s", exc.status_code, exc.detail)
yield _frame({"event": "error", "detail": f"[{exc.status_code}] {exc.detail}"})
except Exception as exc:
logger.exception("multi-agent init unexpected failure")
yield _frame({"event": "error", "detail": f"unexpected: {exc!r}"})
return StreamingResponse(
stream_generator(),
@@ -825,6 +1056,11 @@ async def _leader_run(
new_message=new_message,
excluded_tools=["web_search"],
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()
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.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
_create_thread,
_flatten_content,
@@ -237,7 +238,7 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
async with _make_loopback_client(request) as client:
try:
# 每次推荐独立 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(
agent_name=RECOMMENDER_AGENT_ID,
@@ -253,6 +254,12 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
"ask_clarification",
],
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] = []
@@ -137,7 +137,17 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
召回结果包在 ``<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:
return None
config = self._memory_config or get_memory_config()
@@ -1,7 +1,9 @@
"""Configuration and loaders for custom agents."""
import logging
import os
import re
import threading
from typing import Any
import yaml
@@ -13,6 +15,25 @@ logger = logging.getLogger(__name__)
SOUL_FILENAME = "SOUL.md"
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;
# after the DB-backed agent table, display names can be Chinese and duplicated.
# 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
# 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] = {}
if config_file.exists():
try:
@@ -112,7 +145,11 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None:
known_fields = set(AgentConfig.model_fields.keys())
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:
@@ -131,8 +168,21 @@ def load_agent_soul(agent_id: str | None) -> str | None:
soul_path = agent_dir / SOUL_FILENAME
if not soul_path.exists():
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()
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]:
+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