Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
# Conflicts: # frontend-web/src/components/workspace/chats/chat-box.tsx # offline-backend-20260512/backend/app/gateway/routers/agents.py
This commit is contained in:
commit
6792b4f4c4
200 files changed
+78520
-392
No files matched your search
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
+10
@@ -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]:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -0,0 +1,175 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Upload, FileText, Calendar, Users, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface CreateTaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (taskData: any) => void;
|
||||
}
|
||||
|
||||
const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onCreate }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
scene: '宣传策略生成',
|
||||
deadline: '',
|
||||
resources: '',
|
||||
constraints: '',
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.name || !formData.scene) {
|
||||
alert('请填写必填项');
|
||||
return;
|
||||
}
|
||||
onCreate(formData);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative bg-slate-800/95 backdrop-blur-xl border border-blue-800/30 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-blue-800/30">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">新建任务</h2>
|
||||
<p className="text-slate-400 text-sm mt-1">创建一个新的宣传策略分析任务</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-700/50 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-5 max-h-[60vh] overflow-y-auto">
|
||||
{/* Task Name */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">
|
||||
任务名称 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="例如:五四青年节宣传活动策略"
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Task Description */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">
|
||||
任务描述
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="详细描述任务目标和背景..."
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scene Selection */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">
|
||||
选择场景 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.scene}
|
||||
onChange={(e) => setFormData({ ...formData, scene: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
>
|
||||
<option value="宣传策略生成">宣传策略生成</option>
|
||||
<option value="宣传方案推演">宣传方案推演</option>
|
||||
<option value="宣传活动复盘">宣传活动复盘</option>
|
||||
<option value="舆情应对策略">舆情应对策略</option>
|
||||
<option value="多版本策略比较">多版本策略比较</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Deadline */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
时间要求
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.deadline}
|
||||
onChange={(e) => setFormData({ ...formData, deadline: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
资源条件
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.resources}
|
||||
onChange={(e) => setFormData({ ...formData, resources: e.target.value })}
|
||||
placeholder="例如:预算10万,运营团队3人..."
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Constraints */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
风险约束
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={formData.constraints}
|
||||
onChange={(e) => setFormData({ ...formData, constraints: e.target.value })}
|
||||
placeholder="描述任务的限制条件和风险边界..."
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
附件/参考资料
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-blue-800/30 rounded-lg p-6 text-center hover:border-blue-500/50 transition-colors cursor-pointer">
|
||||
<Upload className="w-8 h-8 text-slate-500 mx-auto mb-2" />
|
||||
<p className="text-slate-400 text-sm">点击或拖拽文件到此处上传</p>
|
||||
<p className="text-slate-500 text-xs mt-1">支持 PDF、Word、图片等格式</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-blue-800/30 bg-slate-900/30">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 bg-slate-700/50 hover:bg-slate-600/50 border border-blue-800/30 text-slate-300 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
开始分析
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTaskModal;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
const searilizeError = (error: any) => {
|
||||
if (error instanceof Error) {
|
||||
return error.message + '\n' + error.stack;
|
||||
}
|
||||
return JSON.stringify(error, null, 2);
|
||||
};
|
||||
|
||||
export class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error: any }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: any) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="p-4 border border-red-500 rounded">
|
||||
<h2 className="text-red-500">Something went wrong.</h2>
|
||||
<pre className="mt-2 text-sm">{searilizeError(this.state.error)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Bell, Search, User, ChevronDown, Moon, Sun, Palette } from 'lucide-react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
export interface userData {
|
||||
userName: string;
|
||||
typeName: string;
|
||||
}
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [userData, setUserData] = useState<userData>({
|
||||
userName: '',
|
||||
typeName: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let userInfo: any = localStorage.getItem('userInfo');
|
||||
if (userInfo) {
|
||||
userInfo = JSON.parse(userInfo);
|
||||
setUserData(userInfo);
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<header className={`h-16 flex items-center justify-between px-6 theme-transition ${
|
||||
isDark
|
||||
? 'bg-slate-900/50 backdrop-blur-xl border-b border-blue-800/30'
|
||||
: 'bg-red-600 border-b border-red-700'
|
||||
}`}>
|
||||
{/* 左侧 - 搜索框 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* <div className="relative">
|
||||
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${isDark ? 'text-slate-500' : 'text-red-200'}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索场景、智能体、任务..."
|
||||
className={`w-80 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-white/20 transition-all duration-200 theme-transition ${
|
||||
isDark
|
||||
? 'bg-slate-800/50 border border-blue-800/30 text-white placeholder-slate-500'
|
||||
: 'bg-red-700 border border-red-800 text-white placeholder-red-200'
|
||||
}`}
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* 右侧 - 用户信息和通知 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`p-2 rounded-lg transition-all duration-200 ${
|
||||
isDark
|
||||
? 'hover:bg-slate-800/50 text-slate-400 hover:text-white'
|
||||
: 'hover:bg-red-500 text-white'
|
||||
}`}
|
||||
title={isDark ? '切换到红白主题' : '切换到深色主题'}
|
||||
>
|
||||
{isDark ? (
|
||||
<Palette className="w-5 h-5" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 通知 */}
|
||||
{/* <button className={`relative p-2 rounded-lg transition-colors ${
|
||||
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
|
||||
}`}>
|
||||
<Bell className={`w-5 h-5 ${isDark ? 'text-slate-400' : 'text-white'}`} />
|
||||
<span className={`absolute top-1 right-1 w-2 h-2 rounded-full ${isDark ? 'bg-cyan-500' : 'bg-white'}`} />
|
||||
</button> */}
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className={`w-px h-8 ${isDark ? 'bg-blue-800/30' : 'bg-red-500'}`} />
|
||||
|
||||
{/* 用户信息 */}
|
||||
<button className={`flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors ${
|
||||
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
|
||||
}`}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-blue-500 to-cyan-400'
|
||||
: 'bg-white'
|
||||
}`}>
|
||||
<User className={`w-4 h-4 ${isDark ? 'text-white' : 'text-red-600'}`} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-white' : 'text-white'}`}>{userData.userName}</p>
|
||||
<p className={`text-xs ${isDark ? 'text-blue-400/60' : 'text-red-200'}`}>{userData.typeName}</p>
|
||||
</div>
|
||||
{/* <ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-white'}`} /> */}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
onCitationClick?: (index: number) => void;
|
||||
}
|
||||
|
||||
const extractTextFromNode = (node: React.ReactNode): string => {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
if (Array.isArray(node)) return node.map(extractTextFromNode).join('');
|
||||
if (React.isValidElement<{ children?: React.ReactNode }>(node)) return extractTextFromNode(node.props.children);
|
||||
return '';
|
||||
};
|
||||
|
||||
const CodeBlock: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const codeText = extractTextFromNode(children).replace(/\n$/, '');
|
||||
const language = className?.match(/language-(\w+)/)?.[1];
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(codeText);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch (error) {
|
||||
console.error('复制代码失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative my-4 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 shadow-sm dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400">
|
||||
<span>{language || 'code'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
|
||||
>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="m-0 overflow-x-auto p-4 text-sm leading-relaxed text-gray-800 dark:text-slate-100">
|
||||
<code className={className}>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '', onCitationClick }) => {
|
||||
const contentWithCitationBadge = content.replace(
|
||||
/\[\[(\d+)\]\]/g,
|
||||
'<button type="button" data-citation-index="$1" class="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-2 mx-0.5 rounded-md border text-xs font-semibold align-middle bg-red-50 text-red-600 border-red-200 dark:bg-slate-700 dark:text-slate-100 dark:border-slate-500 cursor-pointer transition-all hover:opacity-85">[$1]</button>',
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`markdown-content ${className}`}
|
||||
onClick={(event) => {
|
||||
if (!onCitationClick) return;
|
||||
const target = event.target as HTMLElement;
|
||||
const button = target.closest('[data-citation-index]') as HTMLElement | null;
|
||||
if (!button) return;
|
||||
const index = Number(button.dataset.citationIndex || '0');
|
||||
if (Number.isNaN(index) || index <= 0) return;
|
||||
onCitationClick(index);
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{
|
||||
h1: ({ node, ...props }) => <h1 className="text-2xl font-bold mb-4" {...props} />,
|
||||
h2: ({ node, ...props }) => <h2 className="text-xl font-bold mb-3" {...props} />,
|
||||
h3: ({ node, ...props }) => <h3 className="text-lg font-bold mb-2" {...props} />,
|
||||
h4: ({ node, ...props }) => <h4 className="text-base font-bold mb-2" {...props} />,
|
||||
h5: ({ node, ...props }) => <h5 className="text-sm font-bold mb-2" {...props} />,
|
||||
h6: ({ node, ...props }) => <h6 className="text-xs font-bold mb-2" {...props} />,
|
||||
p: ({ node, ...props }) => <p className="mb-4 leading-relaxed" {...props} />,
|
||||
ul: ({ node, ...props }) => <ul className="list-disc list-inside mb-4 ml-4" {...props} />,
|
||||
ol: ({ node, ...props }) => <ol className="list-decimal list-inside mb-4 ml-4" {...props} />,
|
||||
li: ({ node, ...props }) => <li className="mb-1" {...props} />,
|
||||
// code: ({ node, inline, className, children, ...props }: any) => {
|
||||
// if (inline) {
|
||||
// return (
|
||||
// <code
|
||||
// className={`rounded bg-gray-100 px-1 py-0.5 text-sm text-gray-800 dark:bg-slate-800 dark:text-slate-100 ${className || ''}`}
|
||||
// {...props}
|
||||
// >
|
||||
// {children}
|
||||
// </code>
|
||||
// );
|
||||
// }
|
||||
// return <CodeBlock className={className}>{children}</CodeBlock>;
|
||||
// },
|
||||
// pre: ({ node, children }) => <>{children}</>,
|
||||
blockquote: ({ node, ...props }) => (
|
||||
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic my-4" {...props} />
|
||||
),
|
||||
a: ({ node, ...props }) => (
|
||||
<a className="text-blue-600 dark:text-blue-400 hover:underline" {...props} />
|
||||
),
|
||||
table: ({ node, ...props }) => (
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="min-w-full border-collapse" {...props} />
|
||||
</div>
|
||||
),
|
||||
thead: ({ node, ...props }) => <thead className="bg-gray-100 dark:bg-gray-800" {...props} />,
|
||||
tbody: ({ node, ...props }) => <tbody className="divide-y divide-gray-200 dark:divide-gray-700" {...props} />,
|
||||
tr: ({ node, ...props }) => <tr className="hover:bg-gray-50 dark:hover:bg-gray-700" {...props} />,
|
||||
th: ({ node, ...props }) => <th className="px-4 py-2 text-left font-semibold" {...props} />,
|
||||
td: ({ node, ...props }) => <td className="px-4 py-2" {...props} />,
|
||||
hr: ({ node, ...props }) => <hr className="my-4 border-gray-300 dark:border-gray-600" {...props} />,
|
||||
strong: ({ node, ...props }) => <strong className="font-bold" {...props} />,
|
||||
em: ({ node, ...props }) => <em className="italic" {...props} />,
|
||||
}}
|
||||
>
|
||||
{contentWithCitationBadge}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownRenderer;
|
||||
@@ -0,0 +1,572 @@
|
||||
import React, { useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
BarChart3,
|
||||
Bot,
|
||||
Brain,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
Compass,
|
||||
Database,
|
||||
FileBox,
|
||||
FileOutput,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Grid3X3,
|
||||
HelpCircle,
|
||||
History,
|
||||
Layers,
|
||||
Layout,
|
||||
Library,
|
||||
ListTodo,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Network,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
Scale,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
|
||||
export type PageType = string;
|
||||
|
||||
type MenuItem = {
|
||||
id: PageType;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
children?: MenuItem[];
|
||||
path?: string;
|
||||
activePath?: string;
|
||||
};
|
||||
type Props = {
|
||||
currentPage: PageType;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
};
|
||||
type Level1MenuId =
|
||||
| "qa-center"
|
||||
| "light-app-center"
|
||||
| "task-management"
|
||||
| "agent-list"
|
||||
| "result-management"
|
||||
| "resource-management";
|
||||
type TaskGroupId = string;
|
||||
|
||||
const taskGroups: TaskGroupId[] = [
|
||||
"xk-task",
|
||||
"plan-task",
|
||||
"mnty-task",
|
||||
"effect-task",
|
||||
];
|
||||
const collapsibleMenus = new Set<PageType>([
|
||||
"qa-center",
|
||||
"light-app-center",
|
||||
"task-management",
|
||||
"agent-list",
|
||||
"result-management",
|
||||
"resource-management",
|
||||
]);
|
||||
const taskPages = new Set([
|
||||
"dashboard",
|
||||
"task-create",
|
||||
"task-single",
|
||||
"task-scene",
|
||||
"task-consult-create",
|
||||
"task-consult",
|
||||
"task-consult-result",
|
||||
"task-list",
|
||||
]);
|
||||
const qaPages = new Set([
|
||||
"new-qa",
|
||||
"general-qa",
|
||||
"agent-qa",
|
||||
"multi-agent-qa",
|
||||
"qa-records",
|
||||
"sixstep-chat",
|
||||
"topic-research",
|
||||
"task-chat",
|
||||
"action-chat",
|
||||
"qa-from",
|
||||
"qa-from-6bf"
|
||||
]);
|
||||
const lightAppPages = new Set([
|
||||
"light-app-square",
|
||||
"light-app-records",
|
||||
"light-app-run",
|
||||
]);
|
||||
const agentPages = new Set(["agent-trial"]);
|
||||
const resultPages = new Set([
|
||||
"result-planning",
|
||||
"result-mnty",
|
||||
"result-effect-pg",
|
||||
"result-xk",
|
||||
"result-dq",
|
||||
"result-other",
|
||||
"result-detail",
|
||||
]);
|
||||
const resourcePages = new Set([
|
||||
"resource-zz",
|
||||
"resource-network-actor",
|
||||
"resource-knowledge-base",
|
||||
"resource-evaluation-index",
|
||||
]);
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
label: "问答管理",
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "qa-from", label: "通用问答", icon: Bot }, // general-qa
|
||||
// { id: 'sixstep-chat', label: 'zz决策支持', icon: Layers },
|
||||
{ id: "qa-from-6bf", label: "zz决策支持", icon: Layers },
|
||||
{ id: "action-chat", label: "zzXD应用", icon: History },
|
||||
{ id: "topic-research", label: "课题研究", icon: CircleHelp },
|
||||
// { id: 'agent-qa', label: '智能体问答', icon: Bot },
|
||||
// { id: 'multi-agent-qa', label: '多智能体协同', icon: Layers },
|
||||
// { id: 'qa-records', label: '问答记录', icon: History },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "task-management",
|
||||
label: "任务管理",
|
||||
icon: Target,
|
||||
children: [
|
||||
// { id: "dashboard", label: "任务统计", icon: BarChart3 },
|
||||
{
|
||||
id: "xk-task",
|
||||
label: "XK中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo },
|
||||
{ id: "xk-task-dispatch", label: "任务调度", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "plan-task",
|
||||
label: "COH中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "plan-task-sentiment", label: "三情FX", icon: ListTodo },
|
||||
{ id: "plan-task-fx", label: "任务FX", icon: ListTodo },
|
||||
{ id: "plan-task-action", label: "行动CE", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "effect-task",
|
||||
label: "PG中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "effect-task-pg", label: "任务PG", icon: ListTodo },
|
||||
{ id: "effect-action-pg", label: "行动PG", icon: ListTodo },
|
||||
{ id: "effect-info-pg", label: "信息PG", icon: ListTodo },
|
||||
// { id: "effect-account-pg", label: "账号PG", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mnty-task",
|
||||
label: "TY中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "mnty-task-mechanism", label: "XDTY任务", icon: ListTodo },
|
||||
{ id: "mnty-task-plan", label: "方案TY任务", icon: ListTodo },
|
||||
{ id: "mnty-task-zsty", label: "XXTY任务", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// id: 'light-app-center', label: '工具管理', icon: Grid3X3,
|
||||
// children: [
|
||||
// { id: 'light-app-square', label: '应用广场', icon: Layout },
|
||||
// { id: 'light-app-records', label: '运行记录', icon: History },
|
||||
// { id: 'scene-list', label: '场景管理', icon: Layers },
|
||||
// ]
|
||||
// },
|
||||
{
|
||||
id: "agent-list",
|
||||
label: " 综合管理",
|
||||
icon: Bot,
|
||||
children: [
|
||||
// { id: 'agent-list', label: '业务链条管理', icon: Users },
|
||||
// { id: 'agent-list', label: '智能体管理', icon: Users },
|
||||
{ id: "agent-expert-center", label: "智能体管理", icon: Users },
|
||||
{ id: 'scene-list', label: '业务链条管理', icon: Layers },
|
||||
// { id: "skills-workshop", label: "技能列表", icon: Users },
|
||||
// { id: "sixstep-agents", label: "mcp管理", icon: Layers },
|
||||
// { id: "file-skills", label: "skills管理", icon: Layers },
|
||||
// { id: 'agent-trial', label: '单智能体试用', icon: PlayCircle }
|
||||
],
|
||||
},
|
||||
// { id: 'result-management', label: '结果管理', icon: BarChart3, children: [
|
||||
// { id: 'result-planning', label: '规划任务', icon: Target, children: [
|
||||
// { id: 'result-planning-all', label: '全部', icon: BarChart3, path: '/result-management/result-planning', activePath: '/result-management/result-planning' },
|
||||
// { id: 'result-planning-goal', label: '目标分析', icon: Target, path: '/result-management/result-planning?subCategory=目标分析' },
|
||||
// { id: 'result-planning-brand', label: '品牌策略', icon: Target, path: '/result-management/result-planning?subCategory=品牌策略' },
|
||||
// { id: 'result-planning-content', label: '内容规划', icon: Target, path: '/result-management/result-planning?subCategory=内容规划' },
|
||||
// { id: 'result-planning-channel', label: '渠道规划', icon: Target, path: '/result-management/result-planning?subCategory=渠道规划' },
|
||||
// { id: 'result-planning-narrative', label: '叙事策略', icon: Target, path: '/result-management/result-planning?subCategory=叙事策略' },
|
||||
// ] },
|
||||
// { id: 'result-mnty', label: 'MNTY任务', icon: Brain, children: [
|
||||
// { id: 'result-mnty-all', label: '全部', icon: BarChart3, path: '/result-management/result-mnty', activePath: '/result-management/result-mnty' },
|
||||
// { id: 'result-mnty-mechanism', label: '机制研究', icon: Brain, path: '/result-management/result-mnty?subCategory=机制研究' },
|
||||
// { id: 'result-mnty-plan', label: '方案设计', icon: Brain, path: '/result-management/result-mnty?subCategory=方案设计' },
|
||||
// { id: 'result-mnty-zsty', label: 'ZSTY分析', icon: Brain, path: '/result-management/result-mnty?subCategory=ZSTY分析' },
|
||||
// ] },
|
||||
// { id: 'result-effect-pg', label: '效果PG任务', icon: TrendingUp, children: [
|
||||
// { id: 'result-effect-pg-all', label: '全部', icon: BarChart3, path: '/result-management/result-effect-pg', activePath: '/result-management/result-effect-pg' },
|
||||
// { id: 'result-effect-pg-behavior', label: '行为PG', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=行为PG' },
|
||||
// { id: 'result-effect-pg-evaluation', label: '效果评估', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=效果评估' },
|
||||
// { id: 'result-effect-pg-info', label: '信息PG', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=信息PG' },
|
||||
// { id: 'result-effect-pg-account', label: '账号PG', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=账号PG' },
|
||||
// ] },
|
||||
// { id: 'result-xk', label: 'XK任务', icon: Compass, children: [
|
||||
// { id: 'result-xk-all', label: '全部', icon: BarChart3, path: '/result-management/result-xk', activePath: '/result-management/result-xk' },
|
||||
// { id: 'result-xk-jc', label: '任务JC', icon: Compass, path: '/result-management/result-xk?subCategory=任务JC' },
|
||||
// { id: 'result-xk-dispatch', label: '任务调度', icon: Compass, path: '/result-management/result-xk?subCategory=任务调度' },
|
||||
// ] },
|
||||
// { id: 'result-dq', label: 'DQ研究类任务', icon: Database, children: [
|
||||
// { id: 'result-dq-all', label: '全部', icon: BarChart3, path: '/result-management/result-dq', activePath: '/result-management/result-dq' },
|
||||
// { id: 'result-dq-audience', label: '受众分析', icon: Database, path: '/result-management/result-dq?subCategory=受众分析' },
|
||||
// { id: 'result-dq-kb', label: '知识库研究', icon: Database, path: '/result-management/result-dq?subCategory=知识库研究' },
|
||||
// { id: 'result-dq-trend', label: '趋势研究', icon: Database, path: '/result-management/result-dq?subCategory=趋势研究' },
|
||||
// ] },
|
||||
// { id: 'result-other', label: '其他类任务', icon: MoreHorizontal, children: [
|
||||
// { id: 'result-other-all', label: '全部', icon: BarChart3, path: '/result-management/result-other', activePath: '/result-management/result-other' },
|
||||
// { id: 'result-other-general', label: '通用分析', icon: MoreHorizontal, path: '/result-management/result-other?subCategory=通用分析' },
|
||||
// { id: 'result-other-temp', label: '临时任务', icon: MoreHorizontal, path: '/result-management/result-other?subCategory=临时任务' },
|
||||
// { id: 'result-other-other', label: '其他', icon: MoreHorizontal, path: '/result-management/result-other?subCategory=其他' },
|
||||
// ] },
|
||||
// ] },
|
||||
{
|
||||
id: "resource-management",
|
||||
label: "资源管理",
|
||||
icon: Database,
|
||||
children: [
|
||||
// { id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
|
||||
{
|
||||
id: "resource-network-actor",
|
||||
label: "网络行为体资源管理",
|
||||
icon: Network,
|
||||
},
|
||||
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
|
||||
{ id: "resource-evaluation-index", label: "作战资源管理", icon: Library },
|
||||
// {
|
||||
// id: "resource-evaluation-index",
|
||||
// label: "评估指标体系资源管理",
|
||||
// icon: Scale,
|
||||
// },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const subEntries = [
|
||||
// { id: 'task-create', label: '新建任务', icon: ListTodo, parent: 'task-management' },
|
||||
// { id: 'task-single', label: '单智能体任务', icon: Bot, parent: 'task-management' },
|
||||
// { id: 'task-scene', label: '场景任务', icon: Workflow, parent: 'task-management' },
|
||||
// { id: 'task-consult-create', label: '多智能体会商', icon: MessageSquare, parent: 'task-management' },
|
||||
// { id: 'final-plan', label: '方案输出', icon: FileOutput, parent: 'strategy-result' },
|
||||
];
|
||||
|
||||
const getPath = (id: PageType) => {
|
||||
if (taskGroups.includes(id as TaskGroupId)) {
|
||||
console.log('----task-management', id);
|
||||
return `/task-management/${id}`;
|
||||
}
|
||||
if (taskGroups.some((group) => id.startsWith(group))) {
|
||||
return `/task-management/${id.split("-").slice(0, 2).join("-")}/${id}`;
|
||||
}
|
||||
// 特殊处理 effect-task 下的子菜单项(effect-action-pg, effect-info-pg, effect-account-pg)
|
||||
if (id.startsWith("effect-") && !id.startsWith("effect-task")) {
|
||||
return `/task-management/effect-task/${id}`;
|
||||
}
|
||||
if (taskPages.has(id)) return `/task-management/${id}`;
|
||||
if (qaPages.has(id)) return `/qa-center/${id}`;
|
||||
if (id === "qa-center") return "/qa-center";
|
||||
if (lightAppPages.has(id)) return `/light-app-center/${id}`;
|
||||
if (agentPages.has(id)) return `/agent-list/${id}`;
|
||||
if (resultPages.has(id)) return `/result-management/${id}`;
|
||||
if (resourcePages.has(id)) return `/resource-management/${id}`;
|
||||
|
||||
return `/${id}`;
|
||||
};
|
||||
|
||||
const toggleSet = <T,>(set: Set<T>, item: T) => {
|
||||
const next = new Set(set);
|
||||
next.has(item) ? next.delete(item) : next.add(item);
|
||||
return next;
|
||||
};
|
||||
|
||||
export default function Sidebar({
|
||||
currentPage,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse,
|
||||
}: Props) {
|
||||
const { theme } = useTheme();
|
||||
const location = useLocation();
|
||||
const isDark = theme === "dark";
|
||||
const [collapsedMenus, setCollapsedMenus] = useState<Set<Level1MenuId>>(
|
||||
new Set()
|
||||
);
|
||||
const [expandedTaskGroups, setExpandedTaskGroups] = useState<Set<string>>(
|
||||
() =>
|
||||
new Set([
|
||||
...taskGroups.filter((group) => currentPage.startsWith(group)),
|
||||
...(currentPage.startsWith("result-") ? [currentPage] : []),
|
||||
])
|
||||
);
|
||||
|
||||
const panel = `ml-4 pl-4 border-l space-y-1 theme-transition ${
|
||||
isDark ? "border-blue-800/30" : "border-red-100"
|
||||
}`;
|
||||
const iconBtn = `rounded-lg transition-all duration-200 ${
|
||||
isDark
|
||||
? "text-slate-400 hover:text-white hover:bg-slate-800/50"
|
||||
: "text-gray-500 hover:text-red-600 hover:bg-red-50"
|
||||
}`;
|
||||
const itemClass = (active: boolean) =>
|
||||
`rounded-lg transition-all duration-200 theme-transition ${
|
||||
active
|
||||
? isDark
|
||||
? "bg-blue-600/20 text-blue-400 border border-blue-500/30"
|
||||
: "bg-red-50 text-red-600 border border-red-200"
|
||||
: isDark
|
||||
? "text-slate-400 hover:text-white hover:bg-slate-800/50"
|
||||
: "text-gray-600 hover:text-red-600 hover:bg-red-50"
|
||||
}`;
|
||||
|
||||
const isTopActive = (id: PageType) => {
|
||||
if (id === "qa-center") return qaPages.has(currentPage);
|
||||
if (id === "light-app-center") return currentPage.startsWith("light-app");
|
||||
if (id === "task-management")
|
||||
return (
|
||||
currentPage === "dashboard" ||
|
||||
taskPages.has(currentPage) ||
|
||||
taskGroups.some((group) => currentPage.startsWith(group))
|
||||
);
|
||||
if (id === "scene-list") return currentPage.startsWith("scene");
|
||||
if (id === "agent-list") return currentPage.startsWith("agent");
|
||||
if (id === "result-management") return currentPage.startsWith("result");
|
||||
if (id === "resource-management") return currentPage.startsWith("resource");
|
||||
return currentPage === id;
|
||||
};
|
||||
const hasActiveChild = (item: MenuItem): boolean =>
|
||||
currentPage === item.id || Boolean(item.children?.some(hasActiveChild));
|
||||
|
||||
const renderLink = (item: MenuItem, top = false) => {
|
||||
const Icon = item.icon;
|
||||
const activePath = decodeURI(`${location.pathname}${location.search}`);
|
||||
const active = top
|
||||
? isTopActive(item.id)
|
||||
: item.path
|
||||
? activePath === (item.activePath ?? item.path)
|
||||
: currentPage === item.id;
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path ?? getPath(item.id)}
|
||||
onClick={() =>
|
||||
top &&
|
||||
item.id === "task-management" &&
|
||||
expandedTaskGroups.size === 0 &&
|
||||
setExpandedTaskGroups(new Set(["xk-task"]))
|
||||
}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
className={`${top ? "flex-1" : "w-full"} flex items-center ${
|
||||
isCollapsed && top ? "justify-center gap-0 px-2" : "gap-2 px-3"
|
||||
} py-2 ${itemClass(active)}`}
|
||||
>
|
||||
<Icon
|
||||
className={`${top ? "w-5 h-5" : "w-4 h-4"} flex-shrink-0 ${
|
||||
active ? (isDark ? "text-blue-400" : "text-red-500") : ""
|
||||
}`}
|
||||
/>
|
||||
{(!isCollapsed || !top) && (
|
||||
<span
|
||||
className={`${
|
||||
top ? "text-sm" : "text-xs"
|
||||
} font-medium flex-1 text-left`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTaskGroup = (item: MenuItem) => {
|
||||
const expanded = expandedTaskGroups.has(item.id);
|
||||
const active = hasActiveChild(item);
|
||||
const Icon = expanded ? FolderOpen : Folder;
|
||||
return (
|
||||
<div key={item.id} className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedTaskGroups((prev) => toggleSet(prev, item.id))
|
||||
}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 ${itemClass(
|
||||
active
|
||||
)}`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-4 h-4 flex-shrink-0 ${
|
||||
active ? (isDark ? "text-blue-400" : "text-red-500") : ""
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs font-medium flex-1 text-left">
|
||||
{item.label}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={`w-4 h-4 transition-transform duration-200 ${
|
||||
expanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{expanded && item.children && (
|
||||
<div className={panel}>
|
||||
{item.children.map((child) => renderLink(child))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderChildren = (item: MenuItem) =>
|
||||
item.children ? (
|
||||
<div className={panel}>
|
||||
{item.children.map((child) =>
|
||||
child.children ? renderTaskGroup(child) : renderLink(child)
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
const renderSubEntries = (item: MenuItem) => {
|
||||
const entries = subEntries.filter((entry) => entry.parent === item.id);
|
||||
return entries.length && isTopActive(item.id) ? (
|
||||
<div className={panel}>{entries.map((entry) => renderLink(entry))}</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 h-screen ${
|
||||
isCollapsed ? "w-16" : "w-64"
|
||||
} z-50 theme-transition overflow-y-auto ${
|
||||
isDark
|
||||
? "bg-slate-900/80 backdrop-blur-xl border-r border-blue-800/30"
|
||||
: "bg-white border-r border-red-100 shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-16 flex items-center ${
|
||||
isCollapsed ? "justify-center px-2" : "justify-between px-6"
|
||||
} border-b theme-transition flex-shrink-0 ${
|
||||
isDark ? "border-blue-800/30" : "border-red-100"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||
isDark
|
||||
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||||
: "bg-gradient-to-br from-red-500 to-red-400"
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
<h1
|
||||
className={`font-semibold text-sm ${
|
||||
isDark ? "text-white" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
智能CM系统
|
||||
</h1>
|
||||
<p
|
||||
className={`text-xs ${
|
||||
isDark ? "text-blue-400/60" : "text-red-400/80"
|
||||
}`}
|
||||
>
|
||||
Smart CM Platform
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
className={`p-2 ${iconBtn}`}
|
||||
title="收起侧边栏"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<nav className={`${isCollapsed ? "p-2" : "p-4"} space-y-2`}>
|
||||
{isCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
className={`w-full flex items-center justify-center p-2.5 ${iconBtn}`}
|
||||
title="展开侧边栏"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{menuItems.map((item) => {
|
||||
const canCollapse = collapsibleMenus.has(item.id);
|
||||
const childrenCollapsed =
|
||||
canCollapse && collapsedMenus.has(item.id as Level1MenuId);
|
||||
return (
|
||||
<div key={item.id} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderLink(item, true)}
|
||||
{!isCollapsed && item.children && canCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCollapsedMenus((prev) =>
|
||||
toggleSet(prev, item.id as Level1MenuId)
|
||||
)
|
||||
}
|
||||
className={`p-1.5 ${iconBtn}`}
|
||||
title={childrenCollapsed ? "展开菜单" : "折叠菜单"}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-4 h-4 transition-transform duration-200 ${
|
||||
childrenCollapsed ? "" : "rotate-90"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed &&
|
||||
item.children &&
|
||||
!childrenCollapsed &&
|
||||
renderChildren(item)}
|
||||
{!isCollapsed && !childrenCollapsed && renderSubEntries(item)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{isDark ? (
|
||||
<>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-20 right-0 w-24 h-24 bg-cyan-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-red-500/5 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-20 right-0 w-24 h-24 bg-red-400/5 rounded-full blur-3xl pointer-events-none" />
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
@keyframes breathing {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathingGlow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-breathing {
|
||||
animation: breathing 1.5s ease-in-out infinite, breathingGlow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.agent-resizing {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll,
|
||||
.agent-file-preview-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar-track,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar-thumb,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.38);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar-thumb:hover,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(96, 165, 250, 0.58);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.agent-file-markdown-preview > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.agent-file-markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.agent-file-markdown-preview img {
|
||||
max-width: 100%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,55 @@
|
||||
import SkillCreatorConversationItem from './SkillCreatorConversationItem';
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export interface ConversationStep {
|
||||
text: string;
|
||||
subText?: string;
|
||||
status: 'done' | 'processing';
|
||||
}
|
||||
|
||||
export interface ConversationMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
steps?: ConversationStep[];
|
||||
thinkings?: Array<{ duration: number; content: string }>;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
interface AgentConversationListProps {
|
||||
messages: ConversationMessage[];
|
||||
isConnecting?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AgentConversationList({ messages, isConnecting = false, className = '' }: AgentConversationListProps) {
|
||||
return (
|
||||
<div className={`w-full max-w-4xl mx-auto space-y-5 pb-6 ${className}`}>
|
||||
{messages.map((message, index) => {
|
||||
// 仅在最后一条 assistant 且尚未收到任何生成内容时显示“连接中”态
|
||||
// 一旦出现 steps/thinkings/toolCalls/content,就切回正常渲染
|
||||
const hasStartedGenerating =
|
||||
Boolean(message.content) ||
|
||||
Boolean(message.steps?.length) ||
|
||||
Boolean(message.thinkings?.length) ||
|
||||
Boolean(message.toolCalls?.length);
|
||||
const shouldShowConnecting =
|
||||
isConnecting && index === messages.length - 1 && message.role === 'assistant' && !hasStartedGenerating;
|
||||
|
||||
return (
|
||||
<SkillCreatorConversationItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
isConnecting={shouldShowConnecting}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, Sparkles, ArrowRight, MessageCircle, Bot, BriefcaseBusiness, Info, Pencil, Trash2, Eye, EyeOff, MessageCircleMore, InfoIcon, Edit } from 'lucide-react';
|
||||
import { Dialog, Loading, MessagePlugin, Popconfirm, Switch } from 'tdesign-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useExpertAuthBootstrap } from '../../hooks';
|
||||
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
|
||||
|
||||
interface ExpertAgentItem {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
description: string | null;
|
||||
model_name: string;
|
||||
is_draft: boolean;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type ExpertTab = 'mine' | 'published';
|
||||
|
||||
export default function AgentExpertCenterPage() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const { llmList, currentUser, authedFetch } = useExpertAuthBootstrap();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ExpertTab>('mine');
|
||||
const [agentList, setAgentList] = useState<ExpertAgentItem[]>([]);
|
||||
const [isListLoading, setIsListLoading] = useState(false);
|
||||
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [selectedAgent, setSelectedAgent] = useState<ExpertAgentItem | null>(null);
|
||||
|
||||
const fetchExpertList = useCallback(async () => {
|
||||
setIsListLoading(true);
|
||||
try {
|
||||
const isDraft = activeTab === 'mine';
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ is_draft: isDraft }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('expert-agents/list接口调用失败:', res.status, res.statusText);
|
||||
setAgentList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: unknown = await res.json();
|
||||
const rawList = Array.isArray(data) ? (data as ExpertAgentItem[]) : [];
|
||||
|
||||
if (isDraft) {
|
||||
const mineFromList = currentUser ? rawList.filter((item) => item.user_id === currentUser.id) : [];
|
||||
|
||||
const allRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
let mineFromAll: ExpertAgentItem[] = [];
|
||||
if (allRes.ok) {
|
||||
const allData: unknown = await allRes.json();
|
||||
const allList = Array.isArray(allData) ? (allData as ExpertAgentItem[]) : [];
|
||||
mineFromAll = currentUser ? allList.filter((item) => item.user_id === currentUser.id) : [];
|
||||
} else {
|
||||
console.error('expert-agents接口调用失败:', allRes.status, allRes.statusText);
|
||||
}
|
||||
|
||||
const mergedMap = new Map<number, ExpertAgentItem>();
|
||||
[...mineFromList, ...mineFromAll].forEach((item) => mergedMap.set(item.id, item));
|
||||
setAgentList(Array.from(mergedMap.values()));
|
||||
return;
|
||||
}
|
||||
|
||||
setAgentList(rawList);
|
||||
} catch (error) {
|
||||
console.error('expert-agents/list接口异常:', error);
|
||||
setAgentList([]);
|
||||
} finally {
|
||||
setIsListLoading(false);
|
||||
}
|
||||
}, [activeTab, authedFetch, currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'mine' && !currentUser) return;
|
||||
void fetchExpertList();
|
||||
}, [activeTab, currentUser, fetchExpertList]);
|
||||
|
||||
const handleStatusToggle = async (nextPublished: boolean) => {
|
||||
if (!selectedAgent) return;
|
||||
|
||||
const nextIsDraft = !nextPublished;
|
||||
setIsStatusUpdating(true);
|
||||
try {
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${selectedAgent.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ is_draft: nextIsDraft }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('更新专家状态失败:', res.status, res.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAgent({ ...selectedAgent, is_draft: nextIsDraft });
|
||||
await fetchExpertList();
|
||||
} catch (error) {
|
||||
console.error('更新专家状态异常:', error);
|
||||
} finally {
|
||||
setIsStatusUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addAgentSearch = async () => {
|
||||
const parmy = {
|
||||
model_name: 'Qwen3.5-27B',
|
||||
name: '未命名专家',
|
||||
system_prompt: '你是一个专业的助手,能够回答用户的问题。'
|
||||
}
|
||||
const res: any = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(parmy),
|
||||
});
|
||||
console.log('创建专家', res);
|
||||
const data: any = await res.json()
|
||||
navigate(`/agent-expert-edit?id=${data.id}`)
|
||||
}
|
||||
|
||||
const delAgentSearch = async (id) => {
|
||||
const res: any = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
MessagePlugin.success('操作成功')
|
||||
await fetchExpertList();
|
||||
}
|
||||
|
||||
const cards = useMemo(
|
||||
() =>
|
||||
agentList.map((item) => ({
|
||||
id: String(item.id),
|
||||
title: item.name || '未命名专家',
|
||||
desc: item.description || '暂无描述',
|
||||
status: item.is_draft ? '草稿' : '已发布',
|
||||
avatarSvg: item.avatar_url,
|
||||
raw: item,
|
||||
})),
|
||||
[agentList],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`w-full min-h-[calc(100vh-140px)] ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
<div className="max-w-[1100px] mx-auto">
|
||||
<section className={`rounded-2xl border px-6 py-6 ${isDark ? 'bg-slate-900/60 border-slate-700' : 'bg-white border-gray-200'}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-fuchsia-500 to-violet-600 flex items-center justify-center text-white shadow">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">我的智能体</h1>
|
||||
</div>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>管理你的专属 AI 专家助手</p>
|
||||
<p className={`mt-1 text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>已加载模型:{llmList.length}</p>
|
||||
|
||||
<div className="mt-4 inline-flex items-center rounded-lg border overflow-hidden">
|
||||
<button
|
||||
onClick={() => setActiveTab('mine')}
|
||||
className={`px-3 py-1.5 text-xs ${activeTab === 'mine' ? 'bg-slate-900 text-white' : isDark ? 'bg-slate-800/60 text-slate-300' : 'bg-gray-50 text-gray-600'}`}
|
||||
>
|
||||
我的智能体
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('published')}
|
||||
className={`px-3 py-1.5 text-xs ${activeTab === 'published' ? 'bg-slate-900 text-white' : isDark ? 'bg-slate-800/60 text-slate-300' : 'bg-gray-50 text-gray-600'}`}
|
||||
>
|
||||
发布的智能体
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="inline-flex items-center gap-2 h-10 px-4 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition" onClick={addAgentSearch}>
|
||||
<Plus className="w-4 h-4" /> 创建智能体
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
{isListLoading ? (
|
||||
<div className={`min-h-[280px] rounded-2xl border flex items-center justify-center ${isDark ? 'bg-slate-900/40 border-slate-700' : 'bg-white border-gray-200'}`}>
|
||||
<Loading loading text="加载专家列表中..." />
|
||||
</div>
|
||||
) : cards.length === 0 ? (
|
||||
<div className={`min-h-[280px] rounded-2xl border flex flex-col items-center justify-center text-center px-4 ${isDark ? 'bg-slate-900/40 border-slate-700' : 'bg-white border-gray-200'}`}>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center mb-3 ${isDark ? 'bg-slate-800 text-slate-300' : 'bg-gray-100 text-gray-500'}`}>
|
||||
<BriefcaseBusiness className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className={`text-base font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
{activeTab === 'mine' ? '暂无我的专家' : '暂无发布的专家'}
|
||||
</h3>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
创建你的第一个专家助手,开始高效工作
|
||||
</p>
|
||||
<button className="mt-4 inline-flex items-center gap-2 h-10 px-4 rounded-xl bg-slate-900 text-white hover:bg-slate-800 transition">
|
||||
<Plus className="w-4 h-4" /> 创建第一个专家
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{cards.map((item, idx) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className={`rounded-2xl border p-5 transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 ${isDark ? 'bg-slate-900/60 border-slate-700 hover:border-slate-500' : 'bg-white border-gray-200 hover:border-gray-300'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{item.avatarSvg ? (
|
||||
<img
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(item.avatarSvg)}`}
|
||||
alt={item.title}
|
||||
className="w-10 h-10 rounded-xl object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center text-white shadow-sm flex-shrink-0">
|
||||
{idx % 2 === 0 ? <MessageCircle className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-[15px] leading-6 truncate">{item.title}</h3>
|
||||
<p className={`text-xs mt-1 line-clamp-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{activeTab === 'mine' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title="查看详情"
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-700/60' : 'border-gray-200 text-gray-500 hover:bg-gray-100'}`}
|
||||
onClick={() => {
|
||||
setSelectedAgent(item.raw);
|
||||
setDetailVisible(true);
|
||||
}}
|
||||
>
|
||||
<Info className="w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="编辑"
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-700/60' : 'border-gray-200 text-gray-500 hover:bg-gray-100'}`}
|
||||
onClick={() => {
|
||||
navigate(`/agent-expert-edit?id=${item.id}`);
|
||||
}}
|
||||
>
|
||||
<Edit className="w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
<Popconfirm
|
||||
content="确认删除该条数据吗?"
|
||||
onConfirm={() => delAgentSearch(item.id)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title="删除"
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-rose-900/20 hover:text-rose-300' : 'border-gray-200 text-gray-500 hover:bg-rose-50 hover:text-rose-500'}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded-full border ${isDark ? 'bg-emerald-950/50 border-emerald-800 text-emerald-300' : 'bg-emerald-50 border-emerald-200 text-emerald-700'}`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/agent-chat?id=${item.id}`)}
|
||||
className={`mt-6 inline-flex items-center gap-1 text-sm ${isDark ? 'text-slate-300 hover:text-white' : 'text-gray-700 hover:text-gray-900'}`}
|
||||
>
|
||||
进行对话
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={`mt-6 rounded-2xl border px-6 py-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4 ${isDark ? 'bg-violet-950/30 border-violet-800/50' : 'bg-violet-50 border-violet-100'}`}>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">不确定从哪个开始?</h3>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>直接开始对话,AI 会自动匹配最合适的专家助手</p>
|
||||
</div>
|
||||
|
||||
<button className="inline-flex items-center justify-center gap-2 h-11 px-6 rounded-xl bg-gradient-to-r from-fuchsia-500 to-violet-600 text-white font-medium hover:opacity-95 transition">
|
||||
开始对话
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<Dialog
|
||||
visible={detailVisible}
|
||||
onClose={() => setDetailVisible(false)}
|
||||
confirmBtn={null}
|
||||
cancelBtn={null}
|
||||
header="专家详情"
|
||||
width={520}
|
||||
>
|
||||
<div className="py-2">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{selectedAgent?.avatar_url ? (
|
||||
<img
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(selectedAgent.avatar_url)}`}
|
||||
alt={selectedAgent?.name || '专家头像'}
|
||||
className="w-20 h-20 rounded-2xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-2xl bg-gray-100" />
|
||||
)}
|
||||
|
||||
<h4 className="mt-4 text-2xl font-semibold text-gray-900">{selectedAgent?.name || '未命名专家'}</h4>
|
||||
<p className="mt-2 text-sm text-gray-400">{selectedAgent?.description || '暂无描述'}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-xl bg-gray-50 px-3 py-3 flex items-center justify-between">
|
||||
<div className="inline-flex items-center gap-2 text-sm text-gray-500">
|
||||
{selectedAgent?.is_draft ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{selectedAgent?.is_draft ? '草稿状态' : '已发布'}
|
||||
</div>
|
||||
<Switch
|
||||
value={!selectedAgent?.is_draft}
|
||||
loading={isStatusUpdating}
|
||||
disabled={isStatusUpdating}
|
||||
onChange={(value) => {
|
||||
void handleStatusToggle(value as boolean);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="mt-4 w-full h-10 rounded-lg bg-gradient-to-r from-fuchsia-500 to-violet-600 text-white inline-flex items-center justify-center gap-2">
|
||||
<MessageCircleMore className="w-4 h-4" />
|
||||
开始聊天
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,130 @@
|
||||
import type { KeyboardEvent, ReactNode } from 'react';
|
||||
import { Send, Square, Upload } from 'lucide-react';
|
||||
import { Textarea } from 'tdesign-react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
interface ChatInputComposerProps {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
onSend?: () => void;
|
||||
onStop?: () => void;
|
||||
onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
isSending?: boolean;
|
||||
isDarkMode: boolean;
|
||||
trailingActions?: ReactNode;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
showBorderTop?: boolean;
|
||||
showUpload?: boolean;
|
||||
onUploadFile?: (file: File) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function ChatInputComposer({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
onStop,
|
||||
onKeyDown,
|
||||
placeholder = '输入你的问题...',
|
||||
disabled = false,
|
||||
isSending = false,
|
||||
isDarkMode,
|
||||
trailingActions,
|
||||
readOnly = false,
|
||||
className = '',
|
||||
showBorderTop = false,
|
||||
showUpload = true,
|
||||
onUploadFile,
|
||||
}: ChatInputComposerProps) {
|
||||
const submitEnabled = Boolean(value.trim()) && !disabled && !readOnly && Boolean(onSend);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const iconButtonClass = `rounded-lg p-2 transition-colors ${
|
||||
isDarkMode ? 'text-slate-400 hover:bg-slate-600 hover:text-slate-200' : 'text-gray-500 hover:bg-gray-200 hover:text-gray-700'
|
||||
}`;
|
||||
|
||||
const handleKeyDown = (val, context) => {
|
||||
if (context?.e?.key === 'Enter') {
|
||||
// 阻止默认的换行行为(如果是 Textarea)
|
||||
context.e.preventDefault();
|
||||
|
||||
// 模拟发送请求
|
||||
onSend()
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${showBorderTop ? (isDarkMode ? 'border-t border-slate-700/50' : 'border-t border-red-100') : ''} p-4 ${className}`}>
|
||||
<div className={`flex items-end gap-3 rounded-2xl p-3 shadow-inner ${isDarkMode ? 'bg-slate-700/50' : 'bg-gray-100'}`}>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(nextValue) => onChange?.(String(nextValue))}
|
||||
onKeydown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
autosize={{ minRows: 1, maxRows: 8 }}
|
||||
disabled={disabled}
|
||||
readonly={readOnly}
|
||||
className={`flex-1 !bg-transparent !border-0 !shadow-none qa-composer-textarea [&]:!border-0 [&]:!shadow-none [&]:!outline-none [&]:focus:!border-0 [&]:focus:!shadow-none [&]:focus:!outline-none [&_.t-textarea]:!border-0 [&_.t-textarea]:!shadow-none [&_.t-textarea]:!outline-none [&_.t-textarea:hover]:!border-0 [&_.t-textarea:hover]:!shadow-none [&_.t-textarea--focused]:!border-0 [&_.t-textarea--focused]:!shadow-none [&_.t-is-focused]:!border-0 [&_.t-is-focused]:!shadow-none [&_.t-textarea__inner]:!min-h-[40px] [&_.t-textarea__inner]:!resize-none [&_.t-textarea__inner]:!bg-transparent [&_.t-textarea__inner]:!border-0 [&_.t-textarea__inner]:!p-0 [&_.t-textarea__inner]:!shadow-none [&_.t-textarea__inner]:!outline-none [&_.t-textarea__inner:hover]:!border-0 [&_.t-textarea__inner:hover]:!shadow-none [&_.t-textarea__inner:focus]:!border-0 [&_.t-textarea__inner:focus]:!shadow-none [&_.t-textarea__inner:focus]:!outline-none [&_.t-textarea__inner]:disabled:!cursor-not-allowed ${
|
||||
isDarkMode
|
||||
? '[&_.t-textarea__inner]:!text-white [&_.t-textarea__inner]:placeholder:!text-slate-500'
|
||||
: '[&_.t-textarea__inner]:!text-gray-800 [&_.t-textarea__inner]:placeholder:!text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{showUpload && (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
void onUploadFile?.(file);
|
||||
}
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled || !onUploadFile}
|
||||
className={`${iconButtonClass} ${(disabled || !onUploadFile) ? 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-inherit' : ''}`}
|
||||
title="上传文件"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{trailingActions && <div className="flex items-center gap-2">{trailingActions}</div>}
|
||||
|
||||
<button
|
||||
onClick={isSending ? onStop : onSend}
|
||||
disabled={!isSending && !submitEnabled}
|
||||
className={`flex items-center gap-2 rounded-xl px-4 py-2 font-medium shadow-sm transition-all ${
|
||||
isSending
|
||||
? isDarkMode
|
||||
? 'bg-rose-600 text-white'
|
||||
: 'bg-red-600 text-white'
|
||||
: submitEnabled
|
||||
? isDarkMode
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-red-500 text-white hover:bg-red-600'
|
||||
: isDarkMode
|
||||
? 'cursor-not-allowed bg-slate-600 text-slate-400'
|
||||
: 'cursor-not-allowed bg-gray-300 text-gray-500'
|
||||
}`}
|
||||
type="button"
|
||||
title={isSending ? '停止' : '发送'}
|
||||
>
|
||||
{isSending ? <Square className="h-4 w-4 fill-current" /> : <Send className="h-4 w-4" />}
|
||||
{isSending ? '停止' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
FileCode2,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
interface FileSkill {
|
||||
id: string;
|
||||
folder: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kind: 'md' | 'py';
|
||||
filename: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
const API_BASE = 'https://ch1.b.uat.4.cn/zncmback/api/skills/files';
|
||||
|
||||
export default function FileSkillsWorkshop() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const [skills, setSkills] = useState<FileSkill[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FileSkill | null>(null);
|
||||
const [openCardMenuId, setOpenCardMenuId] = useState<string | null>(null);
|
||||
|
||||
const cardMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const reload = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(API_BASE);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
const data = (await res.json()) as FileSkill[];
|
||||
setSkills(data);
|
||||
} catch (err) {
|
||||
console.error('加载技能失败', err);
|
||||
setError('加载技能失败,请稍后重试');
|
||||
setSkills([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (cardMenuRef.current && !cardMenuRef.current.contains(target)) {
|
||||
setOpenCardMenuId(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const keyword = searchTerm.trim().toLowerCase();
|
||||
if (!keyword) return skills;
|
||||
return skills.filter((s) =>
|
||||
`${s.name} ${s.description} ${s.filename}`.toLowerCase().includes(keyword),
|
||||
);
|
||||
}, [searchTerm, skills]);
|
||||
|
||||
const handleDelete = async (skill: FileSkill) => {
|
||||
setOpenCardMenuId(null);
|
||||
if (!window.confirm(`确认删除技能「${skill.name}」?`)) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/${encodeURIComponent(skill.folder)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
setSkills((prev) => prev.filter((s) => s.folder !== skill.folder));
|
||||
} catch (err) {
|
||||
console.error('删除技能失败', err);
|
||||
setError('删除技能失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full min-h-[calc(100vh-140px)] ${
|
||||
isDark ? 'bg-slate-950 text-slate-100' : 'bg-[#fcfcfd] text-[#101828]'
|
||||
}`}
|
||||
>
|
||||
{createOpen && (
|
||||
<CreateSkillModal
|
||||
isDark={isDark}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => {
|
||||
setCreateOpen(false);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditSkillModal
|
||||
isDark={isDark}
|
||||
skill={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mx-auto max-w-[1360px] px-6 py-8">
|
||||
<section className="mb-7 flex items-start justify-between gap-5">
|
||||
<div>
|
||||
<h1
|
||||
className={`text-[22px] font-bold tracking-tight ${
|
||||
isDark ? 'text-slate-100' : 'text-[#111827]'
|
||||
}`}
|
||||
>
|
||||
技能管理
|
||||
</h1>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>
|
||||
上传 Markdown 提示词或 Python 脚本作为技能,供 MCP 与模型调用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="inline-flex h-11 items-center gap-2 rounded-xl bg-[#ea2d2d] px-5 text-sm font-medium text-white shadow-[0_10px_20px_-12px_rgba(234,45,45,0.7)] transition hover:bg-[#d92626]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建技能
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`mb-8 rounded-[28px] border px-6 py-5 ${
|
||||
isDark ? 'border-slate-800 bg-slate-900/65' : 'border-[#f5d4d4] bg-white'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-2xl border px-4 py-3 ${
|
||||
isDark ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'
|
||||
}`}
|
||||
>
|
||||
<Search className={`h-4 w-4 ${isDark ? 'text-slate-400' : 'text-[#ff6b6b]'}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索技能名称 / 描述..."
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
className={`min-w-0 flex-1 bg-transparent text-sm outline-none ${
|
||||
isDark
|
||||
? 'text-slate-100 placeholder:text-slate-500'
|
||||
: 'text-gray-800 placeholder:text-[#9ca3af]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{loading ? (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-[28px] border py-20 text-sm ${
|
||||
isDark
|
||||
? 'border-slate-800 bg-slate-900/60 text-slate-300'
|
||||
: 'border-[#f5d4d4] bg-white text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
正在加载技能...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${
|
||||
isDark ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'
|
||||
}`}
|
||||
>
|
||||
<h3
|
||||
className={`mb-2 text-lg font-semibold ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
加载失败
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>{error}</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${
|
||||
isDark ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mb-4 flex h-12 w-12 items-center justify-center rounded-2xl ${
|
||||
isDark ? 'bg-slate-800 text-slate-400' : 'bg-[#fff1f1] text-[#ff6b6b]'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-6 w-6" />
|
||||
</div>
|
||||
<h3
|
||||
className={`mb-2 text-lg font-semibold ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
暂无技能
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>
|
||||
点击右上角「新建技能」上传你的第一个技能。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filtered.map((skill) => {
|
||||
const isPy = skill.kind === 'py';
|
||||
const Icon = isPy ? FileCode2 : FileText;
|
||||
return (
|
||||
<article
|
||||
key={skill.folder}
|
||||
className={`group relative overflow-hidden rounded-[22px] border px-5 py-5 transition-all duration-200 ${
|
||||
isDark
|
||||
? 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:shadow-xl hover:shadow-black/20'
|
||||
: 'border-[#f5d4d4] bg-white hover:border-[#ffbdbd] hover:shadow-[0_16px_40px_-26px_rgba(239,68,68,0.35)]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-x-0 top-0 h-1 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
|
||||
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute right-4 top-4 z-20"
|
||||
ref={openCardMenuId === skill.folder ? cardMenuRef : null}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpenCardMenuId((prev) =>
|
||||
prev === skill.folder ? null : skill.folder,
|
||||
);
|
||||
}}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{openCardMenuId === skill.folder && (
|
||||
<div
|
||||
className={`absolute right-0 top-[calc(100%+6px)] z-30 w-36 rounded-2xl border py-1 shadow-xl ${
|
||||
isDark ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpenCardMenuId(null);
|
||||
setEditing(skill);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-slate-300 hover:bg-slate-800'
|
||||
: 'text-gray-700 hover:bg-[#fff6f6]'
|
||||
}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4 shrink-0" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(skill);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-rose-400 hover:bg-slate-800'
|
||||
: 'text-rose-500 hover:bg-[#fff6f6]'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 pr-8">
|
||||
<div
|
||||
className={`relative flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br shadow-[0_18px_32px_-22px_rgba(15,23,42,0.35)] ring-1 ${
|
||||
isPy
|
||||
? 'from-indigo-100 via-blue-50 to-white text-indigo-500 shadow-indigo-100/80 ring-indigo-100'
|
||||
: 'from-rose-100 via-red-50 to-white text-red-500 shadow-red-100/80 ring-red-100'
|
||||
}`}
|
||||
>
|
||||
<Icon className="relative z-10 h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className={`truncate text-[17px] font-semibold ${
|
||||
isDark ? 'text-slate-100' : 'text-[#111827]'
|
||||
}`}
|
||||
title={skill.name}
|
||||
>
|
||||
{skill.name}
|
||||
</h3>
|
||||
<p
|
||||
className={`mt-1 line-clamp-3 min-h-[3.6em] text-sm ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
{skill.description || '暂无描述'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mt-4 flex items-center justify-between text-xs ${
|
||||
isDark ? 'text-slate-500' : 'text-[#98a2b3]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium ${
|
||||
isPy
|
||||
? 'bg-indigo-50 text-indigo-600'
|
||||
: 'bg-[#fff2f2] text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{isPy ? 'Python' : 'Markdown'}
|
||||
</span>
|
||||
<span>{new Date(skill.updated_at * 1000).toLocaleString()}</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CreateModalProps {
|
||||
isDark: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
function CreateSkillModal({ isDark, onClose, onCreated }: CreateModalProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleFileChange = (next: File | null) => {
|
||||
setFile(next);
|
||||
if (next && !name.trim()) {
|
||||
const stem = next.name.replace(/\.[^.]+$/, '');
|
||||
setName(stem);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!file) {
|
||||
setErrMsg('请上传 .md 或 .py 文件');
|
||||
return;
|
||||
}
|
||||
setErrMsg('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
if (name.trim()) fd.append('name', name.trim());
|
||||
if (description.trim()) fd.append('description', description.trim());
|
||||
const res = await fetch(API_BASE, { method: 'POST', body: fd });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
console.error('新建技能失败', err);
|
||||
setErrMsg(err instanceof Error ? err.message : '新建技能失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`relative w-[480px] overflow-hidden rounded-[28px] border shadow-2xl ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-100'
|
||||
: 'border-[#f3d6d6] bg-white text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-1 w-full ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
|
||||
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between px-6 pb-3 pt-5">
|
||||
<h2 className="text-base font-semibold">新建技能</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 pb-2 pt-2">
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
技能文件(.md 或 .py)
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".md,.py"
|
||||
className="hidden"
|
||||
onChange={(event) => handleFileChange(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 hover:bg-slate-800'
|
||||
: 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow ring-1 ring-white/20">
|
||||
<Upload className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`truncate text-sm font-medium ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{file ? file.name : '选择文件'}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
支持 .md 提示词 或 .py 可执行脚本
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
技能名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="默认使用上传文件名"
|
||||
className={`w-full rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
描述(可选)
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="技能用途、输入输出说明等"
|
||||
className={`w-full resize-none rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errMsg && (
|
||||
<p className="text-xs text-rose-500">{errMsg}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-5 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`rounded-xl px-4 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
: 'text-[#667085] hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
className={`rounded-xl bg-[#ea2d2d] px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-[#d92626] ${
|
||||
submitting ? 'cursor-not-allowed opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
{submitting ? '提交中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EditModalProps {
|
||||
isDark: boolean;
|
||||
skill: FileSkill;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function EditSkillModal({ isDark, skill, onClose, onSaved }: EditModalProps) {
|
||||
const [name, setName] = useState(skill.name);
|
||||
const [description, setDescription] = useState(skill.description ?? '');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
setErrMsg('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
if (file) fd.append('file', file);
|
||||
if (name.trim() !== skill.name) fd.append('name', name.trim());
|
||||
if (description !== (skill.description ?? '')) fd.append('description', description);
|
||||
|
||||
const res = await fetch(`${API_BASE}/${encodeURIComponent(skill.folder)}`, {
|
||||
method: 'PUT',
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
console.error('保存技能失败', err);
|
||||
setErrMsg(err instanceof Error ? err.message : '保存技能失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`relative w-[480px] overflow-hidden rounded-[28px] border shadow-2xl ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-100'
|
||||
: 'border-[#f3d6d6] bg-white text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-1 w-full ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
|
||||
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between px-6 pb-3 pt-5">
|
||||
<h2 className="text-base font-semibold">编辑技能</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 pb-2 pt-2">
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
技能名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className={`w-full rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
className={`w-full resize-none rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
覆盖文件(可选,选择后将替换原有内容)
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".md,.py"
|
||||
className="hidden"
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 hover:bg-slate-800'
|
||||
: 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow ring-1 ring-white/20">
|
||||
<Upload className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`truncate text-sm font-medium ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{file ? file.name : `当前文件:${skill.filename}`}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
仅支持 .md 或 .py
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errMsg && <p className="text-xs text-rose-500">{errMsg}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-5 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`rounded-xl px-4 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
: 'text-[#667085] hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
className={`rounded-xl bg-[#ea2d2d] px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-[#d92626] ${
|
||||
submitting ? 'cursor-not-allowed opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bot, CheckCircle2, ChevronDown, Info, Sparkles, UserRound, Wrench } from 'lucide-react';
|
||||
import MarkdownRenderer from '../MarkdownRenderer';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
interface ConversationStep {
|
||||
text: string;
|
||||
subText?: string;
|
||||
status: 'done' | 'processing';
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
interface ConversationMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
steps?: ConversationStep[];
|
||||
thinkings?: Array<{ duration: number; content: string }>;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
interface SkillCreatorConversationItemProps {
|
||||
message: ConversationMessage;
|
||||
isConnecting?: boolean;
|
||||
}
|
||||
|
||||
function MessageAvatar({ role, active = false }: { role: 'user' | 'assistant'; active?: boolean }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const Icon = role === 'assistant' ? Bot : UserRound;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-9 h-9 rounded-2xl flex items-center justify-center flex-shrink-0 shadow-sm ${
|
||||
role === 'assistant'
|
||||
? isDarkMode
|
||||
? 'bg-gradient-to-br from-slate-700 to-slate-900 text-sky-200 ring-1 ring-slate-600'
|
||||
: 'bg-gradient-to-br from-red-50 to-rose-100 text-red-500 ring-1 ring-red-100'
|
||||
: isDarkMode
|
||||
? 'bg-gradient-to-br from-blue-500 to-indigo-600 text-white'
|
||||
: 'bg-gradient-to-br from-red-500 to-rose-500 text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{role === 'assistant' && active && (
|
||||
<Sparkles className={`absolute -right-0.5 -top-0.5 w-3.5 h-3.5 ${isDarkMode ? 'text-sky-200' : 'text-amber-400'}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepsSummary({ steps }: { steps: ConversationStep[] }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const [open, setOpen] = useState(true);
|
||||
const doneCount = steps.filter((step) => step.status === 'done').length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mb-3 overflow-hidden rounded-2xl border shadow-sm ${
|
||||
isDarkMode ? 'border-slate-700 bg-slate-900/60 text-slate-300' : 'border-red-100 bg-white text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
|
||||
isDarkMode ? 'bg-slate-800/70 hover:bg-slate-800' : 'bg-red-50/60 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className={`w-3.5 h-3.5 ${isDarkMode ? 'text-emerald-400' : 'text-red-500'}`} />
|
||||
<span className="font-medium">执行步骤</span>
|
||||
<span className={isDarkMode ? 'text-slate-500' : 'text-gray-400'}>已完成 {doneCount} / {steps.length}</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'max-h-[640px] opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className={`border-t px-4 py-3 space-y-3 ${isDarkMode ? 'border-slate-700' : 'border-red-50'}`}>
|
||||
{steps.map((step, index) => {
|
||||
const done = step.status === 'done';
|
||||
|
||||
return (
|
||||
<div key={`${step.text}-${index}`} className="flex gap-3">
|
||||
<div className="pt-0.5">
|
||||
{done ? (
|
||||
<CheckCircle2 className={`w-4 h-4 ${isDarkMode ? 'text-emerald-400' : 'text-red-500'}`} />
|
||||
) : (
|
||||
<span className={`block w-4 h-4 rounded-full border-2 ${isDarkMode ? 'border-sky-300 border-t-transparent' : 'border-red-400 border-t-transparent'} animate-spin`} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-xs font-medium ${isDarkMode ? 'text-slate-200' : 'text-gray-800'}`}>
|
||||
第 {index + 1} 步:{step.text}
|
||||
</div>
|
||||
{step.subText && (
|
||||
<div className={`mt-1 rounded-xl px-3 py-2 text-xs leading-5 ${isDarkMode ? 'bg-slate-950/50 text-slate-400' : 'bg-gray-50 text-gray-600'}`}>
|
||||
{step.subText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingPanel({ duration, content }: { duration: number; content: string }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (duration > 0) setOpen(false);
|
||||
}, [duration]);
|
||||
|
||||
return (
|
||||
<div className={`mb-3 rounded-2xl border overflow-hidden shadow-sm ${isDarkMode ? 'border-slate-700 bg-slate-900/60' : 'border-red-100 bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
|
||||
isDarkMode ? 'text-slate-300 bg-slate-800/70 hover:bg-slate-800' : 'text-gray-600 bg-red-50/60 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<Info className={`w-3.5 h-3.5 ${isDarkMode ? 'text-sky-300' : 'text-red-500'}`} />
|
||||
<span className="font-medium">思考过程</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className={`px-4 py-3 text-xs leading-6 whitespace-pre-wrap border-t ${isDarkMode ? 'border-slate-700 text-slate-300' : 'border-red-50 text-gray-600'}`}>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallPanel({ toolCall }: { toolCall: ToolCall }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const details = {
|
||||
input: toolCall.input && Object.keys(toolCall.input).length > 0 ? JSON.stringify(toolCall.input, null, 2) : '',
|
||||
output: toolCall.output ?? '',
|
||||
};
|
||||
const hasDetails = Boolean(details.input || details.output);
|
||||
|
||||
return (
|
||||
<div className={`mb-3 rounded-2xl border overflow-hidden shadow-sm ${isDarkMode ? 'border-slate-700 bg-slate-900/60' : 'border-red-100 bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
|
||||
isDarkMode ? 'text-slate-300 bg-slate-800/70 hover:bg-slate-800' : 'text-gray-600 bg-red-50/60 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<Wrench className={`w-3.5 h-3.5 ${isDarkMode ? 'text-amber-300' : 'text-red-500'}`} />
|
||||
<span className="font-medium">{toolCall.name}</span>
|
||||
{hasDetails && <ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />}
|
||||
</button>
|
||||
{hasDetails && (
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'max-h-[520px] opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className={`border-t px-4 py-3 space-y-3 ${isDarkMode ? 'border-slate-700' : 'border-red-50'}`}>
|
||||
{details.input && (
|
||||
<div>
|
||||
<p className={`text-[11px] mb-1 ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>请求参数</p>
|
||||
<pre className={`rounded-xl px-3 py-2 text-[11px] leading-5 overflow-x-auto ${isDarkMode ? 'bg-slate-950/60 text-slate-300' : 'bg-gray-50 text-gray-700'}`}>{details.input}</pre>
|
||||
</div>
|
||||
)}
|
||||
{details.output && (
|
||||
<div>
|
||||
<p className={`text-[11px] mb-1 ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>返回结果</p>
|
||||
<pre className={`rounded-xl px-3 py-2 text-[11px] leading-5 overflow-x-auto ${isDarkMode ? 'bg-slate-950/60 text-slate-300' : 'bg-gray-50 text-gray-700'}`}>{details.output}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SkillCreatorConversationItem({ message, isConnecting = false }: SkillCreatorConversationItemProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end gap-3">
|
||||
<div className={`max-w-[72%] rounded-2xl rounded-tr-md px-4 py-3 text-sm leading-relaxed shadow-sm ${
|
||||
isDarkMode ? 'bg-blue-600/80 text-white shadow-blue-950/20' : 'bg-gradient-to-r from-red-500 to-rose-500 text-white shadow-red-200/70'
|
||||
}`}>
|
||||
{message.content}
|
||||
</div>
|
||||
<MessageAvatar role="user" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<MessageAvatar role="assistant" active={!message.content} />
|
||||
<div
|
||||
className={`max-w-[86%] flex-1 rounded-3xl border px-4 py-3.5 text-sm shadow-sm ${
|
||||
isDarkMode ? 'border-slate-700 bg-slate-800/45 text-slate-200 shadow-black/10' : 'border-red-100 bg-white text-[#2a2f3f] shadow-red-100/60'
|
||||
}`}
|
||||
>
|
||||
{message.steps && message.steps.length > 0 && <StepsSummary steps={message.steps} />}
|
||||
{message.thinkings?.map((thinking, index) => (
|
||||
<ThinkingPanel key={`${message.id}-thinking-${index}`} duration={thinking.duration} content={thinking.content} />
|
||||
))}
|
||||
{message.toolCalls?.map((toolCall) => (
|
||||
<ToolCallPanel key={toolCall.id} toolCall={toolCall} />
|
||||
))}
|
||||
{isConnecting && (
|
||||
<div className={`flex items-center gap-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<span className="skill-creator-dot" />
|
||||
<span className="skill-creator-dot" />
|
||||
<span className="skill-creator-dot" />
|
||||
<span
|
||||
data-text="正在连接技能服务..."
|
||||
className={`ml-1 skill-creator-shimmer-text ${isDarkMode ? 'skill-creator-shimmer-dark' : 'skill-creator-shimmer-light'}`}
|
||||
>
|
||||
正在连接技能服务...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{message.content && (
|
||||
<MarkdownRenderer
|
||||
content={message.content}
|
||||
className={isDarkMode ? '[&_p]:text-slate-200 [&_li]:text-slate-200' : '[&_p]:text-[#2a2f3f] [&_li]:text-[#2a2f3f]'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,337 @@
|
||||
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
|
||||
import { Archive, Loader2, MessageSquareText, Plus, RefreshCw, Trash2, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export interface SkillCreatorThread {
|
||||
id: number;
|
||||
title: string;
|
||||
archived: boolean;
|
||||
expert_agent_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ThreadListResponse {
|
||||
threads?: SkillCreatorThread[];
|
||||
archived_threads?: SkillCreatorThread[];
|
||||
}
|
||||
|
||||
interface SkillCreatorThreadSidebarProps {
|
||||
authedFetch: (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => Promise<Response>;
|
||||
currentThreadId: string;
|
||||
isDarkMode: boolean;
|
||||
isOpen: boolean;
|
||||
isBusy?: boolean;
|
||||
createThreadPayload?: Record<string, unknown>;
|
||||
createButtonText?: string;
|
||||
emptyText?: string;
|
||||
subtitle?: string;
|
||||
threadFilter?: (thread: SkillCreatorThread) => boolean;
|
||||
onClose: () => void;
|
||||
onThreadSelect: (threadId: string) => void;
|
||||
}
|
||||
|
||||
const THREADS_API_URL = `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads`;
|
||||
|
||||
const sortThreads = (threads: SkillCreatorThread[]) =>
|
||||
[...threads].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
||||
|
||||
export default function SkillCreatorThreadSidebar({
|
||||
authedFetch,
|
||||
currentThreadId,
|
||||
isDarkMode,
|
||||
isOpen,
|
||||
isBusy = false,
|
||||
createThreadPayload,
|
||||
createButtonText = '新建聊天',
|
||||
emptyText = '暂无记录',
|
||||
subtitle = '切换或管理当前会话',
|
||||
threadFilter,
|
||||
onClose,
|
||||
onThreadSelect,
|
||||
}: SkillCreatorThreadSidebarProps) {
|
||||
const [threads, setThreads] = useState<SkillCreatorThread[]>([]);
|
||||
const [archivedThreads, setArchivedThreads] = useState<SkillCreatorThread[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [deletingThreadId, setDeletingThreadId] = useState<number | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const applyFilter = useCallback(
|
||||
(items: SkillCreatorThread[]) => (threadFilter ? items.filter(threadFilter) : items),
|
||||
[threadFilter],
|
||||
);
|
||||
|
||||
const refreshThreads = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await authedFetch(THREADS_API_URL, { method: 'GET' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取会话列表失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ThreadListResponse;
|
||||
const nextThreads = Array.isArray(data.threads) ? data.threads : [];
|
||||
const nextArchivedThreads = Array.isArray(data.archived_threads) ? data.archived_threads : [];
|
||||
|
||||
setThreads(sortThreads(applyFilter(nextThreads)));
|
||||
setArchivedThreads(sortThreads(applyFilter(nextArchivedThreads)));
|
||||
} catch (fetchError) {
|
||||
console.error('获取会话列表失败', fetchError);
|
||||
setError('会话列表加载失败,请稍后重试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [applyFilter, authedFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
void refreshThreads();
|
||||
}, [isOpen, refreshThreads]);
|
||||
|
||||
const allThreads = useMemo(() => [...threads, ...archivedThreads], [archivedThreads, threads]);
|
||||
|
||||
const handleCreateThread = async () => {
|
||||
if (isBusy || isCreating) return;
|
||||
|
||||
setIsCreating(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await authedFetch(THREADS_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: '新建技能',
|
||||
archived: false,
|
||||
is_real_chat: true,
|
||||
...createThreadPayload,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`创建会话失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const newThread = (await response.json()) as SkillCreatorThread;
|
||||
if (!threadFilter || threadFilter(newThread)) {
|
||||
setThreads((prev) => sortThreads([newThread, ...prev]));
|
||||
}
|
||||
onThreadSelect(String(newThread.id));
|
||||
} catch (createError) {
|
||||
console.error('创建会话失败', createError);
|
||||
setError('新建会话失败,请稍后重试');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteThread = async (thread: SkillCreatorThread) => {
|
||||
if (isBusy || deletingThreadId === thread.id) return;
|
||||
|
||||
const confirmed = window.confirm(`确认删除会话“${thread.title || `会话 ${thread.id}`}”吗?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
setDeletingThreadId(thread.id);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await authedFetch(`${THREADS_API_URL}/${thread.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`删除会话失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const nextThreads = threads.filter((item) => item.id !== thread.id);
|
||||
const nextArchivedThreads = archivedThreads.filter((item) => item.id !== thread.id);
|
||||
setThreads(nextThreads);
|
||||
setArchivedThreads(nextArchivedThreads);
|
||||
|
||||
if (currentThreadId === String(thread.id)) {
|
||||
const fallbackThread = [...nextThreads, ...nextArchivedThreads][0];
|
||||
onThreadSelect(fallbackThread ? String(fallbackThread.id) : '');
|
||||
}
|
||||
} catch (deleteError) {
|
||||
console.error('删除会话失败', deleteError);
|
||||
setError('删除会话失败,请稍后重试');
|
||||
} finally {
|
||||
setDeletingThreadId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const renderThreadSection = (title: string, items: SkillCreatorThread[], archived = false) => (
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{archived ? <Archive className="h-3.5 w-3.5" /> : <MessageSquareText className="h-3.5 w-3.5" />}
|
||||
<span className="text-[11px] font-semibold tracking-[0.14em]">{title}</span>
|
||||
</div>
|
||||
<span className={`text-[11px] ${isDarkMode ? 'text-slate-500' : 'text-gray-400'}`}>{items.length}</span>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
className={`rounded-xl border border-dashed px-3 py-4 text-center text-xs ${
|
||||
isDarkMode ? 'border-slate-700 text-slate-500' : 'border-gray-200 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{items.map((thread) => {
|
||||
const isActive = currentThreadId === String(thread.id);
|
||||
const isDeleting = deletingThreadId === thread.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={thread.id}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => onThreadSelect(String(thread.id))}
|
||||
className={`group flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 ${
|
||||
isActive
|
||||
? isDarkMode
|
||||
? 'border-slate-600 bg-slate-800/90 text-slate-50 shadow-sm'
|
||||
: 'border-red-200 bg-red-50/80 text-gray-900 shadow-sm'
|
||||
: isDarkMode
|
||||
? 'border-transparent text-slate-300 hover:border-slate-700 hover:bg-slate-800/60 hover:text-slate-100'
|
||||
: 'border-transparent text-gray-700 hover:border-gray-200 hover:bg-gray-50 hover:text-gray-900'
|
||||
} ${isBusy ? 'cursor-not-allowed opacity-70' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
|
||||
isActive
|
||||
? isDarkMode
|
||||
? 'bg-slate-700 text-slate-200'
|
||||
: 'bg-white text-red-500 ring-1 ring-red-100'
|
||||
: isDarkMode
|
||||
? 'bg-slate-800 text-slate-400'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{archived ? <Archive className="h-4 w-4" /> : <MessageSquareText className="h-4 w-4" />}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{thread.title || `会话 ${thread.id}`}</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDeleteThread(thread);
|
||||
}}
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
|
||||
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
|
||||
} ${
|
||||
isDarkMode
|
||||
? 'text-slate-400 hover:bg-rose-500/15 hover:text-rose-300'
|
||||
: 'text-gray-400 hover:bg-rose-50 hover:text-rose-500'
|
||||
}`}
|
||||
role="button"
|
||||
aria-label={`删除会话 ${thread.title || thread.id}`}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleDeleteThread(thread);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`flex h-full shrink-0 flex-col overflow-hidden border-r transition-[width,opacity,transform,border-color] duration-300 ease-out ${
|
||||
isOpen ? 'w-[286px] translate-x-0 opacity-100' : 'w-0 -translate-x-3 opacity-0 pointer-events-none'
|
||||
} ${
|
||||
isDarkMode ? 'border-slate-700 bg-slate-950/90 text-slate-100' : 'border-gray-200 bg-[#fafafa] text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div className={`border-b px-4 py-4 ${isDarkMode ? 'border-slate-800 bg-slate-900/70' : 'border-gray-200 bg-white'}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">聊天记录</h3>
|
||||
<p className={`mt-1 text-xs ${isDarkMode ? 'text-slate-500' : 'text-gray-500'}`}>{subtitle}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
|
||||
isDarkMode ? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
title="关闭聊天记录"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || isCreating}
|
||||
onClick={() => void handleCreateThread()}
|
||||
className={`inline-flex h-9 flex-1 items-center justify-center gap-2 rounded-lg border px-3 text-sm font-medium transition ${
|
||||
isDarkMode
|
||||
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-red-200 hover:bg-red-50 hover:text-red-600'
|
||||
} disabled:cursor-not-allowed disabled:opacity-70`}
|
||||
>
|
||||
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||
{createButtonText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={() => void refreshThreads()}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-lg border transition ${
|
||||
isDarkMode
|
||||
? 'border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'
|
||||
: 'border-gray-200 bg-white text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
} ${isLoading ? 'cursor-not-allowed opacity-70' : ''}`}
|
||||
title="刷新会话列表"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={`mt-3 rounded-lg px-3 py-2 text-xs ${isDarkMode ? 'bg-rose-500/10 text-rose-300' : 'bg-rose-50 text-rose-500'}`}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
|
||||
{isLoading && allThreads.length === 0 ? (
|
||||
<div className={`flex h-full items-center justify-center gap-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在加载聊天记录...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{renderThreadSection('进行中', threads)}
|
||||
{renderThreadSection('已归档', archivedThreads, true)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
Boxes,
|
||||
Braces,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Compass,
|
||||
Crown,
|
||||
Edit,
|
||||
FileCode2,
|
||||
Globe,
|
||||
LineChart,
|
||||
MoreVertical,
|
||||
PenTool,
|
||||
Pencil,
|
||||
PencilIcon,
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Target,
|
||||
Trash2,
|
||||
Upload,
|
||||
Wand2,
|
||||
Workflow,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useExpertAuthBootstrap } from '../../hooks';
|
||||
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
|
||||
import { MessagePlugin } from 'tdesign-react';
|
||||
|
||||
interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
highlight: string;
|
||||
relatedCount: number;
|
||||
version: string;
|
||||
inputCount: number;
|
||||
outputCount: number;
|
||||
isNew?: boolean;
|
||||
isMine: boolean;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface ApiSkill {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface SkillsResponse {
|
||||
items: ApiSkill[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
interface CreateSkillThreadResponse {
|
||||
id: number;
|
||||
title: string;
|
||||
archived: boolean;
|
||||
expert_agent_id: number | null;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
type CategoryKey = 'all' | 'writing' | 'analysis' | 'decision' | 'validation' | 'workflow';
|
||||
|
||||
const CATEGORY_LABELS: Record<CategoryKey, string> = {
|
||||
all: '全部类型',
|
||||
writing: '编辑类',
|
||||
analysis: '分析类',
|
||||
decision: '决策类',
|
||||
validation: '校验类',
|
||||
workflow: '流程类',
|
||||
};
|
||||
|
||||
const categoryOrder: CategoryKey[] = ['all', 'writing', 'analysis', 'decision', 'validation', 'workflow'];
|
||||
|
||||
const AVATAR_STYLES = [
|
||||
{
|
||||
Icon: Bot,
|
||||
wrapper: 'from-rose-100 via-red-50 to-white text-red-500',
|
||||
glow: 'shadow-red-100/80',
|
||||
ring: 'ring-red-100',
|
||||
orb: 'bg-red-500/10',
|
||||
},
|
||||
{
|
||||
Icon: Wand2,
|
||||
wrapper: 'from-violet-100 via-fuchsia-50 to-white text-violet-500',
|
||||
glow: 'shadow-violet-100/80',
|
||||
ring: 'ring-violet-100',
|
||||
orb: 'bg-violet-500/10',
|
||||
},
|
||||
{
|
||||
Icon: LineChart,
|
||||
wrapper: 'from-cyan-100 via-sky-50 to-white text-sky-500',
|
||||
glow: 'shadow-sky-100/80',
|
||||
ring: 'ring-sky-100',
|
||||
orb: 'bg-sky-500/10',
|
||||
},
|
||||
{
|
||||
Icon: ShieldCheck,
|
||||
wrapper: 'from-emerald-100 via-green-50 to-white text-emerald-500',
|
||||
glow: 'shadow-emerald-100/80',
|
||||
ring: 'ring-emerald-100',
|
||||
orb: 'bg-emerald-500/10',
|
||||
},
|
||||
{
|
||||
Icon: Workflow,
|
||||
wrapper: 'from-amber-100 via-yellow-50 to-white text-amber-500',
|
||||
glow: 'shadow-amber-100/80',
|
||||
ring: 'ring-amber-100',
|
||||
orb: 'bg-amber-500/10',
|
||||
},
|
||||
{
|
||||
Icon: FileCode2,
|
||||
wrapper: 'from-indigo-100 via-blue-50 to-white text-indigo-500',
|
||||
glow: 'shadow-indigo-100/80',
|
||||
ring: 'ring-indigo-100',
|
||||
orb: 'bg-indigo-500/10',
|
||||
},
|
||||
{
|
||||
Icon: Target,
|
||||
wrapper: 'from-pink-100 via-rose-50 to-white text-pink-500',
|
||||
glow: 'shadow-pink-100/80',
|
||||
ring: 'ring-pink-100',
|
||||
orb: 'bg-pink-500/10',
|
||||
},
|
||||
{
|
||||
Icon: Compass,
|
||||
wrapper: 'from-orange-100 via-amber-50 to-white text-orange-500',
|
||||
glow: 'shadow-orange-100/80',
|
||||
ring: 'ring-orange-100',
|
||||
orb: 'bg-orange-500/10',
|
||||
},
|
||||
];
|
||||
|
||||
const hashString = (value: string) =>
|
||||
value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
|
||||
const inferCategory = (skill: ApiSkill): CategoryKey => {
|
||||
const text = `${skill.name} ${skill.description}`.toLowerCase();
|
||||
|
||||
if (/(summary|readme|writer|copy|frontend|narrative|edit|document)/.test(text)) return 'writing';
|
||||
if (/(analysis|audience|risk|search|insight|research|data)/.test(text)) return 'analysis';
|
||||
if (/(plan|decision|goal|strategy|logic|outreach)/.test(text)) return 'decision';
|
||||
if (/(check|validate|review|guard|test|lint)/.test(text)) return 'validation';
|
||||
return 'workflow';
|
||||
};
|
||||
|
||||
const buildVersion = (id: number) => `v${1 + (id % 3)}.${(id % 8) + 1}`;
|
||||
const buildCount = (id: number, offset: number) => 2 + ((id + offset) % 4);
|
||||
const buildRelatedCount = (id: number) => 3 + (id % 4);
|
||||
|
||||
const mapApiSkillToUiSkill = (skill: ApiSkill, type: 'official' | 'mine'): Skill => ({
|
||||
id: String(skill.id),
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
category: inferCategory(skill),
|
||||
highlight: type === 'mine' ? '已接入' : '可添加',
|
||||
relatedCount: buildRelatedCount(skill.id),
|
||||
version: buildVersion(skill.id),
|
||||
inputCount: buildCount(skill.id, 1),
|
||||
outputCount: buildCount(skill.id, 2),
|
||||
isNew: skill.id % 3 === 0,
|
||||
isMine: type === 'mine',
|
||||
});
|
||||
|
||||
const getAvatarStyle = (skillId: string) => AVATAR_STYLES[hashString(skillId) % AVATAR_STYLES.length];
|
||||
|
||||
export default function SkillsWorkshop() {
|
||||
const navigate = useNavigate();
|
||||
const { authedFetch } = useExpertAuthBootstrap();
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedFilter, setSelectedFilter] = useState<'all' | 'mine'>('all');
|
||||
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all');
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
|
||||
const [isCreatingSkillThread, setIsCreatingSkillThread] = useState(false);
|
||||
const [openCardMenuId, setOpenCardMenuId] = useState<string | null>(null);
|
||||
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
|
||||
const [addingSkillId, setAddingSkillId] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isCreatingByUpload, setIsCreatingByUpload] = useState(false);
|
||||
const [isAiEditing, setIsAiEditing] = useState(false);
|
||||
|
||||
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const cardMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const createUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
const fetchByType = async (type: 'official' | 'mine') => {
|
||||
const params = new URLSearchParams({
|
||||
type,
|
||||
keyword: searchTerm.trim(),
|
||||
page: '1',
|
||||
page_size: '100',
|
||||
});
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`加载技能失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const data = (await res.json()) as SkillsResponse;
|
||||
return data.items.map((item) => mapApiSkillToUiSkill(item, type));
|
||||
};
|
||||
|
||||
const loadSkills = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const list =
|
||||
selectedFilter === 'all'
|
||||
? await Promise.all([fetchByType('official'), fetchByType('mine')])
|
||||
: [await fetchByType('mine')];
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
const merged = selectedFilter === 'all' ? [...list[0], ...list[1]] : list[0];
|
||||
const deduped = Array.from(new Map(merged.map((item) => [item.id, item])).values());
|
||||
setSkills(deduped);
|
||||
} catch {
|
||||
if (canceled) return;
|
||||
setError('加载技能失败,请稍后重试');
|
||||
setSkills([]);
|
||||
} finally {
|
||||
if (!canceled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadSkills();
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [searchTerm, selectedFilter, authedFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (createMenuRef.current && !createMenuRef.current.contains(target)) {
|
||||
setIsCreateMenuOpen(false);
|
||||
}
|
||||
if (cardMenuRef.current && !cardMenuRef.current.contains(target)) {
|
||||
setOpenCardMenuId(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filteredSkills = useMemo(
|
||||
() => skills.filter((skill) => selectedCategory === 'all' || skill.category === selectedCategory),
|
||||
[selectedCategory, skills],
|
||||
);
|
||||
|
||||
const categoryCounts = useMemo(
|
||||
() =>
|
||||
categoryOrder.reduce<Record<CategoryKey, number>>((acc, category) => {
|
||||
acc[category] = category === 'all' ? skills.length : skills.filter((skill) => skill.category === category).length;
|
||||
return acc;
|
||||
}, {} as Record<CategoryKey, number>),
|
||||
[skills],
|
||||
);
|
||||
|
||||
const mySkillCount = skills.filter((skill) => skill.isMine).length;
|
||||
const officialSkillCount = skills.filter((skill) => !skill.isMine).length;
|
||||
|
||||
const handlePublishSkill = async (skillId: string) => {
|
||||
setOpenCardMenuId(null);
|
||||
try {
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/skills/publish`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
skill_id: parseInt(skillId, 10),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`发布失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('发布技能失败', err);
|
||||
setError('发布技能失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditSkill = (skillId: string) => {
|
||||
setOpenCardMenuId(null);
|
||||
const skill = skills.find((item) => item.id === skillId) ?? null;
|
||||
setEditingSkill(skill);
|
||||
};
|
||||
|
||||
const handleDeleteSkill = async (skillId: string) => {
|
||||
setOpenCardMenuId(null);
|
||||
try {
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/mine/${skillId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`删除失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
setSkills((prev) => prev.filter((item) => item.id !== skillId));
|
||||
MessagePlugin.success('操作成功');
|
||||
} catch (err) {
|
||||
console.error('删除技能失败', err);
|
||||
setError('删除技能失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadSkillFile = async (file: File, skillId: string) => {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/${skillId}/upload`, {
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`上传失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
setEditingSkill(null);
|
||||
} catch (err) {
|
||||
console.error('上传技能失败', err);
|
||||
setError('上传技能失败,请稍后重试');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (uploadInputRef.current) uploadInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddOfficialSkill = async (skillId: string) => {
|
||||
if (addingSkillId) return;
|
||||
setAddingSkillId(skillId);
|
||||
try {
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/mine/add`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ official_skill_id: parseInt(skillId, 10) }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`添加失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
setSkills((prev) => prev.map((item) => (item.id === skillId ? { ...item, isMine: true, highlight: '已接入' } : item)));
|
||||
MessagePlugin.success('添加成功');
|
||||
} catch (err) {
|
||||
console.error('添加技能失败', err);
|
||||
setError('添加技能失败,请稍后重试');
|
||||
} finally {
|
||||
setAddingSkillId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAiEditSkill = async (skill: Skill) => {
|
||||
if (isAiEditing) return;
|
||||
setIsAiEditing(true);
|
||||
try {
|
||||
const threadRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: `编辑技能:${skill.name}`,
|
||||
archived: true,
|
||||
is_real_chat: true,
|
||||
}),
|
||||
});
|
||||
if (!threadRes.ok) {
|
||||
throw new Error(`创建会话失败: ${threadRes.status} ${threadRes.statusText}`);
|
||||
}
|
||||
const threadData = (await threadRes.json()) as CreateSkillThreadResponse;
|
||||
const threadId = threadData?.id;
|
||||
if (!threadId) throw new Error('创建会话成功,但未返回会话 id');
|
||||
|
||||
const copyRes = await authedFetch(
|
||||
`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${threadId}/copy-skill/${skill.id}`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!copyRes.ok) {
|
||||
throw new Error(`复制技能失败: ${copyRes.status} ${copyRes.statusText}`);
|
||||
}
|
||||
|
||||
setEditingSkill(null);
|
||||
localStorage.setItem('skill_creator_thread_id', String(threadId));
|
||||
navigate(`/skill-creator?id=${threadId}`);
|
||||
} catch (err) {
|
||||
console.error('AI 辅助编辑失败', err);
|
||||
setError('AI 辅助编辑失败,请稍后重试');
|
||||
} finally {
|
||||
setIsAiEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSkillByUpload = async (file: File) => {
|
||||
setIsCreatingByUpload(true);
|
||||
setIsCreateMenuOpen(false);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`上传失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('上传技能失败', err);
|
||||
setError('上传技能失败,请稍后重试');
|
||||
} finally {
|
||||
setIsCreatingByUpload(false);
|
||||
if (createUploadInputRef.current) createUploadInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSkillByAgent = async () => {
|
||||
if (isCreatingSkillThread) return;
|
||||
setIsCreatingSkillThread(true);
|
||||
setIsCreateMenuOpen(false);
|
||||
try {
|
||||
const threadRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: '新建技能',
|
||||
archived: true,
|
||||
is_real_chat: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!threadRes.ok) {
|
||||
throw new Error(`创建技能会话失败: ${threadRes.status} ${threadRes.statusText}`);
|
||||
}
|
||||
|
||||
const threadData = (await threadRes.json()) as CreateSkillThreadResponse;
|
||||
const threadId = threadData?.id;
|
||||
if (!threadId) {
|
||||
throw new Error('创建技能会话成功,但未返回会话 id');
|
||||
}
|
||||
|
||||
localStorage.setItem('skill_creator_thread_id', String(threadId));
|
||||
navigate(`/qa-center/skill-creator?id=${threadId}`);
|
||||
} catch (createError) {
|
||||
console.error('创建技能会话失败', createError);
|
||||
setError('创建技能会话失败,请稍后重试');
|
||||
} finally {
|
||||
setIsCreatingSkillThread(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-full min-h-[calc(100vh-140px)] ${isDarkMode ? 'bg-slate-950 text-slate-100' : 'bg-[#fcfcfd] text-[#101828]'}`}>
|
||||
{editingSkill && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) setEditingSkill(null);
|
||||
}}
|
||||
>
|
||||
<div className={`relative w-[440px] overflow-hidden rounded-[28px] border shadow-2xl ${isDarkMode ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-[#f3d6d6] bg-white text-gray-900'}`}>
|
||||
<div className="flex items-center justify-between px-6 pb-4 pt-5">
|
||||
<h2 className="text-base font-semibold">编辑技能</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingSkill(null)}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${isDarkMode ? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100' : 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className={`px-6 pb-4 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
选择编辑方式:<span className={`font-medium ${isDarkMode ? 'text-slate-200' : 'text-gray-800'}`}>{editingSkill.name}</span>
|
||||
</p>
|
||||
|
||||
<div className={`mx-6 border-t ${isDarkMode ? 'border-slate-700' : 'border-[#f4e4e4]'}`} />
|
||||
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isAiEditing}
|
||||
onClick={() => void handleAiEditSkill(editingSkill)}
|
||||
className={`group/opt flex w-full items-center gap-4 rounded-[22px] border px-4 py-4 text-left transition ${isDarkMode ? 'border-slate-700 bg-slate-800/60 hover:bg-slate-800' : 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'} ${isAiEditing ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-indigo-600 text-white shadow-lg shadow-violet-500/30 ring-1 ring-white/20">
|
||||
<Bot className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
{isAiEditing ? '处理中...' : 'AI 辅助编辑'}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>通过对话开启 AI 帮助您优化技能内容</p>
|
||||
</div>
|
||||
<ChevronRight className={`h-4 w-4 shrink-0 transition ${isDarkMode ? 'text-slate-500 group-hover/opt:text-slate-300' : 'text-gray-300 group-hover/opt:text-gray-500'}`} />
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept=".md,.zip"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && editingSkill) {
|
||||
void handleUploadSkillFile(file, editingSkill.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isUploading}
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
className={`group/opt flex w-full items-center gap-4 rounded-[22px] border px-4 py-4 text-left transition ${isDarkMode ? 'border-slate-700 bg-slate-800/60 hover:bg-slate-800' : 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'} ${isUploading ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-lg shadow-orange-400/30 ring-1 ring-white/20">
|
||||
<Upload className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
{isUploading ? '上传中...' : '上传新版覆盖'}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>上传 `.md` 或 `.zip` 文件替换当前内容</p>
|
||||
</div>
|
||||
<ChevronRight className={`h-4 w-4 shrink-0 transition ${isDarkMode ? 'text-slate-500 group-hover/opt:text-slate-300' : 'text-gray-300 group-hover/opt:text-gray-500'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center justify-end gap-3 px-6 pb-5 pt-1 ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingSkill(null)}
|
||||
className={`rounded-xl px-4 py-2 text-sm transition ${isDarkMode ? 'hover:bg-slate-800 hover:text-slate-200' : 'hover:bg-[#fff3f3] hover:text-[#ef4444]'}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto max-w-[1360px] px-6 py-8">
|
||||
<section className={`mb-7 ${isDarkMode ? '' : ''}`}>
|
||||
<div className="flex items-start justify-between gap-5">
|
||||
<div>
|
||||
<h1 className={`text-[22px] font-bold tracking-tight ${isDarkMode ? 'text-slate-100' : 'text-[#111827]'}`}>技能工坊</h1>
|
||||
<p className={`mt-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>统一管理系统中的技能资产,支持发现、接入、发布与编辑。</p>
|
||||
</div>
|
||||
|
||||
<div className="relative" ref={createMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateMenuOpen((prev) => !prev)}
|
||||
className="inline-flex h-11 items-center gap-2 rounded-xl bg-[#ea2d2d] px-5 text-sm font-medium text-white shadow-[0_10px_20px_-12px_rgba(234,45,45,0.7)] transition hover:bg-[#d92626]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建技能体
|
||||
</button>
|
||||
|
||||
{isCreateMenuOpen && (
|
||||
<div className={`absolute right-0 top-[calc(100%+10px)] z-20 w-60 rounded-2xl border py-2 shadow-xl ${isDarkMode ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isCreatingSkillThread}
|
||||
onClick={() => void handleCreateSkillByAgent()}
|
||||
className={`w-full px-4 py-3 text-left transition ${isDarkMode ? 'hover:bg-slate-800' : 'hover:bg-[#fff6f6]'} ${isCreatingSkillThread ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>{isCreatingSkillThread ? '创建中...' : '通过 Agent 构建'}</p>
|
||||
<p className={`mt-1 text-xs ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>通过对话构建和训练 AI 技能</p>
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={createUploadInputRef}
|
||||
type="file"
|
||||
accept=".md,.zip"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void handleCreateSkillByUpload(file);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isCreatingByUpload}
|
||||
onClick={() => createUploadInputRef.current?.click()}
|
||||
className={`w-full px-4 py-3 text-left transition ${isDarkMode ? 'hover:bg-slate-800' : 'hover:bg-[#fff6f6]'} ${isCreatingByUpload ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>{isCreatingByUpload ? '上传中...' : '上传技能文件'}</p>
|
||||
<p className={`mt-1 text-xs ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>导入 `.md` 或 `.zip` 文件</p>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`mb-8 rounded-[28px] border px-6 py-6 ${isDarkMode ? 'border-slate-800 bg-slate-900/65' : 'border-[#f5d4d4] bg-white'}`}>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className={`flex min-w-0 flex-1 items-center gap-3 rounded-2xl border px-4 py-3 ${isDarkMode ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'}`}>
|
||||
<Search className={`h-4 w-4 ${isDarkMode ? 'text-slate-400' : 'text-[#ff6b6b]'}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索技能名称..."
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
className={`min-w-0 flex-1 bg-transparent text-sm outline-none ${isDarkMode ? 'text-slate-100 placeholder:text-slate-500' : 'text-gray-800 placeholder:text-[#9ca3af]'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`inline-flex rounded-2xl border p-1 ${isDarkMode ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedFilter('all')}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${selectedFilter === 'all' ? 'bg-[#fff1f1] text-[#ef4444]' : isDarkMode ? 'text-slate-300 hover:bg-slate-800' : 'text-[#475467] hover:bg-[#f9fafb]'}`}
|
||||
>
|
||||
全部类型
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedFilter('mine')}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${selectedFilter === 'mine' ? 'bg-[#fff1f1] text-[#ef4444]' : isDarkMode ? 'text-slate-300 hover:bg-slate-800' : 'text-[#475467] hover:bg-[#f9fafb]'}`}
|
||||
>
|
||||
我的技能
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="flex flex-wrap items-center gap-3">
|
||||
<span className={`text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#475467]'}`}>按类型筛选:</span>
|
||||
{categoryOrder.slice(1).map((category) => {
|
||||
const active = selectedCategory === category;
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategory(active ? 'all' : category)}
|
||||
className={`rounded-full border px-4 py-2 text-sm transition ${
|
||||
active
|
||||
? 'border-[#ffb5b5] bg-[#fff5f5] text-[#ef4444]'
|
||||
: isDarkMode
|
||||
? 'border-slate-700 bg-slate-900/40 text-slate-300 hover:border-slate-600'
|
||||
: 'border-[#f3d0d0] bg-white text-[#667085] hover:border-[#ffcccc] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_LABELS[category]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div> */}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{loading ? (
|
||||
<div className={`flex items-center justify-center rounded-[28px] border py-20 text-sm ${isDarkMode ? 'border-slate-800 bg-slate-900/60 text-slate-300' : 'border-[#f5d4d4] bg-white text-[#667085]'}`}>
|
||||
正在加载技能...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${isDarkMode ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'}`}>
|
||||
<h3 className={`mb-2 text-lg font-semibold ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>加载失败</h3>
|
||||
<p className={`text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>{error}</p>
|
||||
</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${isDarkMode ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'}`}>
|
||||
<div className={`mb-4 flex h-12 w-12 items-center justify-center rounded-2xl ${isDarkMode ? 'bg-slate-800 text-slate-400' : 'bg-[#fff1f1] text-[#ff6b6b]'}`}>
|
||||
<Search className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className={`mb-2 text-lg font-semibold ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>未找到匹配的技能</h3>
|
||||
<p className={`text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>尝试调整搜索词或切换筛选条件。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{filteredSkills.map((skill) => {
|
||||
const avatar = getAvatarStyle(skill.id);
|
||||
const AvatarIcon = avatar.Icon;
|
||||
const categoryLabel = CATEGORY_LABELS[skill.category as CategoryKey] ?? '流程类';
|
||||
|
||||
return (
|
||||
<article
|
||||
key={skill.id}
|
||||
className={`group relative rounded-[22px] border px-5 py-5 transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:shadow-xl hover:shadow-black/20'
|
||||
: 'border-[#f5d4d4] bg-white hover:border-[#ffbdbd] hover:shadow-[0_16px_40px_-26px_rgba(239,68,68,0.35)]'
|
||||
}`}
|
||||
>
|
||||
|
||||
{!skill.isMine && selectedFilter === 'all' && (
|
||||
<div className="absolute right-4 top-4 z-20 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
disabled={addingSkillId === skill.id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleAddOfficialSkill(skill.id);
|
||||
}}
|
||||
className={`flex items-center justify-center rounded-lg bg-emerald-500 text-white shadow-sm transition hover:bg-emerald-600 ${addingSkillId === skill.id ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{skill.isMine && (
|
||||
<div className="absolute right-4 top-4 z-20" ref={openCardMenuId === skill.id ? cardMenuRef : null}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpenCardMenuId((prev) => (prev === skill.id ? null : skill.id));
|
||||
}}
|
||||
className={`flex items-center justify-center rounded-lg transition ${isDarkMode ? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'}`}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{openCardMenuId === skill.id && (
|
||||
<div className={`absolute right-0 top-[calc(100%+6px)] z-30 w-40 rounded-2xl border py-1 shadow-xl ${isDarkMode ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handlePublishSkill(skill.id);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${isDarkMode ? 'text-emerald-400 hover:bg-slate-800' : 'text-emerald-600 hover:bg-[#fff6f6]'}`}
|
||||
>
|
||||
<Globe className="h-4 w-4 shrink-0" />
|
||||
发布到全部
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleEditSkill(skill.id);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${isDarkMode ? 'text-slate-300 hover:bg-slate-800' : 'text-amber-600 hover:bg-[#fff6f6]'}`}
|
||||
>
|
||||
<Edit className="h-4 w-4 shrink-0" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDeleteSkill(skill.id);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${isDarkMode ? 'text-rose-400 hover:bg-slate-800' : 'text-rose-500 hover:bg-[#fff6f6]'}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className={`relative flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br ${avatar.wrapper} shadow-[0_18px_32px_-22px_rgba(15,23,42,0.35)] ${avatar.glow} ring-1 ${avatar.ring}`}>
|
||||
<div className={`absolute inset-0 rounded-2xl ${avatar.orb}`} />
|
||||
<AvatarIcon className="relative z-10 h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className={`text-[17px] font-semibold ${skill.isMine ? 'text-[#111827]' : isDarkMode ? 'text-slate-100' : 'text-[#111827]'}`}>{skill.name}</h3>
|
||||
<span className="inline-flex items-center rounded-full bg-[#fff2f2] px-2.5 py-1 text-xs font-medium text-[#ef4444]">
|
||||
{ skill.isMine ? '我的' : '官方' }
|
||||
</span>
|
||||
</div>
|
||||
<p className={`mt-1 line-clamp-3 text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>{skill.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
export interface AgentStepItem {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AgentParsed {
|
||||
steps: AgentStepItem[];
|
||||
thinkingMarkdown: string;
|
||||
answerMarkdown: string;
|
||||
thinkingComplete: boolean;
|
||||
}
|
||||
|
||||
const STEP_RE = /<step\s+title="([^"]+)">([\s\S]*?)<\/step>/g;
|
||||
const ANSWER_RE = /<answer>([\s\S]*?)<\/answer>/;
|
||||
const THINKING_COMMENT_RE = /<!--\s*thinking:\s*([\s\S]*?)\s*-->/i;
|
||||
const WAITING_PREFIX = '仍在等待模型首个 token...';
|
||||
|
||||
const stripCodeFence = (raw: string): string => {
|
||||
const trimmed = raw.trim();
|
||||
const match = /```(?:json)?\s*([\s\S]*?)```/i.exec(trimmed);
|
||||
if (match) return match[1].trim();
|
||||
return trimmed.replace(/^```(?:json)?\s*/i, '').trim();
|
||||
};
|
||||
|
||||
const stripThinkTags = (
|
||||
text: string,
|
||||
): { remainder: string; thinkBlock: string; closed: boolean } => {
|
||||
const start = text.indexOf('<think>');
|
||||
if (start === -1) return { remainder: text, thinkBlock: '', closed: false };
|
||||
const after = text.slice(start + '<think>'.length);
|
||||
const endIdx = after.indexOf('</think>');
|
||||
if (endIdx === -1) {
|
||||
return {
|
||||
remainder: text.slice(0, start),
|
||||
thinkBlock: after,
|
||||
closed: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
remainder: text.slice(0, start) + after.slice(endIdx + '</think>'.length),
|
||||
thinkBlock: after.slice(0, endIdx),
|
||||
closed: true,
|
||||
};
|
||||
};
|
||||
|
||||
const mergeStep = (steps: AgentStepItem[], nextStep: AgentStepItem) => {
|
||||
const existingIndex = steps.findIndex((step) => step.title === nextStep.title);
|
||||
if (existingIndex === -1) {
|
||||
steps.push(nextStep);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = steps[existingIndex];
|
||||
const nextIsWaiting = nextStep.content.startsWith(WAITING_PREFIX);
|
||||
const existingLines = existing.content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const stableLines = existingLines.filter((line) => !line.startsWith(WAITING_PREFIX));
|
||||
|
||||
if (nextIsWaiting) {
|
||||
const mergedLines = stableLines.length > 0 ? [...stableLines, nextStep.content] : [nextStep.content];
|
||||
steps[existingIndex] = {
|
||||
...existing,
|
||||
content: mergedLines.join('\n'),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedLines = stableLines.includes(nextStep.content)
|
||||
? stableLines
|
||||
: [...stableLines, nextStep.content];
|
||||
steps[existingIndex] = {
|
||||
...existing,
|
||||
content: normalizedLines.join('\n'),
|
||||
};
|
||||
};
|
||||
|
||||
const extractSteps = (text: string): { steps: AgentStepItem[]; remainder: string } => {
|
||||
const steps: AgentStepItem[] = [];
|
||||
const remainder = text.replace(STEP_RE, (_, title: string, content: string) => {
|
||||
mergeStep(steps, { title: title.trim(), content: content.trim() });
|
||||
return '';
|
||||
});
|
||||
return { steps, remainder };
|
||||
};
|
||||
|
||||
const extractTaggedSection = (
|
||||
text: string,
|
||||
re: RegExp,
|
||||
): { value: string; remainder: string } => {
|
||||
const match = re.exec(text);
|
||||
if (!match) return { value: '', remainder: text };
|
||||
return {
|
||||
value: match[1].trim(),
|
||||
remainder: text.replace(match[0], '').trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const extractJsonStringField = (
|
||||
text: string,
|
||||
field: string,
|
||||
): { value: string; complete: boolean; matched: boolean } => {
|
||||
const re = new RegExp(`"${field}"\\s*:\\s*"`);
|
||||
const match = re.exec(text);
|
||||
if (!match) return { value: '', complete: false, matched: false };
|
||||
let i = match.index + match[0].length;
|
||||
let value = '';
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
if (ch === '\\') {
|
||||
if (i + 1 >= text.length) return { value, complete: false, matched: true };
|
||||
const next = text[i + 1];
|
||||
switch (next) {
|
||||
case 'n':
|
||||
value += '\n';
|
||||
break;
|
||||
case 't':
|
||||
value += '\t';
|
||||
break;
|
||||
case 'r':
|
||||
value += '\r';
|
||||
break;
|
||||
case '"':
|
||||
value += '"';
|
||||
break;
|
||||
case '\\':
|
||||
value += '\\';
|
||||
break;
|
||||
case '/':
|
||||
value += '/';
|
||||
break;
|
||||
case 'b':
|
||||
value += '\b';
|
||||
break;
|
||||
case 'f':
|
||||
value += '\f';
|
||||
break;
|
||||
case 'u':
|
||||
if (i + 5 < text.length) {
|
||||
const hex = text.slice(i + 2, i + 6);
|
||||
value += String.fromCharCode(parseInt(hex, 16));
|
||||
i += 6;
|
||||
continue;
|
||||
}
|
||||
return { value, complete: false, matched: true };
|
||||
default:
|
||||
value += next;
|
||||
}
|
||||
i += 2;
|
||||
} else if (ch === '"') {
|
||||
return { value, complete: true, matched: true };
|
||||
} else {
|
||||
value += ch;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return { value, complete: false, matched: true };
|
||||
};
|
||||
|
||||
const extractItems = (text: string): unknown[] => {
|
||||
const re = /"items"\s*:\s*\[/;
|
||||
const match = re.exec(text);
|
||||
if (!match) return [];
|
||||
let i = match.index + match[0].length;
|
||||
const out: unknown[] = [];
|
||||
while (i < text.length) {
|
||||
while (i < text.length && /[\s,]/.test(text[i])) i++;
|
||||
if (i >= text.length) break;
|
||||
if (text[i] === ']') break;
|
||||
if (text[i] !== '{') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let depth = 0;
|
||||
let inStr = false;
|
||||
let escape = false;
|
||||
let j = i;
|
||||
for (; j < text.length; j++) {
|
||||
const c = text[j];
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (inStr) {
|
||||
if (c === '\\') escape = true;
|
||||
else if (c === '"') inStr = false;
|
||||
} else if (c === '"') inStr = true;
|
||||
else if (c === '{') depth++;
|
||||
else if (c === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
j++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (depth !== 0) break;
|
||||
try {
|
||||
out.push(JSON.parse(text.slice(i, j)));
|
||||
} catch {
|
||||
// ignore partial item
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const itemToMarkdown = (item: unknown, idx: number): string => {
|
||||
if (item == null) return `${idx + 1}.`;
|
||||
if (typeof item !== 'object') return `${idx + 1}. ${String(item)}`;
|
||||
const o = item as Record<string, unknown>;
|
||||
const title =
|
||||
(typeof o.title === 'string' && o.title) ||
|
||||
(typeof o.expected_behavior === 'string' && o.expected_behavior) ||
|
||||
(typeof o.name === 'string' && o.name) ||
|
||||
`条目 ${idx + 1}`;
|
||||
const body =
|
||||
(typeof o.content === 'string' && o.content) ||
|
||||
(typeof o.reason === 'string' && o.reason) ||
|
||||
'';
|
||||
|
||||
const lines: string[] = [`**${idx + 1}. ${title}**`];
|
||||
if (body) lines.push(body);
|
||||
|
||||
if (typeof o.expected_behavior === 'string' && typeof o.driving_factor === 'string') {
|
||||
if (!o.title) {
|
||||
lines.length = 0;
|
||||
lines.push(`**${idx + 1}. ${o.expected_behavior}**`);
|
||||
}
|
||||
lines.push(`_驱动因素_:${o.driving_factor}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(o.references) && o.references.length > 0) {
|
||||
const refs = (o.references as unknown[])
|
||||
.filter((x) => typeof x === 'string' || typeof x === 'number')
|
||||
.map((x) => `\`${String(x)}\``)
|
||||
.join(' ');
|
||||
if (refs) lines.push(`引用:${refs}`);
|
||||
}
|
||||
|
||||
return lines.join('\n\n');
|
||||
};
|
||||
|
||||
const itemsToMarkdown = (items: unknown[]): string =>
|
||||
items.map((item, idx) => itemToMarkdown(item, idx)).join('\n\n---\n\n');
|
||||
|
||||
export const parseAgentResponse = (raw: string): AgentParsed => {
|
||||
if (!raw) {
|
||||
return {
|
||||
steps: [],
|
||||
thinkingMarkdown: '',
|
||||
answerMarkdown: '',
|
||||
thinkingComplete: false,
|
||||
};
|
||||
}
|
||||
|
||||
const stripped = stripThinkTags(raw);
|
||||
const stepsResult = extractSteps(stripped.remainder);
|
||||
let body = stripCodeFence(stepsResult.remainder);
|
||||
const extraThinking = stripped.thinkBlock;
|
||||
let thinkingComplete = stripped.closed;
|
||||
|
||||
const answerSection = extractTaggedSection(body, ANSWER_RE);
|
||||
body = answerSection.remainder;
|
||||
const thinkingCommentSection = extractTaggedSection(body, THINKING_COMMENT_RE);
|
||||
body = thinkingCommentSection.remainder;
|
||||
|
||||
if (body) {
|
||||
const start = body.indexOf('{');
|
||||
if (start !== -1) body = body.slice(start);
|
||||
}
|
||||
|
||||
if (body) {
|
||||
try {
|
||||
const obj = JSON.parse(body) as Record<string, unknown>;
|
||||
if (obj && typeof obj === 'object') {
|
||||
const thinking = typeof obj.thinking === 'string' ? obj.thinking : '';
|
||||
const items = Array.isArray(obj.items) ? (obj.items as unknown[]) : [];
|
||||
const answerMarkdown = items.length > 0 ? itemsToMarkdown(items) : answerSection.value;
|
||||
return {
|
||||
steps: stepsResult.steps,
|
||||
thinkingMarkdown: [extraThinking, thinking, thinkingCommentSection.value]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
answerMarkdown,
|
||||
thinkingComplete: true,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const thinkingField = extractJsonStringField(body, 'thinking');
|
||||
const items = extractItems(body);
|
||||
|
||||
if (thinkingField.complete) thinkingComplete = true;
|
||||
|
||||
let fallbackAnswer = answerSection.value || body.trim();
|
||||
if (items.length > 0) {
|
||||
fallbackAnswer = itemsToMarkdown(items);
|
||||
}
|
||||
|
||||
if (!thinkingField.matched && items.length === 0 && !extraThinking) {
|
||||
return {
|
||||
steps: stepsResult.steps,
|
||||
thinkingMarkdown: '',
|
||||
answerMarkdown: fallbackAnswer,
|
||||
thinkingComplete: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
steps: stepsResult.steps,
|
||||
thinkingMarkdown: [extraThinking, thinkingField.value, thinkingCommentSection.value]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
answerMarkdown: fallbackAnswer,
|
||||
thinkingComplete,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,736 @@
|
||||
# Agent 组件开发问答(Q&A)
|
||||
|
||||
> 目录范围:`src/components/agent`
|
||||
>
|
||||
> 涵盖文件:
|
||||
> - `AgentExpertCenterPage.tsx`
|
||||
> - `AgentExpertEditPage.tsx`
|
||||
> - `AgentChatPage.tsx`
|
||||
> - `SkillCreatorPage.tsx`
|
||||
> - `SkillsWorkshop.tsx`
|
||||
> - `SkillCreatorThreadSidebar.tsx`
|
||||
> - `AgentConversationList.tsx`
|
||||
> - `SkillCreatorConversationItem.tsx`
|
||||
> - `ChatInputComposer.tsx`
|
||||
> - `AgentChatPage.css`
|
||||
|
||||
---
|
||||
|
||||
## Q1:这个目录整体是干什么的?
|
||||
|
||||
**A:这是“专家 Agent + 技能工坊 + 技能创建对话”的前端 UI 组件集合。**
|
||||
|
||||
- `AgentExpertCenterPage`:专家列表入口(我的专家/发布专家)。
|
||||
- `AgentExpertEditPage`:专家配置编辑页(左侧对话预览 + 右侧配置)。
|
||||
- `AgentChatPage`:某个专家的真实聊天页(含线程、文件上传、流式回答)。
|
||||
- `SkillsWorkshop`:技能资产中心(搜索/筛选/接入/发布/删除/编辑)。
|
||||
- `SkillCreatorPage`:技能创建/改写专用对话页(支持文件树预览和保存弹窗)。
|
||||
- `SkillCreatorThreadSidebar`:复用的会话侧边栏(获取线程、新建、删除、切换)。
|
||||
- `AgentConversationList` + `SkillCreatorConversationItem`:统一消息列表与消息卡片渲染。
|
||||
- `ChatInputComposer`:统一输入框(发送/停止/上传)。
|
||||
- `AgentChatPage.css`:`AgentChatPage` 专用动画和滚动条样式。
|
||||
|
||||
---
|
||||
|
||||
## Q2:核心共享数据模型有哪些?
|
||||
|
||||
**A:对话层主要围绕 `ConversationMessage`。**
|
||||
|
||||
- 定义位置:`AgentConversationList.tsx`
|
||||
- 字段:
|
||||
- `id`: 消息唯一标识。
|
||||
- `role`: `'user' | 'assistant'`
|
||||
- `content`: 文本内容(markdown 渲染)。
|
||||
- `steps`: 思考步骤列表(数据来自 `data-thinking-step`)。
|
||||
- `thinkings`: 推理内容列表(来自 `reasoning-*` 事件)。
|
||||
- `toolCalls`: 工具调用(输入输出)列表。
|
||||
|
||||
**A:文件树使用 `FileNode`。**
|
||||
|
||||
- 在 `AgentChatPage`、`SkillCreatorPage` 内部定义并使用。
|
||||
- 字段:`name/path/type/size/extension/children`。
|
||||
|
||||
**A:线程侧边栏使用 `SkillCreatorThread`。**
|
||||
|
||||
- 定义位置:`SkillCreatorThreadSidebar.tsx`
|
||||
- 字段:`id/title/archived/expert_agent_id/created_at/updated_at`。
|
||||
|
||||
---
|
||||
|
||||
## Q3:整个目录的“数据流主干”怎么走?
|
||||
|
||||
**A:可以看成 3 条主链路。**
|
||||
|
||||
### 1) 专家链路(Center -> Edit -> Chat)
|
||||
|
||||
1. `AgentExpertCenterPage` 拉专家列表(我的/发布)。
|
||||
2. 点击“编辑”跳 `AgentExpertEditPage`,加载专家详情 + 工具 + 技能绑定状态。
|
||||
3. 点击“进行对话”跳 `AgentChatPage`,加载该专家详情和对应会话消息。
|
||||
|
||||
### 2) 技能链路(Workshop -> SkillCreator)
|
||||
|
||||
1. `SkillsWorkshop` 拉技能列表(official/mine),本地映射成 UI `Skill`。
|
||||
2. 新建技能:创建会话后跳 `SkillCreatorPage?id=threadId`。
|
||||
3. AI 辅助编辑已有技能:先创建线程,再 copy skill 到线程,再跳转 `SkillCreatorPage`。
|
||||
|
||||
### 3) 对话链路(输入 -> SSE -> UI -> 持久化)
|
||||
|
||||
1. `ChatInputComposer` 触发 `onSend`。
|
||||
2. 页面组件(`AgentChatPage` 或 `SkillCreatorPage`)执行 `handleSendMessage`。
|
||||
3. 先写入用户消息 + assistant 占位消息。
|
||||
4. 调流式接口读取 SSE:
|
||||
- `data-thinking-step` -> 更新 `steps`
|
||||
- `reasoning-start/delta/end` -> 更新 `thinkings`
|
||||
- `tool-input-available/tool-output-available` -> 更新 `toolCalls`
|
||||
- `text-delta` -> 追加 `content`
|
||||
5. 完成后将 assistant 结果再次 POST 到消息接口持久化。
|
||||
|
||||
---
|
||||
|
||||
## Q4:`AgentExpertCenterPage` 组件是干什么的?
|
||||
|
||||
**A:专家资产“总览入口”,负责展示列表和基础操作入口。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 展示“我的专家”和“发布专家”两种视图。
|
||||
- 打开专家详情弹窗(支持发布状态切换)。
|
||||
- 提供“编辑”和“进行对话”跳转入口。
|
||||
|
||||
### 数据来源与接口
|
||||
|
||||
- 列表查询:
|
||||
- `POST /api/v1/expert-agents/list`(按 `is_draft`)
|
||||
- 我的专家场景还会额外 `GET /api/v1/expert-agents` 做合并去重。
|
||||
- 状态切换:
|
||||
- `PATCH /api/v1/expert-agents/{id}`(更新 `is_draft`)
|
||||
|
||||
### 数据流
|
||||
|
||||
- `activeTab` 改变 -> `fetchExpertList` 触发 -> 更新 `agentList`。
|
||||
- `agentList` -> `cards(useMemo)` -> 渲染卡片。
|
||||
- 卡片按钮:
|
||||
- 编辑 -> `navigate('/qa-center/agent-expert-edit?id=...')`
|
||||
- 对话 -> `navigate('/qa-center/agent-chat?id=...')`
|
||||
|
||||
### 维护注意
|
||||
|
||||
- “创建专家”按钮目前是 UI 壳子,未接创建接口。
|
||||
- “删除”按钮当前仅 `console.log`,未接真实删除逻辑。
|
||||
|
||||
---
|
||||
|
||||
## Q5:`AgentExpertEditPage` 组件是干什么的?
|
||||
|
||||
**A:专家配置编辑工作台,同时内置一个“测试对话区”。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 左侧:和该专家进行流式对话(非真实聊天线程,使用 no_real 线程)。
|
||||
- 右侧:编辑专家基础配置(名称、模型、描述、系统提示词)。
|
||||
- 管理工具绑定、技能绑定(弹窗操作)。
|
||||
|
||||
### 关键状态
|
||||
|
||||
- 对话相关:`messages/inputValue/threadId/isStreaming`
|
||||
- 配置相关:`formValues`
|
||||
- 工具相关:`toolOptions/selectedTools/pendingTools/boundToolIdMap`
|
||||
- 技能相关:`skillOptions/selectedSkills/boundSkillMap/skillKeyword`
|
||||
|
||||
### 主要接口
|
||||
|
||||
- 专家详情:`GET /api/v1/expert-agents/{agentId}`
|
||||
- 保存专家:`PUT /api/v1/expert-agents/{agentId}`
|
||||
- 绑定工具:
|
||||
- 查询:`GET /api/v1/expert-agents/{agentId}/tools`
|
||||
- 添加:`POST /api/v1/expert-agents/{agentId}/tools`
|
||||
- 删除:`DELETE /api/v1/expert-agents/{agentId}/tools/{toolId}`
|
||||
- 绑定技能:
|
||||
- 查询:`GET /api/v1/expert-agents/{agentId}/skills`
|
||||
- 添加:`POST /api/v1/expert-agents/{agentId}/skills`
|
||||
- 删除:`DELETE /api/v1/expert-agents/{agentId}/skills/{skillId}`
|
||||
- 测试对话:
|
||||
- 线程:`GET /api/v1/expert-agent-chat/threads/no_real/{agentId}`
|
||||
- 用户消息:`POST /api/v1/expert-agent-chat/messages`
|
||||
- SSE:`POST /api/v1/expert-agent-chat/run/stream/help_expert`
|
||||
|
||||
### 数据流(对话)
|
||||
|
||||
- 用户发送 -> append user msg -> 保证 threadId -> 保存用户消息 -> append assistant 占位 -> SSE 增量更新。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- 右侧 `preview` 区底部 `ChatInputComposer` 是只读展示,不参与真实请求。
|
||||
- 工具与技能弹窗是“前端状态 + 后端同步”双层状态,调试时优先检查 refresh 查询是否成功。
|
||||
|
||||
---
|
||||
|
||||
## Q6:`AgentChatPage` 组件是干什么的?
|
||||
|
||||
**A:专家“真实聊天页”,并带线程管理、文件上传和文件预览。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 根据 URL `id` 读取专家详情。
|
||||
- 管理当前线程(localStorage 持久化)。
|
||||
- 获取历史消息并转换为统一消息结构。
|
||||
- 流式发送消息并渲染思考过程。
|
||||
- 管理会话文件(上传、树形结构、预览、侧栏缩放)。
|
||||
|
||||
### 关键状态
|
||||
|
||||
- 会话:`currentThreadId/messages/inputValue/isStreaming/isLoading`
|
||||
- UI:`isThreadSidebarOpen/autoScrollEnabled/showScrollButton`
|
||||
- 文件:`fileList/fileListThreadId/isFileSidebarOpen/expandedPaths/selectedFile/filePreview*`
|
||||
|
||||
### 主要接口
|
||||
|
||||
- 专家详情:`GET /api/v1/expert-agents/{agentId}`
|
||||
- 会话消息:
|
||||
- 历史:`GET /api/v1/expert-agent-chat/threads/{id}/messages`
|
||||
- 保存用户:`POST /api/v1/expert-agent-chat/messages`
|
||||
- SSE:`POST /api/v1/expert-agent-chat/run/stream`
|
||||
- 保存助手:`POST /api/v1/expert-agent-chat/messages`
|
||||
- 文件:
|
||||
- 列表:`GET /api/v1/expert-agent-chat/threads/{id}/files/all`
|
||||
- 上传:`POST /api/v1/expert-agent-chat/threads/{id}/files/upload`
|
||||
- 预览:`GET /api/v1/expert-agent-chat/threads/{id}/files/preview?path=...`
|
||||
|
||||
### 关键行为细节
|
||||
|
||||
- **发送即出现 assistant 占位**:点击发送后立即插入 assistant 空消息,保证用户立刻看到“连接中”态。
|
||||
- **连接中显示规则**:由 `AgentConversationList` 判断“最后一条 assistant 且还没有任何生成内容”才显示连接状态。
|
||||
- **失败回滚**:发送或流式失败时移除该 assistant 占位,避免残留空卡片。
|
||||
- **停止流式**:`AbortController` 中断请求,并清理 loading 态字段。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- `threadStorageKey` 与 `agentId` 强绑定,切换专家会切换独立 thread 记忆。
|
||||
- `normalizeHistoryContent` 是历史消息兼容核心,后端 content 结构变更时先改这里。
|
||||
|
||||
---
|
||||
|
||||
## Q7:`SkillCreatorPage` 组件是干什么的?
|
||||
|
||||
**A:技能创建/重构专用对话页,结构上是 `AgentChatPage` 的“技能场景定制版”。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 从 query/localStorage 同步当前线程 ID。
|
||||
- 加载技能会话历史与会话文件。
|
||||
- 走技能专属流式接口输出思考步骤/推理/tool call/文本。
|
||||
- 提供“保存技能”弹窗(当前主要是 UI + 本地状态)。
|
||||
|
||||
### 与 `AgentChatPage` 的主要差异
|
||||
|
||||
- 线程 ID 同步到 URL(`useSearchParams`)。
|
||||
- 有独立 `isConnecting` 状态,用于连接中阶段展示。
|
||||
- 流式接口是:`POST /api/v1/expert-agent-chat/skills-agent`
|
||||
- 页面内注入了 dot/shimmer 动画样式(供消息连接中态使用)。
|
||||
|
||||
### 主要接口
|
||||
|
||||
- 会话消息:`GET /threads/{id}/messages`
|
||||
- 流式:`POST /skills-agent`
|
||||
- 保存 assistant:`POST /messages`
|
||||
- 文件:`GET all` / `POST upload` / `GET preview`
|
||||
|
||||
### 数据流
|
||||
|
||||
- 发消息 -> append 用户 + assistant 占位 -> `isConnecting=true` -> 收到首个流块后 `isConnecting=false` -> 增量刷新 assistant -> 持久化。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- 保存技能按钮目前仅 `console.log` 输出技能名和描述,未接正式后端保存接口。
|
||||
|
||||
---
|
||||
|
||||
## Q8:`SkillsWorkshop` 组件是干什么的?
|
||||
|
||||
**A:技能资产管理页(发现、筛选、接入、发布、删除、进入编辑)。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 拉取 `official` 与 `mine` 技能,映射成统一 UI 结构。
|
||||
- 支持搜索、类型筛选、分类筛选。
|
||||
- 支持官方技能接入到“我的技能”。
|
||||
- 支持发布、删除、编辑(编辑弹窗支持 AI 编辑和上传覆盖)。
|
||||
- 支持“新建技能体”:Agent 对话创建 / 上传文件创建。
|
||||
|
||||
### 主要接口
|
||||
|
||||
- 列表:`GET /api/v1/skills?type=official|mine`
|
||||
- 发布:`POST /api/v1/expert-agents/skills/publish`
|
||||
- 删除我的技能:`DELETE /api/v1/skills/mine/{skillId}`
|
||||
- 上传覆盖:`PUT /api/v1/skills/{skillId}/upload`
|
||||
- 接入官方技能:`POST /api/v1/skills/mine/add`
|
||||
- 上传创建:`POST /api/v1/skills/upload`
|
||||
- Agent 创建线程:`POST /api/v1/expert-agent-chat/threads`
|
||||
- AI 编辑复制技能:`POST /threads/{threadId}/copy-skill/{skillId}`
|
||||
|
||||
### 数据流
|
||||
|
||||
- filter/search 改变 -> `useEffect` 重新拉取技能 -> 本地去重并缓存到 `skills`。
|
||||
- 卡片编辑 -> `editingSkill` 打开弹窗 -> 选择 AI 编辑时创建线程后跳 `SkillCreatorPage`。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- `inferCategory` 是关键词规则映射,新增技能类型时可优先扩展该规则。
|
||||
- “进入”按钮目前使用 `skill.id` 跳 `skill-creator`,若后端语义改成 threadId 需调整。
|
||||
|
||||
---
|
||||
|
||||
## Q9:`SkillCreatorThreadSidebar` 组件是干什么的?
|
||||
|
||||
**A:会话线程管理的复用侧边栏。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 打开时加载会话列表。
|
||||
- 新建会话并自动选中。
|
||||
- 删除会话并在当前会话被删时自动回退到其它会话。
|
||||
- 支持线程过滤(如仅展示当前专家线程)。
|
||||
|
||||
### 输入输出(Props 视角)
|
||||
|
||||
- 输入:`authedFetch/currentThreadId/isOpen/isBusy/threadFilter/createThreadPayload...`
|
||||
- 输出:
|
||||
- `onThreadSelect(threadId)`
|
||||
- `onClose()`
|
||||
|
||||
### 数据流
|
||||
|
||||
- `isOpen` 从 false -> true:触发 `refreshThreads`。
|
||||
- 新建成功:更新本地列表并触发 `onThreadSelect`。
|
||||
- 删除成功:本地移除;若删的是当前线程,自动选择 fallback。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- `createThreadPayload` 是高复用扩展点,场景页可注入 `expert_agent_id/title` 等字段。
|
||||
|
||||
---
|
||||
|
||||
## Q10:`AgentConversationList` 组件是干什么的?
|
||||
|
||||
**A:统一遍历消息并决定“连接中状态”是否显示。**
|
||||
|
||||
### 核心逻辑
|
||||
|
||||
- 逐条渲染 `SkillCreatorConversationItem`。
|
||||
- 只在以下条件满足时给最后一条 assistant 传 `isConnecting=true`:
|
||||
- 页面处于连接/流式阶段;
|
||||
- 当前消息是最后一条 assistant;
|
||||
- 且还没有任何实际生成内容(`content/steps/thinkings/toolCalls` 都为空)。
|
||||
|
||||
### 价值
|
||||
|
||||
- 将“连接中占位显示时机”从页面组件抽离,统一控制,减少重复判断代码。
|
||||
|
||||
---
|
||||
|
||||
## Q11:`SkillCreatorConversationItem` 组件是干什么的?
|
||||
|
||||
**A:单条消息卡片渲染器(用户消息 + 助手多面板消息)。**
|
||||
|
||||
### 渲染结构
|
||||
|
||||
- 用户消息:右侧气泡 + user avatar。
|
||||
- 助手消息:左侧 avatar + 多段内容:
|
||||
- `StepsSummary`
|
||||
- `ThinkingPanel[]`
|
||||
- `ToolCallPanel[]`
|
||||
- 连接中动画文案(`正在连接技能服务...`)
|
||||
- Markdown 正文
|
||||
|
||||
### 数据来源
|
||||
|
||||
- 完全由父级传入 `message` 和 `isConnecting` 控制,不直接请求后端。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- 此组件是所有对话页的统一呈现层;若改动 message schema,要同步更新这里的各面板。
|
||||
|
||||
---
|
||||
|
||||
## Q12:`ChatInputComposer` 组件是干什么的?
|
||||
|
||||
**A:通用输入组件,封装输入、发送/停止、上传文件。**
|
||||
|
||||
### 输入输出(Props)
|
||||
|
||||
- 输入:`value/disabled/readOnly/isSending/isDarkMode/showUpload/placeholder...`
|
||||
- 回调:
|
||||
- `onChange(value)`
|
||||
- `onSend()`
|
||||
- `onStop()`
|
||||
- `onUploadFile(file)`
|
||||
|
||||
### 关键交互
|
||||
|
||||
- Enter(非 Shift)触发发送(前提:`submitEnabled` 且非 `isSending`)。
|
||||
- 发送按钮在发送中切换为停止按钮(`onClick={isSending ? onStop : onSend}`)。
|
||||
- 文件上传通过隐藏 `input[type=file]` + 按钮触发。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- 当前实现里文本框只受 `disabled` 控制,不再因为 `isSending` 禁止输入。
|
||||
- 上传按钮仍受 `disabled || !onUploadFile` 控制。
|
||||
|
||||
---
|
||||
|
||||
## Q13:`AgentChatPage.css` 主要做了什么?
|
||||
|
||||
**A:提供 `AgentChatPage` 的辅助样式。**
|
||||
|
||||
- `.agent-resizing`:拖拽文件侧栏时全局 cursor/user-select 控制。
|
||||
- `.agent-file-tree-scroll` / `.agent-file-preview-scroll`:滚动条样式统一。
|
||||
- `.agent-file-markdown-preview`:markdown 首尾边距和图片样式收敛。
|
||||
- `.animate-breathing` 等动画类:预留呼吸动效。
|
||||
|
||||
---
|
||||
|
||||
## Q14:典型业务流程:发送一条消息时,数据如何传输?
|
||||
|
||||
**A:以 `AgentChatPage` 为例。**
|
||||
|
||||
1. 用户在 `ChatInputComposer` 输入后点击发送。
|
||||
2. `handleSendMessage` 校验 `text/threadId/isStreaming`。
|
||||
3. 立即写入:
|
||||
- user message
|
||||
- assistant 占位 message(空内容)
|
||||
4. 调 `POST /messages` 落用户消息。
|
||||
5. 调 `POST /run/stream` 开始 SSE。
|
||||
6. 每个 SSE event 增量更新 assistant message:
|
||||
- step -> `steps`
|
||||
- reasoning -> `thinkings`
|
||||
- tool -> `toolCalls`
|
||||
- text -> `content`
|
||||
7. 流结束后将聚合结果组装成 `persistedContent`,再 `POST /messages` 落库。
|
||||
8. 刷新文件列表(对可能新增输出文件做同步)。
|
||||
|
||||
---
|
||||
|
||||
## Q15:典型业务流程:从技能工坊进入技能创建页,数据如何传输?
|
||||
|
||||
**A:两种入口。**
|
||||
|
||||
### 入口 A:新建技能(Agent 构建)
|
||||
|
||||
1. `SkillsWorkshop.handleCreateSkillByAgent` 创建线程。
|
||||
2. 将 `threadId` 写入 localStorage。
|
||||
3. 跳转 `navigate('/qa-center/skill-creator?id=threadId')`。
|
||||
4. `SkillCreatorPage` 用 query/localStorage 同步 `currentThreadId`,然后拉消息和文件。
|
||||
|
||||
### 入口 B:AI 辅助编辑已有技能
|
||||
|
||||
1. 创建线程。
|
||||
2. `POST /threads/{threadId}/copy-skill/{skillId}` 把技能内容复制到线程工作区。
|
||||
3. 跳转 SkillCreator 页面,后续在线程中对话编辑。
|
||||
|
||||
---
|
||||
|
||||
## Q16:如果要新增一个“对话页”,建议复用哪些组件?
|
||||
|
||||
**A:建议最小复用组合如下。**
|
||||
|
||||
- 消息列表:`AgentConversationList`
|
||||
- 单消息卡片:`SkillCreatorConversationItem`(自动包含 steps/thinking/tool 等面板)
|
||||
- 输入框:`ChatInputComposer`
|
||||
- 线程侧边栏:`SkillCreatorThreadSidebar`
|
||||
|
||||
**页面层只负责:**
|
||||
|
||||
- 状态管理(`messages/input/isStreaming/currentThreadId`)
|
||||
- 接口请求(历史 + SSE + 持久化)
|
||||
- 事件映射(把后端事件转为 `ConversationMessage` 结构)
|
||||
|
||||
---
|
||||
|
||||
## Q17:维护时最容易踩坑的点有哪些?
|
||||
|
||||
**A:建议优先关注这 8 个点。**
|
||||
|
||||
1. **SSE 事件兼容性**:后端 event type 或字段名变化会直接导致 UI 不更新。
|
||||
2. **历史内容兼容**:`normalizeHistoryContent` 没跟上 content schema 会出现历史消息空白。
|
||||
3. **线程切换中断**:切换线程时要记得 abort 流并清状态,避免串流到新线程。
|
||||
4. **占位消息回收**:发送失败/流失败需移除 assistant 占位,避免残留空卡片。
|
||||
5. **threadId 语义一致性**:某些页面参数传 skillId/agentId,需避免误当 threadId 使用。
|
||||
6. **工具/技能绑定状态双源**:本地状态和后端状态不同步时,优先以 refresh 查询结果为准。
|
||||
7. **上传入口 accept 限制**:上传文件类型修改时要同步后端校验和 UI 提示。
|
||||
8. **深色主题样式**:该目录深色/浅色分支很多,改样式需双主题回归。
|
||||
|
||||
---
|
||||
|
||||
## Q18:后续文档扩展建议
|
||||
|
||||
**A:当前文档适合开发入门,下一步可补充“接口契约版”。**
|
||||
|
||||
- 补完整 SSE event JSON 示例(每种事件一例)。
|
||||
- 增加错误码到 UI 提示映射表。
|
||||
- 补一份“页面状态机图”(Idle / Connecting / Streaming / Stopped / Error)。
|
||||
- 给每个页面补“可测点清单”(单测/联调/回归)。
|
||||
|
||||
---
|
||||
|
||||
## Q19:能否给一份可联调的“接口契约附录”?
|
||||
|
||||
**A:可以,以下按模块整理。**
|
||||
|
||||
### 1) 专家模块(Expert)
|
||||
|
||||
- 查询专家列表
|
||||
- `POST /api/v1/expert-agents/list`
|
||||
- 请求体:
|
||||
- `is_draft: boolean`
|
||||
- 响应(数组项):
|
||||
- `id/name/avatar_url/description/model_name/is_draft/user_id/created_at`
|
||||
|
||||
- 查询专家详情
|
||||
- `GET /api/v1/expert-agents/{agentId}`
|
||||
- 响应关键字段:
|
||||
- `id/name/description/system_prompt/model_name`
|
||||
|
||||
- 更新专家配置
|
||||
- `PUT /api/v1/expert-agents/{agentId}`
|
||||
- 请求体关键字段:
|
||||
- `name`
|
||||
- `description`
|
||||
- `system_prompt`
|
||||
- `model_name`
|
||||
- `avatar_url`(当前传 `null`)
|
||||
|
||||
- 切换草稿/发布状态
|
||||
- `PATCH /api/v1/expert-agents/{agentId}`
|
||||
- 请求体:
|
||||
- `is_draft: boolean`
|
||||
|
||||
### 2) 线程与消息模块(Chat Thread + Message)
|
||||
|
||||
- 查询线程列表
|
||||
- `GET /api/v1/expert-agent-chat/threads`
|
||||
- 响应:
|
||||
- `threads: SkillCreatorThread[]`
|
||||
- `archived_threads: SkillCreatorThread[]`
|
||||
|
||||
- 新建线程
|
||||
- `POST /api/v1/expert-agent-chat/threads`
|
||||
- 请求体常见字段:
|
||||
- `title`
|
||||
- `archived`
|
||||
- `is_real_chat`
|
||||
- `expert_agent_id`(专家聊天场景)
|
||||
|
||||
- 删除线程
|
||||
- `DELETE /api/v1/expert-agent-chat/threads/{threadId}`
|
||||
|
||||
- 查询线程消息历史
|
||||
- `GET /api/v1/expert-agent-chat/threads/{threadId}/messages`
|
||||
- 响应消息项关键字段:
|
||||
- `id/thread_id/role/content/created_at`
|
||||
|
||||
- 保存消息(用户或助手)
|
||||
- `POST /api/v1/expert-agent-chat/messages`
|
||||
- 请求体(用户消息):
|
||||
- `thread_id`
|
||||
- `role: "user"`
|
||||
- `content: { type: "text", text: string }`
|
||||
- 请求体(助手消息):
|
||||
- `thread_id`
|
||||
- `role: "assistant"`
|
||||
- `content: persistedContent[]`
|
||||
|
||||
### 3) 流式回复模块(SSE)
|
||||
|
||||
- 专家聊天流式
|
||||
- `POST /api/v1/expert-agent-chat/run/stream`
|
||||
- 请求体关键字段:
|
||||
- `chat_id`
|
||||
- `expert_agent_id`
|
||||
- `user_query`
|
||||
- `messages`
|
||||
|
||||
- 专家编辑页测试流式
|
||||
- `POST /api/v1/expert-agent-chat/run/stream/help_expert`
|
||||
|
||||
- 技能创建流式
|
||||
- `POST /api/v1/expert-agent-chat/skills-agent`
|
||||
- 请求体关键字段:
|
||||
- `chat_id`
|
||||
- `user_query`
|
||||
|
||||
### 4) 文件模块(线程文件)
|
||||
|
||||
- 查询线程文件树
|
||||
- `GET /api/v1/expert-agent-chat/threads/{threadId}/files/all`
|
||||
|
||||
- 上传文件
|
||||
- `POST /api/v1/expert-agent-chat/threads/{threadId}/files/upload`
|
||||
- form-data:
|
||||
- `file`
|
||||
|
||||
- 文件预览
|
||||
- `GET /api/v1/expert-agent-chat/threads/{threadId}/files/preview?path=...`
|
||||
- 兼容响应:
|
||||
- 文本
|
||||
- JSON 字符串
|
||||
- JSON 对象中的 `content`
|
||||
|
||||
### 5) 技能模块(Skills)
|
||||
|
||||
- 查询技能列表
|
||||
- `GET /api/v1/skills?type=official|mine|all&keyword=&page=1&page_size=100`
|
||||
|
||||
- 发布技能
|
||||
- `POST /api/v1/expert-agents/skills/publish`
|
||||
- 请求体:
|
||||
- `skill_id`
|
||||
|
||||
- 添加官方技能到我的技能
|
||||
- `POST /api/v1/skills/mine/add`
|
||||
- 请求体:
|
||||
- `official_skill_id`
|
||||
|
||||
- 删除我的技能
|
||||
- `DELETE /api/v1/skills/mine/{skillId}`
|
||||
|
||||
- 上传技能文件创建
|
||||
- `POST /api/v1/skills/upload`
|
||||
|
||||
- 上传覆盖已有技能
|
||||
- `PUT /api/v1/skills/{skillId}/upload`
|
||||
|
||||
### 6) 专家绑定工具/技能
|
||||
|
||||
- 工具
|
||||
- 查询:`GET /api/v1/expert-agents/{agentId}/tools`
|
||||
- 绑定:`POST /api/v1/expert-agents/{agentId}/tools`(`tool_name`)
|
||||
- 解绑:`DELETE /api/v1/expert-agents/{agentId}/tools/{toolId}`
|
||||
|
||||
- 技能
|
||||
- 查询:`GET /api/v1/expert-agents/{agentId}/skills`
|
||||
- 绑定:`POST /api/v1/expert-agents/{agentId}/skills`(`skill_id`)
|
||||
- 解绑:`DELETE /api/v1/expert-agents/{agentId}/skills/{skillId}`
|
||||
|
||||
---
|
||||
|
||||
## Q20:SSE 事件规范可以补全吗?
|
||||
|
||||
**A:可以,以下是前端当前实现依赖的事件集合。**
|
||||
|
||||
### 1) 事件封包格式
|
||||
|
||||
- 每行以 `data: ` 开头
|
||||
- 正文为 JSON 字符串
|
||||
- 结束标记:`data: [DONE]`
|
||||
|
||||
### 2) 前端识别的事件类型
|
||||
|
||||
- `data-thinking-step`
|
||||
- 用途:更新执行步骤面板
|
||||
- 关键字段:
|
||||
- `data.id`
|
||||
- `data.title`
|
||||
- `data.status`(`completed` 或其它)
|
||||
- `data.items[]`
|
||||
|
||||
- `reasoning-start`
|
||||
- 用途:开始一段思考
|
||||
- 行为:创建新的 thinking 段,记录开始时间
|
||||
|
||||
- `reasoning-delta`
|
||||
- 用途:思考内容增量
|
||||
- 关键字段:
|
||||
- `delta`
|
||||
|
||||
- `reasoning-end`
|
||||
- 用途:结束当前思考段
|
||||
- 行为:计算该段耗时并写入 `duration`
|
||||
|
||||
- `tool-input-available`
|
||||
- 用途:记录工具调用入参
|
||||
- 关键字段:
|
||||
- `toolCallId`
|
||||
- `toolName`
|
||||
- `input`
|
||||
|
||||
- `tool-output-available`
|
||||
- 用途:记录工具调用输出
|
||||
- 关键字段:
|
||||
- `toolCallId`
|
||||
- `output`
|
||||
|
||||
- `text-delta`
|
||||
- 用途:助手正文 token 增量
|
||||
- 关键字段:
|
||||
- `delta`
|
||||
|
||||
### 3) 推荐后端保证
|
||||
|
||||
- 同一次回复里的 `toolCallId` 全局唯一。
|
||||
- `reasoning-start` 与 `reasoning-end` 成对(允许无 `delta`)。
|
||||
- `data-thinking-step` 同一个 `id` 可重复更新状态。
|
||||
- `text-delta` 在结束前允许为空片段,但尽量减少空片段。
|
||||
|
||||
### 4) 前端容错策略(已实现)
|
||||
|
||||
- JSON parse 失败:跳过该行,不中断整流。
|
||||
- 未识别 event.type:忽略。
|
||||
- `output` 非字符串:尝试 `result` 字段,否则 `JSON.stringify`。
|
||||
- `AbortError`:按“用户主动停止”处理,不弹致命错误。
|
||||
|
||||
---
|
||||
|
||||
## Q21:可以给一份页面级测试清单吗?
|
||||
|
||||
**A:可以,以下清单按页面拆分,适合联调和回归。**
|
||||
|
||||
### A. `AgentChatPage` 测试清单
|
||||
|
||||
- 打开页面后,能加载专家信息(名称、描述)。
|
||||
- 未选线程时,空态正确显示;选中线程后历史消息正确渲染。
|
||||
- 点击发送后立即出现 assistant 占位卡片,显示连接中状态。
|
||||
- 首个有效流式事件到达后,连接中占位消失,转为正常内容增量渲染。
|
||||
- 发送失败/流失败时,assistant 占位会被回收,不留空白卡片。
|
||||
- 点击停止后,流式中断,按钮状态恢复,后续不再追加内容。
|
||||
- 上传文件后文件树刷新;文件预览支持 text/json 两类返回。
|
||||
- 切换线程时会中断当前流,且不会把旧线程流内容写到新线程。
|
||||
|
||||
### B. `SkillCreatorPage` 测试清单
|
||||
|
||||
- URL `id` 与 localStorage 线程 ID 同步正确。
|
||||
- 新线程空态和快捷提示按钮显示正常。
|
||||
- 发送后 `isConnecting=true`,收到首包后 `isConnecting=false`。
|
||||
- steps/thinking/tool/text 四类流式内容都能正确出现。
|
||||
- 线程切换、停止流、文件上传/预览路径行为与 `AgentChatPage` 一致。
|
||||
- 保存技能弹窗开关正常(当前为 UI 行为)。
|
||||
|
||||
### C. `AgentExpertEditPage` 测试清单
|
||||
|
||||
- 进入页面后基础信息(name/model/description/system_prompt)能回填。
|
||||
- 保存专家配置后接口成功,按钮 loading 状态恢复。
|
||||
- 工具弹窗:勾选新增 -> 确认添加 -> 列表刷新一致。
|
||||
- 技能弹窗:绑定/解绑/一键绑定后状态与后端一致。
|
||||
- 左侧测试对话能正常流式返回,滚动与发送状态正确。
|
||||
|
||||
### D. `SkillsWorkshop` 测试清单
|
||||
|
||||
- `all`/`mine` 切换结果正确,搜索词可生效。
|
||||
- 分类筛选(writing/analysis/decision/validation/workflow)正确过滤。
|
||||
- 官方技能“+”接入后卡片状态转“已接入”。
|
||||
- 我的技能菜单中的发布/删除操作正确执行。
|
||||
- 编辑弹窗中 AI 编辑路径可成功创建线程并跳转 SkillCreator。
|
||||
- 上传创建、上传覆盖两条路径成功后状态正确恢复(含 input 清空)。
|
||||
|
||||
### E. `AgentExpertCenterPage` 测试清单
|
||||
|
||||
- mine/published 标签切换后列表正确变化。
|
||||
- mine 模式会按当前用户过滤,且与全量接口合并去重后正确展示。
|
||||
- 详情弹窗中的发布开关可切换并刷新列表。
|
||||
- 编辑、对话跳转路径参数正确。
|
||||
|
||||
### F. 主题与可用性回归
|
||||
|
||||
- 深色/浅色主题下对话气泡、按钮、边框、hover 都可读。
|
||||
- 长会话场景滚动性能可接受,自动滚动按钮行为符合预期。
|
||||
- 键盘发送(Enter)与换行(Shift+Enter)行为正确。
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Bot, Edit, Eye, Plus, Search, Sparkles, Trash2, X } from 'lucide-react';
|
||||
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
interface AgentItem {
|
||||
key: string;
|
||||
type: 'builtin' | 'custom' | string;
|
||||
deletable: boolean;
|
||||
name: string;
|
||||
description: string;
|
||||
default_prompt: string;
|
||||
schema_hint: string;
|
||||
bound_skills: string[];
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface SkillItem {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface AgentCardListProps {
|
||||
onEdit?: (key: string) => void;
|
||||
}
|
||||
|
||||
type TypeFilter = 'all' | 'builtin' | 'custom';
|
||||
|
||||
interface CreateAgentForm {
|
||||
name: string;
|
||||
description: string;
|
||||
bound_skills: string[];
|
||||
}
|
||||
|
||||
const INITIAL_FORM: CreateAgentForm = {
|
||||
name: '',
|
||||
description: '',
|
||||
bound_skills: [],
|
||||
};
|
||||
|
||||
const backUrl = 'https://ch1.b.uat.4.cn/zncmback';
|
||||
export default function AgentCardList({ onEdit }: AgentCardListProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const [agents, setAgents] = useState<AgentItem[]>([]);
|
||||
const [skills, setSkills] = useState<SkillItem[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createForm, setCreateForm] = useState<CreateAgentForm>(INITIAL_FORM);
|
||||
const [skillKeyword, setSkillKeyword] = useState('');
|
||||
|
||||
const showNotice = (message: string) => {
|
||||
setNotice(message);
|
||||
window.setTimeout(() => {
|
||||
setNotice((current) => (current === message ? null : current));
|
||||
}, 2400);
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [agentRes, skillRes] = await Promise.all([fetch(`${backUrl}/api/agents`), fetch(`${backUrl}/api/skills`)]);
|
||||
if (!agentRes.ok) {
|
||||
throw new Error(`加载 MCP 失败:${agentRes.status}`);
|
||||
}
|
||||
|
||||
const agentData = (await agentRes.json()) as AgentItem[];
|
||||
const skillData = skillRes.ok ? ((await skillRes.json()) as SkillItem[]) : [];
|
||||
|
||||
setAgents(agentData.sort((a, b) => a.order - b.order));
|
||||
setSkills(skillData);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, []);
|
||||
|
||||
const filteredAgents = useMemo(() => {
|
||||
const kw = keyword.trim().toLowerCase();
|
||||
return agents.filter((agent) => {
|
||||
const matchesKeyword =
|
||||
!kw ||
|
||||
agent.name.toLowerCase().includes(kw) ||
|
||||
agent.key.toLowerCase().includes(kw) ||
|
||||
agent.description.toLowerCase().includes(kw);
|
||||
|
||||
const matchesType = typeFilter === 'all' || agent.type === typeFilter;
|
||||
return matchesKeyword && matchesType;
|
||||
});
|
||||
}, [agents, keyword, typeFilter]);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
const kw = skillKeyword.trim().toLowerCase();
|
||||
if (!kw) {
|
||||
return skills;
|
||||
}
|
||||
return skills.filter((skill) =>
|
||||
`${skill.name} ${skill.key} ${skill.description || ''}`.toLowerCase().includes(kw),
|
||||
);
|
||||
}, [skillKeyword, skills]);
|
||||
|
||||
const resetCreateForm = () => {
|
||||
setCreateForm(INITIAL_FORM);
|
||||
setSkillKeyword('');
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
resetCreateForm();
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
setCreateOpen(false);
|
||||
resetCreateForm();
|
||||
};
|
||||
|
||||
const toggleSkill = (skillKey: string) => {
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
bound_skills: prev.bound_skills.includes(skillKey)
|
||||
? prev.bound_skills.filter((item) => item !== skillKey)
|
||||
: [...prev.bound_skills, skillKey],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = createForm.name.trim();
|
||||
const description = createForm.description.trim();
|
||||
if (!name) {
|
||||
setError('请先输入 MCP 名称');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${backUrl}/api/agents`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
bound_skills: createForm.bound_skills,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = `新增失败:${res.status}`;
|
||||
try {
|
||||
const body = (await res.json()) as { detail?: string };
|
||||
if (body.detail) {
|
||||
detail = body.detail;
|
||||
}
|
||||
} catch {
|
||||
// ignore json parse errors
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
await res.json();
|
||||
await loadData();
|
||||
closeCreateModal();
|
||||
showNotice('MCP 已新增');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '新增失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (agent: AgentItem) => {
|
||||
if (!agent.deletable) return;
|
||||
const ok = window.confirm(`确认删除 MCP “${agent.name}”吗?`);
|
||||
if (!ok) return;
|
||||
|
||||
const res = await fetch(`${backUrl}/api/agents/${agent.key}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
setError(`删除失败:${res.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await loadData();
|
||||
showNotice('MCP 已删除');
|
||||
};
|
||||
|
||||
const selectedSkillCount = createForm.bound_skills.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className={`mb-2 text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
MCP 智能体配置
|
||||
</h1>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
管理内置与用户新增的 MCP,并支持绑定技能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateModal}
|
||||
className={`inline-flex h-10 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
|
||||
isDark ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-red-600 text-white hover:bg-red-500'
|
||||
}`}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新增 MCP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="relative max-w-xl flex-1">
|
||||
<Search
|
||||
className={`absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 ${
|
||||
isDark ? 'text-slate-500' : 'text-red-400'
|
||||
}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索 MCP 名称、Key 或描述"
|
||||
className={`w-full rounded-xl border py-2.5 pl-10 pr-4 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-red-500/20 ${
|
||||
isDark
|
||||
? 'border-blue-800/30 bg-slate-800/50 text-white placeholder-slate-500'
|
||||
: 'border-red-200 bg-white text-gray-900 placeholder-red-300'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'builtin', label: '内置 MCP' },
|
||||
{ key: 'custom', label: '用户新增' },
|
||||
].map((item) => {
|
||||
const active = typeFilter === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setTypeFilter(item.key as TypeFilter)}
|
||||
className={`rounded-full px-3 py-1.5 text-sm transition ${
|
||||
active
|
||||
? isDark
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-red-600 text-white'
|
||||
: isDark
|
||||
? 'bg-slate-800 text-slate-300 hover:bg-slate-700'
|
||||
: 'bg-white text-gray-600 ring-1 ring-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <div className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>加载中...</div> : null}
|
||||
{error ? <div className="text-sm text-red-500">{error}</div> : null}
|
||||
{notice ? <div className="text-sm text-emerald-500">{notice}</div> : null}
|
||||
|
||||
{!loading && !error ? (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{filteredAgents.map((agent) => (
|
||||
<div
|
||||
key={agent.key}
|
||||
className={`group rounded-xl border p-5 transition-all duration-300 ${
|
||||
isDark
|
||||
? 'border-blue-800/30 bg-slate-800/30 backdrop-blur-xl hover:border-blue-500/50'
|
||||
: 'border-red-100 bg-white hover:border-red-300 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-xl ${
|
||||
isDark ? 'bg-blue-500/20' : 'bg-red-100'
|
||||
}`}
|
||||
>
|
||||
<Bot className={`h-6 w-6 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{agent.name}
|
||||
</h3>
|
||||
<p className={`text-xs font-mono ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
{agent.key}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||
agent.type === 'builtin'
|
||||
? isDark
|
||||
? 'bg-cyan-500/15 text-cyan-200'
|
||||
: 'bg-cyan-50 text-cyan-700'
|
||||
: isDark
|
||||
? 'bg-amber-500/15 text-amber-200'
|
||||
: 'bg-amber-50 text-amber-700'
|
||||
}`}
|
||||
>
|
||||
{agent.type === 'builtin' ? '内置' : '自定义'}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||
isDark ? 'bg-slate-700/60 text-slate-300' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
#{agent.order + 1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={`mb-4 min-h-[40px] text-sm ${isDark ? 'text-slate-400' : 'text-gray-600'}`}>
|
||||
{agent.description || '暂无描述'}
|
||||
</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className={`mb-2 flex items-center gap-1 text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
已绑定技能
|
||||
</div>
|
||||
<div className="flex min-h-[28px] flex-wrap gap-1.5">
|
||||
{agent.bound_skills.length === 0 ? (
|
||||
<span className={`text-xs ${isDark ? 'text-slate-600' : 'text-gray-400'}`}>未绑定</span>
|
||||
) : (
|
||||
agent.bound_skills.map((skillKey) => {
|
||||
const skill = skills.find((item) => item.key === skillKey);
|
||||
return (
|
||||
<span
|
||||
key={skillKey}
|
||||
className={`rounded-full border px-2 py-0.5 text-xs ${
|
||||
isDark
|
||||
? 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300'
|
||||
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
}`}
|
||||
>
|
||||
{skill?.name || skillKey}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex items-center justify-end gap-2 border-t pt-3 ${
|
||||
isDark ? 'border-blue-800/30' : 'border-red-100'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit?.(agent.key)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors ${
|
||||
isDark
|
||||
? 'bg-slate-700/60 text-slate-200 hover:bg-slate-700'
|
||||
: 'border border-red-200 bg-white text-red-600 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
详情
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit?.(agent.key)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors ${
|
||||
isDark ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-red-600 text-white hover:bg-red-500'
|
||||
}`}
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
编辑
|
||||
</button>
|
||||
{agent.deletable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDelete(agent)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors ${
|
||||
isDark
|
||||
? 'bg-rose-500/15 text-rose-200 hover:bg-rose-500/25'
|
||||
: 'bg-rose-50 text-rose-600 hover:bg-rose-100'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
删除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !error && filteredAgents.length === 0 ? (
|
||||
<div className={`py-12 text-center text-sm ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
没有匹配的 MCP
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{createOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeCreateModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`max-h-[85vh] w-full max-w-2xl overflow-hidden rounded-3xl border shadow-2xl ${
|
||||
isDark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-red-100 bg-white text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b px-6 py-5 dark:border-slate-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">新增 MCP</h2>
|
||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
输入 MCP 名称、详情并选择要绑定的技能
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeCreateModal}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl ${
|
||||
isDark ? 'hover:bg-slate-800' : 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 overflow-y-auto px-6 py-5">
|
||||
<div>
|
||||
<label className={`mb-2 block text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
|
||||
MCP 名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="例如:游戏分析助手"
|
||||
className={`w-full rounded-xl border px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-red-500/20 ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950 text-white placeholder:text-slate-500'
|
||||
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={`mb-2 block text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
|
||||
详情
|
||||
</label>
|
||||
<textarea
|
||||
value={createForm.description}
|
||||
onChange={(e) => setCreateForm((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="一句话说明这个 MCP 的职责"
|
||||
rows={4}
|
||||
className={`w-full rounded-xl border px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-red-500/20 ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950 text-white placeholder:text-slate-500'
|
||||
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<label className={`block text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
|
||||
绑定技能
|
||||
</label>
|
||||
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
已选 {selectedSkillCount} 项
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={skillKeyword}
|
||||
onChange={(e) => setSkillKeyword(e.target.value)}
|
||||
placeholder="搜索技能名称或描述"
|
||||
className={`w-full rounded-xl border px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-red-500/20 ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950 text-white placeholder:text-slate-500'
|
||||
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`max-h-72 space-y-2 overflow-y-auto rounded-2xl border p-3 ${
|
||||
isDark ? 'border-slate-700 bg-slate-950/60' : 'border-gray-200 bg-gray-50/70'
|
||||
}`}
|
||||
>
|
||||
{filteredSkills.length === 0 ? (
|
||||
<div className={`py-8 text-center text-sm ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
没有匹配的技能
|
||||
</div>
|
||||
) : (
|
||||
filteredSkills.map((skill) => {
|
||||
const checked = createForm.bound_skills.includes(skill.key);
|
||||
return (
|
||||
<button
|
||||
key={skill.key}
|
||||
type="button"
|
||||
onClick={() => toggleSkill(skill.key)}
|
||||
className={`flex w-full items-start gap-3 rounded-xl border px-3 py-3 text-left transition ${
|
||||
checked
|
||||
? isDark
|
||||
? 'border-emerald-500/50 bg-emerald-500/10'
|
||||
: 'border-emerald-200 bg-emerald-50'
|
||||
: isDark
|
||||
? 'border-slate-700 bg-slate-900/50 hover:bg-slate-800/70'
|
||||
: 'border-white bg-white hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mt-1 h-4 w-4 shrink-0 rounded border ${
|
||||
checked
|
||||
? 'border-emerald-500 bg-emerald-500'
|
||||
: isDark
|
||||
? 'border-slate-600'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
{skill.name}
|
||||
<span className={`ml-2 font-mono text-[11px] ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
{skill.key}
|
||||
</span>
|
||||
</div>
|
||||
<p className={`mt-1 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{skill.description || '暂无描述'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex items-center justify-end gap-3 border-t px-6 py-4 ${
|
||||
isDark ? 'border-slate-700' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeCreateModal}
|
||||
className={`rounded-xl px-4 py-2 text-sm ${
|
||||
isDark ? 'text-slate-300 hover:bg-slate-800' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={creating}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-medium text-white ${
|
||||
isDark ? 'bg-blue-600 hover:bg-blue-500' : 'bg-red-600 hover:bg-red-500'
|
||||
} disabled:cursor-not-allowed disabled:opacity-60`}
|
||||
>
|
||||
{creating ? '创建中...' : '确认新增'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Bot, ChevronDown, CheckCircle2, Loader2, Sparkles, UserRound } from 'lucide-react';
|
||||
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import MarkdownRenderer from '../MarkdownRenderer';
|
||||
import { parseAgentResponse } from '../agent/agentResponseParser';
|
||||
|
||||
export interface AgentChatMsg {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface AvatarProps {
|
||||
role: 'user' | 'assistant';
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
const AgentAvatar: React.FC<AvatarProps> = ({ role, isActive = false }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const Icon = role === 'assistant' ? Bot : UserRound;
|
||||
|
||||
const gradientClass =
|
||||
role === 'assistant'
|
||||
? isDark
|
||||
? 'bg-gradient-to-br from-cyan-500 via-blue-600 to-violet-600 text-white'
|
||||
: 'bg-gradient-to-br from-red-400 via-rose-500 to-orange-400 text-white'
|
||||
: isDark
|
||||
? 'bg-gradient-to-br from-slate-500 via-slate-700 to-slate-900 text-white'
|
||||
: 'bg-gradient-to-br from-sky-400 via-indigo-500 to-violet-500 text-white';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-2xl shadow-sm ${gradientClass} ${isActive ? 'qa-avatar-active' : ''}`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/20 opacity-0 qa-avatar-shine" />
|
||||
<Icon className={`relative z-10 h-5 w-5 ${isActive ? 'qa-avatar-icon-active' : ''}`} />
|
||||
{role === 'assistant' && isActive && (
|
||||
<Sparkles className="qa-avatar-spark absolute -right-0.5 -top-0.5 h-3.5 w-3.5 text-yellow-200" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CollapsibleProps {
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Collapsible: React.FC<CollapsibleProps> = ({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden rounded-2xl border ${isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'} ${className}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors ${
|
||||
isDark ? 'hover:bg-slate-800/70' : 'hover:bg-red-50/60'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">{title}</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 shrink-0 transition-transform duration-300 ease-out ${open ? 'rotate-0' : '-rotate-90'} ${
|
||||
isDark ? 'text-slate-400' : 'text-gray-500'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
className={`px-4 pb-4 transition-transform duration-300 ease-out ${open ? 'translate-y-0' : '-translate-y-2'} ${
|
||||
isDark ? 'text-slate-200' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AssistantAnswerProps {
|
||||
steps: { title: string; content: string }[];
|
||||
thinkingMarkdown: string;
|
||||
answerMarkdown: string;
|
||||
thinkingComplete: boolean;
|
||||
streaming: boolean;
|
||||
}
|
||||
|
||||
const AssistantAnswer: React.FC<AssistantAnswerProps> = ({
|
||||
steps,
|
||||
thinkingMarkdown,
|
||||
answerMarkdown,
|
||||
thinkingComplete,
|
||||
streaming,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const showThinkingSection = Boolean(thinkingMarkdown) || (!thinkingComplete && streaming);
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0 max-w-5xl space-y-3 overflow-hidden">
|
||||
{steps.length > 0 && (
|
||||
<Collapsible
|
||||
defaultOpen
|
||||
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-2xl ${
|
||||
isDark ? 'bg-emerald-500/15 text-emerald-300' : 'bg-emerald-50 text-emerald-600'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
步骤
|
||||
</div>
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
展示技能执行、上下文准备和模型调用进度
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={`${step.title}-${index}`}
|
||||
className={`rounded-xl border px-3 py-3 ${
|
||||
isDark ? 'border-slate-700 bg-slate-900/40' : 'border-gray-100 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-sm font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
{step.title}
|
||||
</div>
|
||||
<div className={`mt-1 whitespace-pre-wrap text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
|
||||
{step.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{showThinkingSection && (
|
||||
<Collapsible
|
||||
defaultOpen
|
||||
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-2xl ${
|
||||
isDark ? 'bg-violet-500/15 text-violet-300' : 'bg-orange-50 text-orange-500'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
思考过程
|
||||
</div>
|
||||
{thinkingComplete ? (
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
思考已完成,可展开查看
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`qa-generating-marquee text-xs ${
|
||||
isDark ? 'qa-generating-marquee-dark text-slate-300' : 'qa-generating-marquee-light text-gray-600'
|
||||
}`}
|
||||
>
|
||||
正在生成思考内容...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{thinkingMarkdown ? (
|
||||
<MarkdownRenderer
|
||||
content={thinkingMarkdown}
|
||||
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
|
||||
/>
|
||||
) : (
|
||||
<div className={`flex items-center gap-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
等待模型输出思考内容...
|
||||
</div>
|
||||
)}
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{answerMarkdown ? (
|
||||
<div
|
||||
className={`rounded-2xl border p-4 ${
|
||||
isDark ? 'border-slate-600/40 bg-slate-700/60' : 'border-gray-100 bg-white'
|
||||
}`}
|
||||
>
|
||||
<MarkdownRenderer
|
||||
content={answerMarkdown}
|
||||
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface MessageRowProps {
|
||||
msg: AgentChatMsg;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
const MessageRow: React.FC<MessageRowProps> = ({ msg, streaming = false }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const isUser = msg.role === 'user';
|
||||
|
||||
const parsed = useMemo(() => {
|
||||
if (isUser) return null;
|
||||
return parseAgentResponse(msg.content);
|
||||
}, [msg.content, isUser]);
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex items-end justify-end gap-3">
|
||||
<div
|
||||
className={`max-w-[72%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm ${
|
||||
isDark
|
||||
? 'bg-blue-600/70 text-white'
|
||||
: 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap leading-relaxed">{msg.content}</p>
|
||||
</div>
|
||||
<AgentAvatar role="user" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<AgentAvatar role="assistant" isActive={streaming} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{parsed ? (
|
||||
<AssistantAnswer
|
||||
steps={parsed.steps}
|
||||
thinkingMarkdown={parsed.thinkingMarkdown}
|
||||
answerMarkdown={parsed.answerMarkdown}
|
||||
thinkingComplete={parsed.thinkingComplete && !streaming}
|
||||
streaming={streaming}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`rounded-2xl border p-4 text-sm ${
|
||||
isDark
|
||||
? 'border-slate-600/40 bg-slate-700/60 text-slate-200'
|
||||
: 'border-gray-100 bg-white text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AgentChatMessageListProps {
|
||||
messages: AgentChatMsg[];
|
||||
streamingMsgId?: string | null;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
const AgentChatMessageList: React.FC<AgentChatMessageListProps> = ({
|
||||
messages,
|
||||
streamingMsgId,
|
||||
agentName,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full flex-col items-center justify-center text-center ${
|
||||
isDark ? 'text-slate-500' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<Bot className="mb-3 h-10 w-10" />
|
||||
<p className="text-sm">直接与 {agentName || 'MCP 智能体'} 对话以测试当前配置</p>
|
||||
<p className="mt-1 text-xs">已绑定技能会在步骤区显示实际执行过程</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{messages.map((msg, idx) => {
|
||||
const isLast = idx === messages.length - 1;
|
||||
const isStreaming = isLast && msg.role === 'assistant' && msg.id === streamingMsgId;
|
||||
return <MessageRow key={msg.id} msg={msg} streaming={isStreaming} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentChatMessageList;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,248 @@
|
||||
import React from 'react';
|
||||
import { Check, Plus, RefreshCcw, Sparkles, UserSearch } from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { InlineCollapsible, StageHeader, ThinkingBlock } from './SixStepCards';
|
||||
import type { ExpertCard, ExpertMatchData } from './types';
|
||||
|
||||
interface ExpertCardItemProps {
|
||||
card: ExpertCard;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
const renderAvatar = (avatar: string | undefined, fallback: React.ReactNode, isDark: boolean) => {
|
||||
if (avatar && avatar.trim().startsWith('<svg')) {
|
||||
return (
|
||||
<span
|
||||
className={`flex h-10 w-10 items-center justify-center overflow-hidden rounded-2xl ${
|
||||
isDark ? 'bg-slate-700/40' : 'bg-white shadow-sm'
|
||||
}`}
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: avatar }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${
|
||||
isDark ? 'bg-cyan-500/20 text-cyan-200' : 'bg-red-50 text-red-500'
|
||||
}`}
|
||||
>
|
||||
{fallback}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const ExpertCardItem: React.FC<ExpertCardItemProps> = ({ card, index, selected, disabled, onSelect }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const baseBorder = selected
|
||||
? isDark
|
||||
? 'border-cyan-300/60 ring-1 ring-cyan-300/40 shadow-lg shadow-cyan-500/10 bg-gradient-to-br from-slate-800/70 to-cyan-900/30'
|
||||
: 'border-red-300 ring-1 ring-red-200 shadow-md shadow-red-100 bg-gradient-to-br from-white to-red-50/60'
|
||||
: isDark
|
||||
? 'border-slate-700/60 bg-gradient-to-br from-slate-800/40 to-slate-900/20 hover:border-cyan-300/40'
|
||||
: 'border-gray-100 bg-white hover:border-red-200';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(card.id)}
|
||||
className={`w-full text-left rounded-2xl border p-3 transition-all sixstep-fade-up disabled:cursor-not-allowed disabled:opacity-60 ${baseBorder}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={`w-7 h-7 rounded-xl border flex items-center justify-center text-xs font-semibold shrink-0 ${
|
||||
isDark
|
||||
? 'bg-cyan-500/20 text-cyan-200 border-cyan-400/30'
|
||||
: 'bg-red-50 text-red-600 border-red-200'
|
||||
}`}
|
||||
>
|
||||
{index.toString().padStart(2, '0')}
|
||||
</span>
|
||||
{renderAvatar(card.avatarUrl, <UserSearch className="w-5 h-5" />, isDark)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`text-sm font-semibold leading-snug truncate ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{card.name}
|
||||
</div>
|
||||
{card.isCustom && (
|
||||
<span
|
||||
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-violet-500/20 text-violet-200' : 'bg-violet-50 text-violet-600'
|
||||
}`}
|
||||
>
|
||||
新建
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-xs mt-1 leading-relaxed line-clamp-3 ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
|
||||
{card.detail || '暂无描述'}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 flex h-6 w-6 items-center justify-center rounded-full border transition ${
|
||||
selected
|
||||
? isDark
|
||||
? 'border-cyan-300 bg-cyan-400/30 text-cyan-100'
|
||||
: 'border-red-400 bg-red-500 text-white'
|
||||
: isDark
|
||||
? 'border-slate-600 text-transparent'
|
||||
: 'border-gray-300 text-transparent'
|
||||
}`}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface ExpertMatchCardsProps {
|
||||
data: ExpertMatchData;
|
||||
onSelectExpert: (id: string) => void;
|
||||
onRefetch: () => void;
|
||||
onCreateExpert: () => void;
|
||||
onConfirm: () => void;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
const ExpertMatchCards: React.FC<ExpertMatchCardsProps> = ({
|
||||
data,
|
||||
onSelectExpert,
|
||||
onRefetch,
|
||||
onCreateExpert,
|
||||
onConfirm,
|
||||
locked,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const isSearching = data.status === 'searching';
|
||||
const isCreating = data.status === 'creating';
|
||||
const isReady = data.status === 'ready' || data.status === 'confirmed';
|
||||
const cards = data.experts ?? [];
|
||||
|
||||
return (
|
||||
<InlineCollapsible
|
||||
defaultOpen
|
||||
title={
|
||||
<StageHeader
|
||||
title="专家匹配"
|
||||
desc={
|
||||
isSearching
|
||||
? cards.length > 0
|
||||
? '专家已匹配完成,可选择一位进入下一步'
|
||||
: '正在调用查找专家 MCP...'
|
||||
: isCreating
|
||||
? '正在调用创建专家 MCP...'
|
||||
: data.status === 'error'
|
||||
? data.errorMessage || '专家匹配失败'
|
||||
: '请选择一位专家进入下一步'
|
||||
}
|
||||
count={cards.length}
|
||||
loading={(isSearching && cards.length === 0) || isCreating}
|
||||
done={data.status === 'confirmed'}
|
||||
icon={UserSearch}
|
||||
accent="actor"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ThinkingBlock text={data.thinking} loading={isSearching && cards.length === 0 && !data.thinking} />
|
||||
|
||||
<InlineCollapsible
|
||||
variant="subtle"
|
||||
defaultOpen
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">推荐专家列表</span>
|
||||
<span className="text-[10px] opacity-70">{cards.length} 位</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{cards.length === 0 ? (
|
||||
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
{isSearching && cards.length === 0 ? '正在匹配专家...' : '暂无匹配的专家'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{cards.map((card, idx) => (
|
||||
<ExpertCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
index={idx + 1}
|
||||
selected={data.selectedExpertId === card.id}
|
||||
disabled={locked}
|
||||
onSelect={onSelectExpert}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</InlineCollapsible>
|
||||
|
||||
{data.status === 'error' && (
|
||||
<div className={`rounded-xl border px-3 py-2 text-xs ${
|
||||
isDark ? 'border-rose-500/30 bg-rose-500/10 text-rose-200' : 'border-rose-200 bg-rose-50 text-rose-700'
|
||||
}`}>
|
||||
{data.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefetch}
|
||||
disabled={locked || isSearching || isCreating}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
isDark
|
||||
? 'border-slate-600 bg-slate-800/60 text-slate-200 hover:bg-slate-700'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<RefreshCcw className={`w-3.5 h-3.5 ${isSearching ? 'animate-spin' : ''}`} />
|
||||
重新查找
|
||||
</button>
|
||||
|
||||
<div className={`flex-1 text-right text-[11px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
没有想要的专家?
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateExpert}
|
||||
disabled={locked || isCreating || isSearching}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
isDark
|
||||
? 'border-violet-400/40 bg-violet-500/15 text-violet-100 hover:bg-violet-500/25'
|
||||
: 'border-violet-200 bg-violet-50 text-violet-700 hover:bg-violet-100'
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
创建专家
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={locked || !data.selectedExpertId || !isReady}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs font-semibold text-white transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400'
|
||||
: 'bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-400 hover:to-rose-400'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
选择该专家
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</InlineCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpertMatchCards;
|
||||
@@ -0,0 +1,846 @@
|
||||
# 前端意图识别与交互集成 — 实现指南
|
||||
|
||||
> 对应后端系统:`backend/app/intent/` + `backend/app/orchestrator.py`
|
||||
> 前端主文件:`SixStepChatPage.tsx`, `api.ts`, `types.ts`, `SixStepConversation.tsx`
|
||||
> 完成度:**全部 6 步 + follow_up ✅**
|
||||
|
||||
---
|
||||
|
||||
## 一、核心概念回顾
|
||||
|
||||
### 意图体系(Intent System)
|
||||
|
||||
前端负责**传递上下文** + **处理 SSE 事件流** + **更新 UI 状态**,后端负责**识别意图** + **分支路由** + **执行业务**。
|
||||
|
||||
```
|
||||
前端按需收集当前状态信息
|
||||
↓
|
||||
打包到 IntentOrchestrateRequest
|
||||
↓
|
||||
POST /orchestrate-stream (SSE)
|
||||
↓
|
||||
后端 detect_intent() 识别意图
|
||||
↓
|
||||
根据意图分支,emit SSE 事件
|
||||
↓
|
||||
前端处理事件,同步更新 message 和 phase
|
||||
```
|
||||
|
||||
### 七种意图与前端对应
|
||||
|
||||
| 意图 | 触发词 | 后端处理 | 前端转换 | 最终状态 |
|
||||
|------|-------|---------|---------|---------|
|
||||
| **find_expert** | "找专家" | find_expert MCP | phase: idle → expert-match | 显示专家卡片 + MCP 选择弹窗 |
|
||||
| **reselect_expert** | "换专家" | find_expert MCP + 过滤 | phase: expert-confirm → expert-match | 显示新的专家卡片 |
|
||||
| **start_analysis** | "分析" "开始" | 全新 6bf | phase: expert-confirm → generate | 显示 6bf 阶段卡片和流程图 |
|
||||
| **restart_analysis** | "重新分析" | 清空+重新执行 | phase: completed → generate | 同上 |
|
||||
| **continue_analysis** | "继续" (在 paused 时) | 从中断点恢复 | phase: paused → generate | 显示 resume 提示 + 继续的卡片 |
|
||||
| **follow_up** | "为什么" "解释" (在 completed 时) | LLM 回答 | phase: completed → follow-up | Markdown 渲染追问回答 |
|
||||
| **unknown** | 含糊/歧义 | 请求澄清 | phase: * → clarifying | 显示澄清问题 |
|
||||
|
||||
---
|
||||
|
||||
## 二、数据模型层(types.ts)
|
||||
|
||||
### 2.1 SixStepPhase 枚举
|
||||
|
||||
```typescript
|
||||
export type SixStepPhase =
|
||||
| 'idle' // 【初始】未开始任何操作
|
||||
| 'intent-detecting' // 【过渡】意图正在识别中(通常很短,用户看不到)
|
||||
| 'expert-match' // 【等待】展示专家卡片,用户可浏览、搜索、重选
|
||||
| 'expert-confirm' // 【交互】MCP 多选弹窗打开,用户确认选择
|
||||
| 'generate' // 【处理】6bf 编排执行中,显示进度和卡片
|
||||
| 'paused' // 【中断】用户手动停止;可继续或重新分析
|
||||
| 'completed' // 【完成】编排全部完成;可追问或重新分析
|
||||
| 'clarifying' // 【澄清】后端无法理解,请用户补充
|
||||
| 'follow-up'; // 【追问】正在基于已有分析回答用户问题
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- `expert-match` vs `expert-confirm` 的区别:前者是**显示结果**,后者是**交互确认**(MCP 弹窗)
|
||||
- `paused` **必须**在 `handleStop()` 手动设置,后端的 continue_analysis 分支不会主动发送 paused
|
||||
- `follow-up` 仅在 `current_phase === 'completed'` 且用户输入匹配追问模式时触发
|
||||
|
||||
### 2.2 SixStepMessage 新增字段
|
||||
|
||||
```typescript
|
||||
export interface SixStepMessage {
|
||||
// ... 原有字段 (id, role, content, timestamp, stages, finalAnswer, ...)
|
||||
|
||||
// ========== 意图识别相关 ==========
|
||||
intent?: {
|
||||
name: string; // 识别的意图名(find_expert / follow_up / unknown 等)
|
||||
confidence?: number; // 置信度 [0.0, 1.0]
|
||||
reason?: string; // 识别理由(用于 debug/日志)
|
||||
};
|
||||
|
||||
// ========== 澄清问题 ==========
|
||||
clarifyQuestion?: string; // 后端提出的澄清问题(当 intent.name === "unknown")
|
||||
|
||||
// ========== 继续分析上下文 ==========
|
||||
/**
|
||||
* 原始步骤数据(来自后端 step_end 事件)。
|
||||
* 结构:BackendStepResult[] = { key, name, status, thinking, structured_outputs, markdown, references }
|
||||
* 用途:
|
||||
* - 当用户暂停(handleStop)时,保存这些数据
|
||||
* - 当用户"继续分析"时,传回后端作为 last_steps,后端可恢复上下文
|
||||
* - 当用户"追问"时,后端用这些数据构造 context,生成更准确的回答
|
||||
*/
|
||||
rawSteps?: BackendStepResult[];
|
||||
|
||||
/**
|
||||
* 本轮是从哪个步骤继续的(来自后端 resume 事件)。
|
||||
* 值:step key(如 "behavior_factor")或 step name(如 "预期行为与驱动因素")
|
||||
* UI 用途:在 AssistantMessage 上方显示"⏸ 从 [步骤名] 继续"提示
|
||||
*/
|
||||
resumedFrom?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 IntentOrchestrateRequest(api.ts)
|
||||
|
||||
```typescript
|
||||
export interface IntentOrchestrateRequest {
|
||||
// 意图识别相关
|
||||
intent_mode?: 'auto' | 'force_orchestrate' | 'force_find_expert';
|
||||
|
||||
// 会话状态(前端告知后端)
|
||||
current_phase: string;
|
||||
selected_expert?: ExpertCard | null;
|
||||
selected_mcps?: SelectableMcpKey[];
|
||||
|
||||
// 继续分析相关
|
||||
last_steps?: BackendStepResult[];
|
||||
resume_from_step?: string;
|
||||
expert_candidates?: ExpertCard[]; // 用于 reselect_expert 时过滤
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、流处理层(api.ts)
|
||||
|
||||
### 3.1 runIntentOrchestrateStream() — 统一 SSE 处理器
|
||||
|
||||
```typescript
|
||||
export async function runIntentOrchestrateStream(
|
||||
message: string,
|
||||
options: {
|
||||
onUpdate?: (msg: SixStepMessage) => void;
|
||||
onComplete?: () => void;
|
||||
abortSignal?: AbortSignal;
|
||||
},
|
||||
requestBody?: IntentOrchestrateRequest
|
||||
): Promise<SixStepStreamController>
|
||||
```
|
||||
|
||||
**内部逻辑概述**:
|
||||
|
||||
```
|
||||
1. 发起 POST /orchestrate-stream(带 requestBody 和 AbortSignal)
|
||||
2. 遍历 SSE 事件流,解析 JSON
|
||||
3. 根据事件名分发到不同处理函数
|
||||
4. 每次修改 message 后调用 onUpdate(message),触发前端 re-render
|
||||
5. 接收 done 或 error 事件时停止
|
||||
```
|
||||
|
||||
### 3.2 关键事件处理函数
|
||||
|
||||
#### **intent 事件**
|
||||
|
||||
```typescript
|
||||
// 来自后端:{ intent: "follow_up", confidence: 0.88, reason: "..." }
|
||||
|
||||
case 'intent':
|
||||
const intentName = data.intent;
|
||||
message.intent = { name: intentName, ...data };
|
||||
|
||||
// 👇 根据意图确定流程分支
|
||||
if (intentName === 'find_expert' || intentName === 'reselect_expert') {
|
||||
flow = 'expert'; // 接下来期待 expert_start/expert_end
|
||||
} else if (intentName === 'follow_up') {
|
||||
flow = 'follow_up'; // 接下来期待 delta(文本块)
|
||||
message.phase = 'follow-up';
|
||||
message.outputFormat = 'markdown';
|
||||
} else if (intentName === 'unknown') {
|
||||
flow = 'clarify'; // 接下来期待 clarify 事件
|
||||
} else {
|
||||
// start_analysis / restart_analysis / continue_analysis
|
||||
flow = 'generate'; // 接下来期待 start / step_start / step_end
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
**前端的 flow 变量**(内部状态,不持久化):用于判断预期的**下一个事件类型**和**数据处理方式**。
|
||||
|
||||
#### **expert_start / expert_end 事件**
|
||||
|
||||
```typescript
|
||||
case 'expert_start':
|
||||
message.phase = 'expert-match';
|
||||
message.expertMatch = {
|
||||
status: 'searching',
|
||||
thinking: '',
|
||||
experts: [],
|
||||
selectedExpertId: undefined,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'expert_end':
|
||||
// data = { experts: [...], thinking: str, is_reselect: bool, ... }
|
||||
message.expertMatch!.status = 'ready';
|
||||
message.expertMatch!.thinking = data.thinking;
|
||||
|
||||
// 逐张卡片追加,配合 pop 动画
|
||||
for (const expert of data.experts) {
|
||||
message.expertMatch!.experts.push({
|
||||
id: expert.id,
|
||||
name: expert.name,
|
||||
detail: expert.detail,
|
||||
...
|
||||
});
|
||||
// 每张卡片之间延迟 100ms,触发 pop 动画
|
||||
}
|
||||
|
||||
// 卡片全部加载后,更新 phase 并打开 MCP 选择弹窗
|
||||
message.phase = 'expert-confirm';
|
||||
// → SixStepChatPage 监听 expertMatch.status === 'ready',打开 mcpModalOpen
|
||||
break;
|
||||
```
|
||||
|
||||
#### **clarify 事件**
|
||||
|
||||
```typescript
|
||||
case 'clarify':
|
||||
message.phase = 'clarifying';
|
||||
message.clarifyQuestion = data.message; // "我没有完全理解你的意图,请补充说明..."
|
||||
break;
|
||||
```
|
||||
|
||||
#### **resume 事件**(continue_analysis 特有)
|
||||
|
||||
```typescript
|
||||
case 'resume':
|
||||
// data = { task_id, resume_from, mcp_list: [...] }
|
||||
message.resumedFrom = data.resume_from; // 后端告诉前端从哪个步骤继续
|
||||
|
||||
// UI 用途:在 AssistantMessage 顶部显示 "⏸ 从 [步骤名] 继续"
|
||||
// 该变量会保留在消息中,整个 continue 过程结束后仍显示
|
||||
break;
|
||||
```
|
||||
|
||||
#### **start 事件**(开始 6bf 编排)
|
||||
|
||||
```typescript
|
||||
case 'start':
|
||||
message.phase = 'generate';
|
||||
// data = { task_id, mcp_list: [...], ts: number }
|
||||
|
||||
// 👇 防止 restart_analysis 时保留旧卡片
|
||||
if (!isResume) { // isResume 由 resume 事件设置,continue_analysis 时为 true
|
||||
message.stages = {}; // 清空
|
||||
message.subStepStatus = {};
|
||||
message.mainStepStatus = { info: 'done', sixstep: 'pending', final: 'pending' };
|
||||
}
|
||||
// 若 isResume === true(continue_analysis),则保留已有的 stages,只追加新步骤
|
||||
|
||||
message.currentMainStep = 'sixstep';
|
||||
break;
|
||||
```
|
||||
|
||||
**关键**:`!isResume` 的条件确保 **restart_analysis 时清空旧卡片**,而 **continue_analysis 时保留已有卡片**。
|
||||
|
||||
#### **step_start / step_end 事件**
|
||||
|
||||
```typescript
|
||||
case 'step_start':
|
||||
// data = { name: "目的分析", key: "purpose" }
|
||||
const stepKey = data.key;
|
||||
if (stepKey === 'purpose') {
|
||||
message.subStepStatus['purpose'] = 'running';
|
||||
message.currentSubStep = 'purpose';
|
||||
message.stages!.purpose = { thinking: '', cards: [], complete: false };
|
||||
}
|
||||
// ... 类似处理 actor / behavior / narrative
|
||||
break;
|
||||
|
||||
case 'step_end':
|
||||
// data = { key, status, thinking, structured_outputs, markdown, references, ... }
|
||||
const stepKey = data.key;
|
||||
|
||||
// 👇 原始数据存档,用于后续 continue_analysis / follow_up
|
||||
if (stepKey !== 'info_prepare') {
|
||||
message.rawSteps = message.rawSteps || [];
|
||||
message.rawSteps.push(data); // 保存完整的 BackendStepResult
|
||||
}
|
||||
|
||||
// 更新当前消息的对应阶段
|
||||
if (stepKey === 'purpose') {
|
||||
message.stages!.purpose!.thinking = data.thinking;
|
||||
message.stages!.purpose!.cards = buildPurposeCards(data.structured_outputs);
|
||||
message.stages!.purpose!.complete = true;
|
||||
message.subStepStatus['purpose'] = 'done';
|
||||
}
|
||||
// ... 类似处理 actor / behavior / narrative
|
||||
|
||||
break;
|
||||
```
|
||||
|
||||
#### **delta 事件**(follow_up 专有)
|
||||
|
||||
```typescript
|
||||
case 'delta':
|
||||
// data = { text: "..." }(文本块)
|
||||
// 仅当 flow === 'follow_up' 时处理
|
||||
|
||||
if (flow === 'follow_up') {
|
||||
message.markdownContent = (message.markdownContent || '') + data.text;
|
||||
onUpdate(message); // 逐块更新前端,流式显示
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
#### **done 事件**
|
||||
|
||||
```typescript
|
||||
case 'done':
|
||||
// 各分支的终止信号
|
||||
|
||||
if (flow === 'follow_up') {
|
||||
message.phase = 'completed'; // ✅ follow_up 回答完成
|
||||
} else if (flow === 'expert') {
|
||||
// 专家查询完成,等待前端用户在弹窗中确认选择
|
||||
// phase 保持 'expert-confirm'
|
||||
} else if (flow === 'generate') {
|
||||
message.phase = 'completed'; // ✅ 6bf 推演完成
|
||||
} else if (flow === 'clarify') {
|
||||
// 保持 'clarifying',等待用户重新输入
|
||||
}
|
||||
|
||||
onComplete?.(); // 回调:请求完成
|
||||
break;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、主页面逻辑(SixStepChatPage.tsx)
|
||||
|
||||
### 4.1 新增状态变量
|
||||
|
||||
```typescript
|
||||
// 当前阶段(驱动 UI 和后端意图识别)
|
||||
const [currentPhase, setCurrentPhase] = useState<SixStepPhase>('idle');
|
||||
|
||||
// 已选专家和 MCP(用于请求体和 UI 展示)
|
||||
const [selectedExpert, setSelectedExpert] = useState<ExpertCard | null>(null);
|
||||
const [selectedMcps, setSelectedMcps] = useState<SelectableMcpKey[]>([]);
|
||||
|
||||
// 已推荐过的专家列表(reselect_expert 时用于过滤)
|
||||
const [expertCandidates, setExpertCandidates] = useState<ExpertCard[]>([]);
|
||||
|
||||
// 临时保存原始用户输入(确认专家后需要)
|
||||
const [lastUserInput, setLastUserInput] = useState<string>('');
|
||||
```
|
||||
|
||||
### 4.2 handleSend() — 请求体构造
|
||||
|
||||
```typescript
|
||||
async function handleSend(inputText: string) {
|
||||
setLastUserInput(inputText); // 保存原始输入
|
||||
|
||||
// 【1】获取上次的助手消息(用于获取 rawSteps)
|
||||
const lastAssistant = messages.findLast(m => m.role === 'assistant');
|
||||
|
||||
// 【2】构造请求体(关键!传递上下文给后端)
|
||||
const requestBody: IntentOrchestrateRequest = {
|
||||
intent_mode: 'auto', // 自动意图识别
|
||||
current_phase: currentPhase,
|
||||
selected_expert: selectedExpert,
|
||||
selected_mcps: selectedMcps,
|
||||
last_steps: lastAssistant?.rawSteps || [], // 👈 用于 continue / follow_up
|
||||
resume_from_step: undefined, // 让后端自动推断
|
||||
expert_candidates: expertCandidates, // 👈 用于 reselect_expert 过滤
|
||||
};
|
||||
|
||||
// 【3】调用流处理
|
||||
const controller = await runIntentOrchestrateStream(
|
||||
inputText,
|
||||
{
|
||||
onUpdate: (msg) => {
|
||||
// 替换消息列表中的末尾消息(正在生成的消息)
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev];
|
||||
newMessages[newMessages.length - 1] = msg;
|
||||
return newMessages;
|
||||
});
|
||||
|
||||
// 同步更新当前阶段
|
||||
setCurrentPhase(msg.phase || 'idle');
|
||||
|
||||
// 【关键】监听专家卡片加载完成,打开 MCP 选择弹窗
|
||||
if (msg.expertMatch?.status === 'ready') {
|
||||
setMcpModalOpen(true);
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsGenerating(false);
|
||||
},
|
||||
},
|
||||
requestBody
|
||||
);
|
||||
|
||||
controllerRef.current = controller;
|
||||
setIsGenerating(true);
|
||||
}
|
||||
```
|
||||
|
||||
**核心三步**:
|
||||
1. **构造请求体**:携带所有必要的上下文(current_phase, last_steps, expert_candidates)
|
||||
2. **调用流处理**:传入 onUpdate 回调,每个 SSE 事件都更新前端
|
||||
3. **监听事件**:特别是 expertMatch.status === 'ready',自动打开 MCP 弹窗
|
||||
|
||||
### 4.3 handleMcpModalConfirm() — 确认专家后立即启动分析
|
||||
|
||||
```typescript
|
||||
function handleMcpModalConfirm(selectedExpert: ExpertCard, selectedMcpList: SelectableMcpKey[]) {
|
||||
// 【1】保存用户选择
|
||||
setSelectedExpert(selectedExpert);
|
||||
setSelectedMcps(selectedMcpList);
|
||||
setExpertCandidates(prev => [...prev, selectedExpert]); // 添加到候选列表
|
||||
|
||||
// 【2】立即用 force_orchestrate 启动分析(不需要等用户再次输入)
|
||||
const requestBody: IntentOrchestrateRequest = {
|
||||
intent_mode: 'force_orchestrate', // 👈 强制执行编排,不识别意图
|
||||
current_phase: 'expert-confirm',
|
||||
selected_expert: selectedExpert,
|
||||
selected_mcps: selectedMcpList,
|
||||
last_steps: [],
|
||||
};
|
||||
|
||||
// 【3】使用保存的原始输入(用户之前在输入框里输入的文本)
|
||||
runIntentOrchestrateStream(
|
||||
lastUserInput, // 👈 原始输入
|
||||
{
|
||||
onUpdate: (msg) => {
|
||||
setMessages(prev => [...prev.slice(0, -1), msg]);
|
||||
setCurrentPhase(msg.phase || 'idle');
|
||||
},
|
||||
onComplete: () => setIsGenerating(false),
|
||||
},
|
||||
requestBody
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- `intent_mode: 'force_orchestrate'` — 跳过意图识别,直接执行 6bf
|
||||
- 使用 `lastUserInput`(保存的原始输入)而非动态输入
|
||||
- 添加 selectedExpert 到 expertCandidates,以便后续 reselect 时过滤
|
||||
|
||||
### 4.4 handleRefetchExpert() — 重选专家
|
||||
|
||||
```typescript
|
||||
function handleRefetchExpert() {
|
||||
const requestBody: IntentOrchestrateRequest = {
|
||||
intent_mode: 'force_find_expert', // 👈 强制查询专家
|
||||
current_phase: 'expert-confirm',
|
||||
expert_candidates: expertCandidates, // 👈 告诉后端已有候选,避免重复推荐
|
||||
last_steps: [],
|
||||
};
|
||||
|
||||
runIntentOrchestrateStream(
|
||||
lastUserInput,
|
||||
{
|
||||
onUpdate: (msg) => {
|
||||
setMessages(prev => [...prev.slice(0, -1), msg]);
|
||||
setCurrentPhase(msg.phase || 'idle');
|
||||
// MCP 弹窗保持打开,等待用户重新选择
|
||||
},
|
||||
},
|
||||
requestBody
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 handleStop() — 停止生成
|
||||
|
||||
```typescript
|
||||
function handleStop() {
|
||||
controllerRef.current?.stop();
|
||||
|
||||
// 👇 关键:标记当前消息为 paused,供后续 continue_analysis 识别
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev];
|
||||
const lastMsg = newMessages[newMessages.length - 1];
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
lastMsg.phase = 'paused';
|
||||
}
|
||||
return newMessages;
|
||||
});
|
||||
|
||||
setCurrentPhase('paused');
|
||||
setIsGenerating(false);
|
||||
}
|
||||
```
|
||||
|
||||
**为什么需要标记 paused?**
|
||||
- 后端的 intent 识别器会检查 `current_phase === 'paused'`,优先返回 `continue_analysis` 意图
|
||||
- 前端显示 "⏸ 从 [步骤] 继续" 提示,让用户清楚地知道可以继续
|
||||
|
||||
---
|
||||
|
||||
## 五、对话区渲染(SixStepConversation.tsx)
|
||||
|
||||
### 5.1 AssistantMessage 的新渲染逻辑
|
||||
|
||||
```typescript
|
||||
const AssistantMessage = ({ message, active }: Props) => {
|
||||
const isExpertPhase = message.phase === 'expert-match' || message.phase === 'expert-confirm';
|
||||
const isFollowUp = message.phase === 'follow-up';
|
||||
const isClarifying = message.phase === 'clarifying';
|
||||
const isMarkdown = message.outputFormat === 'markdown';
|
||||
|
||||
// 👇 【防护条件】防止旧分析内容在专家/澄清/追问阶段出现
|
||||
const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 【分支 1】专家匹配阶段 */}
|
||||
{isExpertPhase && message.expertMatch && (
|
||||
<ExpertMatchCards
|
||||
data={message.expertMatch}
|
||||
onRefetch={onRefetch}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 【分支 2】6bf 分析阶段(防护条件) */}
|
||||
{showAnalysisContent && (
|
||||
<>
|
||||
{/* 继续分析提示 */}
|
||||
{message.resumedFrom && (
|
||||
<div className="resume-hint">
|
||||
⏸ 从「{message.resumedFrom}」继续
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 各阶段卡片 */}
|
||||
{message.stages?.info && <InfoStage ... />}
|
||||
{message.stages?.purpose && <PurposeStage ... />}
|
||||
{message.stages?.actor && <ActorStage ... />}
|
||||
{message.stages?.behavior && <BehaviorStage ... />}
|
||||
{message.stages?.narrative && <NarrativeStage ... />}
|
||||
{message.finalAnswer && <FinalAnswer ... />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 【分支 3】追问回答(Markdown) */}
|
||||
{isFollowUp && message.markdownContent && (
|
||||
<MarkdownRenderer content={message.markdownContent} />
|
||||
)}
|
||||
|
||||
{/* 【分支 4】Markdown 输出格式 */}
|
||||
{isMarkdown && !isFollowUp && message.markdownContent && (
|
||||
<MarkdownRenderer content={message.markdownContent} />
|
||||
)}
|
||||
|
||||
{/* 【分支 5】澄清问题 */}
|
||||
{isClarifying && message.clarifyQuestion && (
|
||||
<div className="clarify-block">
|
||||
<p className="text-sm text-gray-600">我需要你进一步说明:</p>
|
||||
<p className="font-medium">{message.clarifyQuestion}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">请重新输入以补充信息</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**关键防护条件**:
|
||||
|
||||
```typescript
|
||||
const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;
|
||||
```
|
||||
|
||||
这一行确保了:
|
||||
- ✅ `expert-match` 阶段只显示专家卡片,不显示旧分析
|
||||
- ✅ `clarifying` 阶段只显示澄清问题,不显示其他内容
|
||||
- ✅ `follow-up` 阶段只显示 Markdown 回答,不显示旧分析
|
||||
|
||||
### 5.2 阶段加载动画
|
||||
|
||||
```typescript
|
||||
// 当前阶段正在生成时,显示 shimmer(流光)动画
|
||||
const purposeGenerating = message.phase === 'generate' &&
|
||||
message.currentSubStep === 'purpose' &&
|
||||
active;
|
||||
|
||||
<StageHeader
|
||||
loading={purposeGenerating}
|
||||
icon={<Target ... />}
|
||||
title="目的分析"
|
||||
count={message.stages?.purpose?.cards.length || 0}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、生命周期示例
|
||||
|
||||
### 6.1 用户"找专家"的流程
|
||||
|
||||
```
|
||||
【用户输入】"帮我找个专家"
|
||||
↓
|
||||
【前端】handleSend("帮我找个专家")
|
||||
- requestBody: { intent_mode: 'auto', current_phase: 'idle', last_steps: [] }
|
||||
- 调用 runIntentOrchestrateStream
|
||||
↓
|
||||
【后端】detect_intent("帮我找个专家", current_phase='idle', ...)
|
||||
- 规则识别:匹配 _FIND_PATTERNS → confidence=0.88
|
||||
- 返回:IntentResult(intent='find_expert', ...)
|
||||
↓
|
||||
【后端】根据 intent='find_expert',调用 _stream_find_expert()
|
||||
- emit: expert_start
|
||||
- 查询 find_expert MCP agent
|
||||
- emit: expert_end { experts: [...] }
|
||||
- emit: done
|
||||
↓
|
||||
【前端】处理 SSE 事件
|
||||
- intent: { name: 'find_expert', ... }
|
||||
→ flow = 'expert'
|
||||
- expert_start
|
||||
→ message.phase = 'expert-match'
|
||||
→ message.expertMatch.status = 'searching'
|
||||
- expert_end
|
||||
→ message.expertMatch.status = 'ready'
|
||||
→ message.expertMatch.experts = [...]
|
||||
→ message.phase = 'expert-confirm'
|
||||
→ 检测到 expertMatch.status === 'ready'
|
||||
→ setMcpModalOpen(true) 打开 MCP 选择弹窗
|
||||
- done
|
||||
→ onComplete()
|
||||
↓
|
||||
【用户】在 MCP 选择弹窗中选择专家和 MCP,点击"确认"
|
||||
↓
|
||||
【前端】handleMcpModalConfirm(selectedExpert, selectedMcps)
|
||||
- setSelectedExpert(selectedExpert)
|
||||
- setSelectedMcps(selectedMcps)
|
||||
- requestBody: { intent_mode: 'force_orchestrate', ... }
|
||||
- 调用 runIntentOrchestrateStream(lastUserInput, ...)
|
||||
↓
|
||||
【后端】orchestrate_stream() with intent_mode='force_orchestrate'
|
||||
- 跳过意图识别
|
||||
- 调用 _stream_orchestrate(..., intent_name='start_analysis')
|
||||
- emit: start
|
||||
- emit: step_start / step_end × 4 (purpose / actor / behavior / narrative)
|
||||
- emit: flow { nodes, edges }
|
||||
- emit: done
|
||||
↓
|
||||
【前端】处理 SSE 事件
|
||||
- start
|
||||
→ message.phase = 'generate'
|
||||
→ 初始化 stages 和状态
|
||||
- step_start × 4
|
||||
→ 每个步骤的 status = 'running'
|
||||
→ 对应阶段初始化为空
|
||||
- step_end × 4
|
||||
→ 填充 stages[stepKey].cards
|
||||
→ message.rawSteps.push(data)
|
||||
→ status = 'done'
|
||||
- flow
|
||||
→ 流程图数据更新(供沙箱显示)
|
||||
- done
|
||||
→ message.phase = 'completed'
|
||||
↓
|
||||
【前端 UI】
|
||||
- ✅ 显示 4 个阶段的卡片
|
||||
- ✅ 显示流程图沙箱
|
||||
- ✅ 阶段完成时有 pop 动画和叙事卡片
|
||||
```
|
||||
|
||||
### 6.2 用户"继续分析"的流程
|
||||
|
||||
```
|
||||
【前次】完成了目的、行为体阶段,用户点击"停止"
|
||||
- 后端返回 step_end(预期行为/驱动因素)但用户取消
|
||||
- message.phase = 'paused'
|
||||
- message.rawSteps = [
|
||||
{ key: 'purpose', status: 'completed', ... },
|
||||
{ key: 'actor', status: 'completed', ... },
|
||||
{ key: 'behavior_factor', status: 'running', ... }
|
||||
]
|
||||
↓
|
||||
【用户输入】"继续"
|
||||
↓
|
||||
【前端】handleSend("继续")
|
||||
- requestBody: {
|
||||
intent_mode: 'auto',
|
||||
current_phase: 'paused', // 👈 关键
|
||||
last_steps: message.rawSteps, // 👈 关键
|
||||
}
|
||||
↓
|
||||
【后端】detect_intent("继续", current_phase='paused', last_steps=[...])
|
||||
- 规则识别:匹配 _CONTINUE_PATTERNS AND current_phase='paused'
|
||||
- 返回:IntentResult(intent='continue_analysis', confidence=0.90)
|
||||
↓
|
||||
【后端】_stream_orchestrate(..., intent_name='continue_analysis')
|
||||
- _resume_context_from_steps(req.last_steps, None)
|
||||
→ 检查已完成的步骤:['purpose', 'actor']
|
||||
→ resume_key = 'behavior_factor'(下一个未完成的)
|
||||
- emit: resume { resume_from: 'behavior_factor', mcp_list: ['behavior_factor', 'narrative'] }
|
||||
- emit: start { mcp_list: ['behavior_factor', 'narrative'] }
|
||||
- emit: step_start / step_end × 2 (behavior_factor, narrative)
|
||||
- emit: done
|
||||
↓
|
||||
【前端】处理 SSE 事件
|
||||
- intent: { name: 'continue_analysis', ... }
|
||||
- resume: { resume_from: 'behavior_factor' }
|
||||
→ message.resumedFrom = 'behavior_factor'
|
||||
→ isResume = true // 标记为继续,不清空旧 stages
|
||||
- start
|
||||
→ 因为 isResume=true,不清空 message.stages
|
||||
→ 保留已有的 purpose/actor 卡片
|
||||
- step_start / step_end × 2
|
||||
→ 追加新的 behavior/narrative 卡片
|
||||
→ message.rawSteps.push(behavior_data)
|
||||
→ message.rawSteps.push(narrative_data)
|
||||
- done
|
||||
→ message.phase = 'completed'
|
||||
↓
|
||||
【前端 UI】
|
||||
- ✅ 显示"⏸ 从「预期行为与驱动因素」继续"提示
|
||||
- ✅ 保留旧的目的/行为体卡片
|
||||
- ✅ 追加新的预期行为/叙事卡片
|
||||
```
|
||||
|
||||
### 6.3 用户"追问"的流程
|
||||
|
||||
```
|
||||
【前次】完成了完整的 6bf,message.phase = 'completed'
|
||||
- message.rawSteps = [
|
||||
{ key: 'purpose', ... },
|
||||
{ key: 'actor', ... },
|
||||
{ key: 'behavior_factor', ... },
|
||||
{ key: 'narrative', ... }
|
||||
]
|
||||
↓
|
||||
【用户输入】"第一条是什么意思"
|
||||
↓
|
||||
【前端】handleSend("第一条是什么意思")
|
||||
- requestBody: {
|
||||
intent_mode: 'auto',
|
||||
current_phase: 'completed', // 👈 关键
|
||||
last_steps: message.rawSteps,
|
||||
}
|
||||
↓
|
||||
【后端】detect_intent("第一条是什么意思", current_phase='completed', last_steps=[...])
|
||||
- 规则识别:匹配 _FOLLOW_UP_PATTERNS(r"第[一二三四五六七八九十\d]+[点条个项]")
|
||||
- 返回:IntentResult(intent='follow_up', confidence=0.88)
|
||||
↓
|
||||
【后端】_stream_follow_up(req, ...)
|
||||
- 提取 req.last_steps 中已完成步骤的数据
|
||||
- 构造 context_str(包含目的、行为体、预期行为、叙事)
|
||||
- 调用 LLM 流式回答
|
||||
- emit: delta { text: "..." } × N
|
||||
- emit: done
|
||||
↓
|
||||
【前端】处理 SSE 事件
|
||||
- intent: { name: 'follow_up', ... }
|
||||
→ flow = 'follow_up'
|
||||
→ message.phase = 'follow-up'
|
||||
→ message.outputFormat = 'markdown'
|
||||
- delta × N
|
||||
→ message.markdownContent += text
|
||||
→ 每块更新都触发 onUpdate,前端流式显示
|
||||
- done
|
||||
→ message.phase = 'completed'
|
||||
↓
|
||||
【前端 UI】
|
||||
- ✅ 显示 Markdown 格式的追问回答
|
||||
- ✅ 不显示旧的 6bf 卡片(因为 showAnalysisContent = false)
|
||||
- ✅ 流式效果:用户看到实时生成的文本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、常见问题解决
|
||||
|
||||
### Q1: 完成 6bf 后再选专家,为什么显示了旧的分析内容?
|
||||
|
||||
**原因**:AssistantMessage 中没有正确应用 `showAnalysisContent` 防护条件。
|
||||
|
||||
**检查**:
|
||||
```typescript
|
||||
const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;
|
||||
```
|
||||
|
||||
确保:
|
||||
- `isExpertPhase = message.phase === 'expert-match' || message.phase === 'expert-confirm'` ✓
|
||||
- 所有 `stages` / `finalAnswer` 的渲染都被 `{showAnalysisContent && (...)}` 包裹 ✓
|
||||
|
||||
### Q2: 继续分析后,旧卡片消失了
|
||||
|
||||
**原因**:SSE start 事件中清空了 stages(应该只在 !isResume 时清空)。
|
||||
|
||||
**检查** `api.ts` 中的 start 事件处理:
|
||||
```typescript
|
||||
case 'start':
|
||||
if (!isResume) { // 👈 这个条件必须有
|
||||
message.stages = {};
|
||||
}
|
||||
// ...
|
||||
break;
|
||||
```
|
||||
|
||||
### Q3: 用户点"继续",但后端返回了"开始新分析"
|
||||
|
||||
**原因**:`current_phase` 未传递为 'paused',或未手动设置 `phase = 'paused'`。
|
||||
|
||||
**检查** `SixStepChatPage.handleStop()`:
|
||||
```typescript
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev];
|
||||
const lastMsg = newMessages[newMessages.length - 1];
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
lastMsg.phase = 'paused'; // 👈 必须设置
|
||||
}
|
||||
return newMessages;
|
||||
});
|
||||
setCurrentPhase('paused'); // 👈 必须同步状态
|
||||
```
|
||||
|
||||
### Q4: 追问回答为空
|
||||
|
||||
**原因**:
|
||||
1. `current_phase !== 'completed'`(后端不识别为 follow_up)
|
||||
2. `last_steps` 为空或格式错误
|
||||
3. LLM 调用失败(查看后端日志)
|
||||
|
||||
**检查**:
|
||||
- 确保 message.rawSteps 在 step_end 时被正确赋值
|
||||
- 确保 request 中 `last_steps` 被传递为 `message.rawSteps`
|
||||
- 后端日志是否有 `_stream_follow_up` 的报错
|
||||
|
||||
---
|
||||
|
||||
## 八、开发建议
|
||||
|
||||
1. **调试技巧**:在 `onUpdate` 中 `console.log(message)` 观察每个事件的状态变化
|
||||
2. **SSE 事件顺序不可打破**:前端严格依赖事件顺序做状态机转换
|
||||
3. **rawSteps 的完整性**:非 info_prepare 的所有 step_end 都必须存档,否则 continue/follow_up 会丢失数据
|
||||
4. **phase 与 outputFormat 的对应**:
|
||||
- follow-up → outputFormat = 'markdown'
|
||||
- generate → outputFormat = 'structured'(卡片渲染)
|
||||
- expert-match → 无 outputFormat(专家卡片独立)
|
||||
|
||||
---
|
||||
|
||||
**相关文件导航**:
|
||||
- 后端意图系统:`backend/app/intent/`
|
||||
- 后端编排系统:`backend/app/orchestrator.py`
|
||||
- 前端核心:`SixStepChatPage.tsx` (state hub) → `api.ts` (SSE handler) → `SixStepConversation.tsx` (rendering)
|
||||
- 前端类型:`types.ts`
|
||||
- 前端原始文档:`doc.md`
|
||||
@@ -0,0 +1,233 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Megaphone, Target, Users, X, Zap } from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import type { ExpertCard, SelectableMcpKey } from './types';
|
||||
|
||||
interface McpOption {
|
||||
key: SelectableMcpKey;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
accent: 'purpose' | 'actor' | 'behavior' | 'narrative';
|
||||
}
|
||||
|
||||
const MCP_OPTIONS: McpOption[] = [
|
||||
{ key: 'purpose', name: '目的', description: '识别本次推演的核心目的,构成后续推理的锚点。', icon: Target, accent: 'purpose' },
|
||||
{ key: 'actor', name: '行为体', description: '推演支撑目的所需的关键行为体角色。', icon: Users, accent: 'actor' },
|
||||
{ key: 'behavior_factor', name: '预期行为和驱动因素', description: '为每个行为体推演预期行为及背后的驱动因素。', icon: Zap, accent: 'behavior' },
|
||||
{ key: 'narrative', name: '叙事', description: '基于驱动因素构建可被复用的叙事框架。', icon: Megaphone, accent: 'narrative' },
|
||||
];
|
||||
|
||||
const ACCENT_LIGHT: Record<McpOption['accent'], { selected: string; idle: string; chip: string }> = {
|
||||
purpose: {
|
||||
selected: 'border-orange-300 bg-orange-50/70 ring-1 ring-orange-200',
|
||||
idle: 'border-gray-200 bg-white hover:border-orange-200',
|
||||
chip: 'bg-orange-50 text-orange-600',
|
||||
},
|
||||
actor: {
|
||||
selected: 'border-red-300 bg-red-50/70 ring-1 ring-red-200',
|
||||
idle: 'border-gray-200 bg-white hover:border-red-200',
|
||||
chip: 'bg-red-50 text-red-600',
|
||||
},
|
||||
behavior: {
|
||||
selected: 'border-emerald-300 bg-emerald-50/70 ring-1 ring-emerald-200',
|
||||
idle: 'border-gray-200 bg-white hover:border-emerald-200',
|
||||
chip: 'bg-emerald-50 text-emerald-600',
|
||||
},
|
||||
narrative: {
|
||||
selected: 'border-amber-300 bg-amber-50/70 ring-1 ring-amber-200',
|
||||
idle: 'border-gray-200 bg-white hover:border-amber-200',
|
||||
chip: 'bg-amber-50 text-amber-600',
|
||||
},
|
||||
};
|
||||
|
||||
const ACCENT_DARK: Record<McpOption['accent'], { selected: string; idle: string; chip: string }> = {
|
||||
purpose: {
|
||||
selected: 'border-violet-300/50 bg-violet-500/15 ring-1 ring-violet-300/30',
|
||||
idle: 'border-slate-700 bg-slate-800/60 hover:border-violet-300/40',
|
||||
chip: 'bg-violet-500/20 text-violet-200',
|
||||
},
|
||||
actor: {
|
||||
selected: 'border-cyan-300/50 bg-cyan-500/15 ring-1 ring-cyan-300/30',
|
||||
idle: 'border-slate-700 bg-slate-800/60 hover:border-cyan-300/40',
|
||||
chip: 'bg-cyan-500/20 text-cyan-200',
|
||||
},
|
||||
behavior: {
|
||||
selected: 'border-emerald-300/50 bg-emerald-500/15 ring-1 ring-emerald-300/30',
|
||||
idle: 'border-slate-700 bg-slate-800/60 hover:border-emerald-300/40',
|
||||
chip: 'bg-emerald-500/20 text-emerald-200',
|
||||
},
|
||||
narrative: {
|
||||
selected: 'border-amber-300/50 bg-amber-500/15 ring-1 ring-amber-300/30',
|
||||
idle: 'border-slate-700 bg-slate-800/60 hover:border-amber-300/40',
|
||||
chip: 'bg-amber-500/20 text-amber-200',
|
||||
},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
expert: ExpertCard | null;
|
||||
initialSelected?: SelectableMcpKey[];
|
||||
onCancel: () => void;
|
||||
onConfirm: (selected: SelectableMcpKey[]) => void;
|
||||
}
|
||||
|
||||
const McpSelectModal: React.FC<Props> = ({ open, expert, initialSelected, onCancel, onConfirm }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const defaultAll = useMemo<SelectableMcpKey[]>(() => MCP_OPTIONS.map((m) => m.key), []);
|
||||
const [selected, setSelected] = useState<SelectableMcpKey[]>(
|
||||
initialSelected && initialSelected.length > 0 ? initialSelected : defaultAll,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected(initialSelected && initialSelected.length > 0 ? initialSelected : defaultAll);
|
||||
setError(null);
|
||||
}
|
||||
}, [defaultAll, initialSelected, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const toggle = (key: SelectableMcpKey) => {
|
||||
setSelected((prev) => {
|
||||
const has = prev.includes(key);
|
||||
if (has) {
|
||||
if (prev.length <= 1) {
|
||||
setError('至少选择一个生成步骤');
|
||||
return prev;
|
||||
}
|
||||
setError(null);
|
||||
return prev.filter((k) => k !== key);
|
||||
}
|
||||
setError(null);
|
||||
return [...prev, key];
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selected.length === 0) {
|
||||
setError('至少选择一个生成步骤');
|
||||
return;
|
||||
}
|
||||
const ordered = MCP_OPTIONS.map((m) => m.key).filter((k) => selected.includes(k));
|
||||
onConfirm(ordered);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center px-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
role="presentation"
|
||||
/>
|
||||
<div
|
||||
className={`relative z-10 w-full max-w-xl rounded-2xl border shadow-2xl ${
|
||||
isDark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-200 bg-white text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center justify-between border-b px-5 py-4 ${
|
||||
isDark ? 'border-slate-700' : 'border-gray-100'
|
||||
}`}>
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold">选择本次执行的 MCP</div>
|
||||
<div className={`text-xs mt-0.5 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{expert ? `专家:${expert.name}` : '默认全部勾选,至少保留 1 个步骤'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
|
||||
isDark ? 'text-slate-400 hover:bg-slate-800' : 'text-gray-400 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto px-5 py-4 space-y-2">
|
||||
{MCP_OPTIONS.map((opt) => {
|
||||
const isSelected = selected.includes(opt.key);
|
||||
const palette = isDark ? ACCENT_DARK[opt.accent] : ACCENT_LIGHT[opt.accent];
|
||||
const Icon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
onClick={() => toggle(opt.key)}
|
||||
className={`w-full text-left rounded-xl border px-3 py-3 transition ${
|
||||
isSelected ? palette.selected : palette.idle
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`flex h-9 w-9 items-center justify-center rounded-xl shrink-0 ${palette.chip}`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold">{opt.name}</div>
|
||||
<div className={`text-xs mt-0.5 leading-relaxed ${isDark ? 'text-slate-400' : 'text-gray-600'}`}>
|
||||
{opt.description}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 mt-0.5 flex h-5 w-5 items-center justify-center rounded-md border transition ${
|
||||
isSelected
|
||||
? isDark
|
||||
? 'border-cyan-300 bg-cyan-400/40 text-cyan-50'
|
||||
: 'border-red-400 bg-red-500 text-white'
|
||||
: isDark
|
||||
? 'border-slate-600 text-transparent'
|
||||
: 'border-gray-300 text-transparent'
|
||||
}`}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<div className={`text-xs ${isDark ? 'text-rose-300' : 'text-rose-600'}`}>{error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center justify-between gap-3 border-t px-5 py-3 ${
|
||||
isDark ? 'border-slate-700' : 'border-gray-100'
|
||||
}`}>
|
||||
<div className={`text-[11px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
后端固定执行顺序:目的 → 行为体 → 预期行为和驱动因素 → 叙事
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={`rounded-lg border px-3 py-1.5 text-sm font-medium transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm font-semibold text-white transition ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400'
|
||||
: 'bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-400 hover:to-rose-400'
|
||||
}`}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default McpSelectModal;
|
||||
@@ -0,0 +1,360 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
Compass,
|
||||
FileSearch,
|
||||
Megaphone,
|
||||
ScrollText,
|
||||
Sparkles,
|
||||
Target,
|
||||
TrendingUp,
|
||||
UserCheck,
|
||||
Users,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import type {
|
||||
ActorCard,
|
||||
BehaviorDriverCard,
|
||||
NarrativeCard,
|
||||
PurposeCard,
|
||||
} from './types';
|
||||
|
||||
interface CollapsibleProps {
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
variant?: 'default' | 'subtle';
|
||||
}
|
||||
|
||||
export const InlineCollapsible: React.FC<CollapsibleProps> = ({ title, children, defaultOpen = true, variant = 'default' }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [open, setOpen] = React.useState(defaultOpen);
|
||||
|
||||
const wrapperClass = variant === 'subtle'
|
||||
? `${isDark ? 'border-slate-700/50 bg-slate-900/30' : 'border-gray-100 bg-white/80'}`
|
||||
: `${isDark ? 'border-slate-700/60 bg-slate-800/40' : 'border-red-100 bg-white'}`;
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border overflow-hidden ${wrapperClass}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`w-full flex items-center justify-between gap-3 px-4 py-3 text-left transition-colors ${
|
||||
isDark ? 'hover:bg-slate-800/70' : 'hover:bg-red-50/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">{title}</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 shrink-0 transition-transform duration-300 ${
|
||||
open ? 'rotate-0' : '-rotate-90'
|
||||
} ${isDark ? 'text-slate-400' : 'text-gray-500'}`}
|
||||
/>
|
||||
</button>
|
||||
<div className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${
|
||||
open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||
}`}>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-4 pb-4 pt-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CardIndexBadgeProps {
|
||||
index: number;
|
||||
variant: 'purpose' | 'actor' | 'behavior' | 'narrative';
|
||||
}
|
||||
|
||||
const VARIANT_COLOR: Record<CardIndexBadgeProps['variant'], { dark: string; light: string }> = {
|
||||
purpose: { dark: 'bg-violet-500/20 text-violet-200 border-violet-400/30', light: 'bg-orange-50 text-orange-600 border-orange-200' },
|
||||
actor: { dark: 'bg-cyan-500/20 text-cyan-200 border-cyan-400/30', light: 'bg-red-50 text-red-600 border-red-200' },
|
||||
behavior: { dark: 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30', light: 'bg-emerald-50 text-emerald-600 border-emerald-200' },
|
||||
narrative: { dark: 'bg-amber-500/20 text-amber-200 border-amber-400/30', light: 'bg-amber-50 text-amber-600 border-amber-200' },
|
||||
};
|
||||
|
||||
const CardIndexBadge: React.FC<CardIndexBadgeProps> = ({ index, variant }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const cls = VARIANT_COLOR[variant][isDark ? 'dark' : 'light'];
|
||||
return (
|
||||
<span className={`w-7 h-7 rounded-xl border flex items-center justify-center text-xs font-semibold shrink-0 ${cls}`}>
|
||||
{index.toString().padStart(2, '0')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const PurposeCardItem: React.FC<{ card: PurposeCard }> = ({ card }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<div className={`group rounded-2xl border p-3 transition-all sixstep-fade-up ${
|
||||
isDark
|
||||
? 'border-violet-400/20 bg-gradient-to-br from-slate-800/60 to-violet-900/20 hover:border-violet-300/40'
|
||||
: 'border-orange-100 bg-gradient-to-br from-white to-orange-50/40 hover:border-orange-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<CardIndexBadge index={card.index} variant="purpose" />
|
||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
|
||||
isDark ? 'bg-violet-500/20 text-violet-200' : 'bg-orange-50 text-orange-500'
|
||||
}`}>
|
||||
<Target className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{card.title}
|
||||
</div>
|
||||
<div className={`text-xs mt-1 leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
|
||||
<span className={`inline-flex items-center gap-1 mr-1 font-medium ${isDark ? 'text-violet-200' : 'text-orange-600'}`}>
|
||||
<Compass className="w-3 h-3" />理由
|
||||
</span>
|
||||
{card.reason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ActorCardItem: React.FC<{ card: ActorCard }> = ({ card }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<div className={`rounded-2xl border p-3 transition-all sixstep-fade-up ${
|
||||
isDark
|
||||
? 'border-cyan-400/20 bg-gradient-to-br from-slate-800/60 to-cyan-900/20 hover:border-cyan-300/40'
|
||||
: 'border-red-100 bg-gradient-to-br from-white to-red-50/40 hover:border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<CardIndexBadge index={card.index} variant="actor" />
|
||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
|
||||
isDark ? 'bg-cyan-500/20 text-cyan-200' : 'bg-red-50 text-red-500'
|
||||
}`}>
|
||||
<UserCheck className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{card.title}
|
||||
</div>
|
||||
<div className={`text-xs mt-1 leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
|
||||
{card.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BehaviorCardItem: React.FC<{ card: BehaviorDriverCard }> = ({ card }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<div className={`rounded-2xl border overflow-hidden transition-all sixstep-fade-up ${
|
||||
isDark
|
||||
? 'border-emerald-400/20 bg-gradient-to-br from-slate-800/60 to-emerald-900/20 hover:border-emerald-300/40'
|
||||
: 'border-emerald-100 bg-gradient-to-br from-white to-emerald-50/40 hover:border-emerald-200'
|
||||
}`}>
|
||||
<div className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<CardIndexBadge index={card.index} variant="behavior" />
|
||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
|
||||
isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-50 text-emerald-600'
|
||||
}`}>
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-[11px] uppercase tracking-wider font-medium mb-1 ${isDark ? 'text-emerald-300/80' : 'text-emerald-600'}`}>
|
||||
预期行为
|
||||
</div>
|
||||
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{card.expectedBehavior}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`border-t px-3 py-3 ${
|
||||
isDark ? 'border-emerald-400/15 bg-emerald-500/5' : 'border-emerald-100 bg-emerald-50/40'
|
||||
}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<Zap className={`w-3.5 h-3.5 mt-0.5 shrink-0 ${isDark ? 'text-emerald-300' : 'text-emerald-600'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-[11px] uppercase tracking-wider font-medium mb-1 ${isDark ? 'text-emerald-300/80' : 'text-emerald-600'}`}>
|
||||
驱动因素
|
||||
</div>
|
||||
<div className={`text-xs leading-relaxed ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
|
||||
{card.driver}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NarrativeCardItem: React.FC<{ card: NarrativeCard }> = ({ card }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<div className={`rounded-2xl border p-3 transition-all sixstep-fade-up ${
|
||||
isDark
|
||||
? 'border-amber-400/20 bg-gradient-to-br from-slate-800/60 to-amber-900/20 hover:border-amber-300/40'
|
||||
: 'border-amber-100 bg-gradient-to-br from-white to-amber-50/40 hover:border-amber-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<CardIndexBadge index={card.index} variant="narrative" />
|
||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
|
||||
isDark ? 'bg-amber-500/20 text-amber-200' : 'bg-amber-50 text-amber-600'
|
||||
}`}>
|
||||
<ScrollText className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{card.title}
|
||||
</div>
|
||||
<div className={`text-xs mt-1 leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
|
||||
<span className={`inline-flex items-center gap-1 mr-1 font-medium ${isDark ? 'text-amber-200' : 'text-amber-600'}`}>
|
||||
<Sparkles className="w-3 h-3" />理由
|
||||
</span>
|
||||
{card.reason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface GroupHeaderProps {
|
||||
label: string;
|
||||
parentTitle: string;
|
||||
count: number;
|
||||
variant: 'actor' | 'behavior' | 'narrative';
|
||||
}
|
||||
|
||||
export const ParentGroupHeader: React.FC<GroupHeaderProps> = ({ label, parentTitle, count, variant }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const Icon = variant === 'actor' ? Target : variant === 'behavior' ? Users : Zap;
|
||||
const accent = variant === 'actor'
|
||||
? (isDark ? 'text-violet-200 bg-violet-500/15 border-violet-400/30' : 'text-orange-600 bg-orange-50 border-orange-200')
|
||||
: variant === 'behavior'
|
||||
? (isDark ? 'text-cyan-200 bg-cyan-500/15 border-cyan-400/30' : 'text-red-600 bg-red-50 border-red-200')
|
||||
: (isDark ? 'text-emerald-200 bg-emerald-500/15 border-emerald-400/30' : 'text-emerald-600 bg-emerald-50 border-emerald-200');
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full border w-fit ${accent}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-medium">{label}</span>
|
||||
<span className={`text-xs font-semibold ${isDark ? 'text-white' : 'text-gray-900'} truncate max-w-[260px]`} title={parentTitle}>
|
||||
{parentTitle}
|
||||
</span>
|
||||
<span className={`text-[10px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>· {count} 张</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ThinkingBlockProps {
|
||||
text: string;
|
||||
loading?: boolean;
|
||||
empty?: string;
|
||||
}
|
||||
|
||||
export const ThinkingBlock: React.FC<ThinkingBlockProps> = ({ text, loading, empty = '正在生成思考内容...' }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<div className={`rounded-2xl border px-4 py-3 ${
|
||||
isDark ? 'bg-slate-900/30 border-slate-700/60' : 'bg-white border-gray-100'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileSearch className={`w-3.5 h-3.5 ${isDark ? 'text-cyan-300' : 'text-red-500'}`} />
|
||||
<span className={`text-[11px] font-medium uppercase tracking-wider ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
思考过程
|
||||
</span>
|
||||
{loading && (
|
||||
<span className={`text-[11px] ${isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}`}>
|
||||
生成中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{text ? (
|
||||
<pre className={`whitespace-pre-wrap text-xs leading-relaxed font-sans ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
|
||||
{text}
|
||||
</pre>
|
||||
) : (
|
||||
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{empty}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StageHeaderProps {
|
||||
title: string;
|
||||
desc?: string;
|
||||
count?: number;
|
||||
loading?: boolean;
|
||||
done?: boolean;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
accent: 'purpose' | 'actor' | 'behavior' | 'narrative';
|
||||
}
|
||||
|
||||
const ACCENT_BG: Record<StageHeaderProps['accent'], { dark: string; light: string }> = {
|
||||
purpose: { dark: 'bg-violet-500/15 text-violet-200', light: 'bg-orange-50 text-orange-500' },
|
||||
actor: { dark: 'bg-cyan-500/15 text-cyan-200', light: 'bg-red-50 text-red-500' },
|
||||
behavior: { dark: 'bg-emerald-500/15 text-emerald-200', light: 'bg-emerald-50 text-emerald-600' },
|
||||
narrative: { dark: 'bg-amber-500/15 text-amber-200', light: 'bg-amber-50 text-amber-600' },
|
||||
};
|
||||
|
||||
export const StageHeader: React.FC<StageHeaderProps> = ({ title, desc, count, loading, done, icon: Icon, accent }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const cls = ACCENT_BG[accent][isDark ? 'dark' : 'light'];
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${cls} ${loading ? 'sixstep-pulse' : ''}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'} flex items-center gap-2`}>
|
||||
<span className="truncate">{title}</span>
|
||||
{typeof count === 'number' && (
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-slate-700 text-slate-300' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{count} 张
|
||||
</span>
|
||||
)}
|
||||
{done && (
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-emerald-500/20 text-emerald-300' : 'bg-emerald-50 text-emerald-600'
|
||||
}`}>
|
||||
已完成
|
||||
</span>
|
||||
)}
|
||||
{loading && (
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-cyan-500/20 text-cyan-200' : 'bg-red-50 text-red-600'
|
||||
}`}>
|
||||
生成中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{desc && (
|
||||
<div className={`text-xs mt-0.5 ${
|
||||
loading ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-slate-400' : 'text-gray-500')
|
||||
}`}>
|
||||
{desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const STAGE_ICONS = {
|
||||
purpose: Target,
|
||||
actor: Users,
|
||||
behavior: Zap,
|
||||
narrative: Megaphone,
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Eraser, MessageSquareText, Pencil, Plus, Trash2, X } from 'lucide-react';
|
||||
|
||||
export interface SixStepConversationMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
isDark: boolean;
|
||||
conversations: SixStepConversationMeta[];
|
||||
activeId: string | null;
|
||||
onClose: () => void;
|
||||
onOpen: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
onNew: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
const UNTITLED = '未命名对话';
|
||||
|
||||
const SixStepChatHistory: React.FC<Props> = ({
|
||||
isOpen,
|
||||
isDark,
|
||||
conversations,
|
||||
activeId,
|
||||
onClose,
|
||||
onOpen,
|
||||
onSelect,
|
||||
onNew,
|
||||
onDelete,
|
||||
onRename,
|
||||
onClearAll,
|
||||
}) => {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [draftTitle, setDraftTitle] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingId && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [editingId]);
|
||||
|
||||
const startEdit = (conversation: SixStepConversationMeta) => {
|
||||
setEditingId(conversation.id);
|
||||
setDraftTitle(conversation.title);
|
||||
};
|
||||
|
||||
const commitEdit = () => {
|
||||
if (!editingId) return;
|
||||
onRename(editingId, draftTitle.trim() || UNTITLED);
|
||||
setEditingId(null);
|
||||
setDraftTitle('');
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setDraftTitle('');
|
||||
};
|
||||
|
||||
const handleDelete = (conversation: SixStepConversationMeta) => {
|
||||
const ok = window.confirm(`确认删除对话“${conversation.title || UNTITLED}”吗?`);
|
||||
if (!ok) return;
|
||||
onDelete(conversation.id);
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
if (conversations.length === 0) return;
|
||||
const ok = window.confirm('确认清空当前对话列表中的全部历史记录吗?此操作无法撤销。');
|
||||
if (!ok) return;
|
||||
onClearAll();
|
||||
};
|
||||
|
||||
const railButtonClass = (active = false) =>
|
||||
`flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border transition ${
|
||||
active
|
||||
? isDark
|
||||
? 'border-cyan-400/40 bg-cyan-500/10 text-cyan-200 shadow-sm'
|
||||
: 'border-red-200 bg-red-50/70 text-red-500 shadow-sm'
|
||||
: isDark
|
||||
? 'border-slate-700 bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-100'
|
||||
: 'border-gray-200 bg-white text-gray-500 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`;
|
||||
|
||||
const handleCollapsedSelect = (id: string) => {
|
||||
onOpen();
|
||||
window.setTimeout(() => {
|
||||
onSelect(id);
|
||||
}, 120);
|
||||
};
|
||||
|
||||
const handleCollapsedNew = () => {
|
||||
onOpen();
|
||||
window.setTimeout(() => {
|
||||
onNew();
|
||||
}, 120);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`flex h-full shrink-0 flex-col overflow-hidden border-r transition-[width,opacity] duration-300 ease-out ${
|
||||
isOpen ? 'pointer-events-auto w-[300px] opacity-100' : 'pointer-events-auto w-[72px] opacity-100'
|
||||
} ${
|
||||
isDark
|
||||
? 'border-slate-700/50 bg-slate-900/60 text-slate-100'
|
||||
: 'border-red-100 bg-gray-50/70 text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{isOpen ? (
|
||||
<>
|
||||
<div
|
||||
className={`border-b px-4 py-4 transition-all duration-300 ease-out ${isDark ? 'border-slate-700/50' : 'border-red-100'} ${
|
||||
isOpen ? 'translate-x-0 opacity-100' : '-translate-x-2 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">聊天记录</h3>
|
||||
<p className={`mt-1 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
查看和管理当前页面的历史对话
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title="收起聊天记录"
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNew}
|
||||
className={`inline-flex h-9 items-center justify-center gap-2 rounded-lg border text-sm font-medium transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
|
||||
: 'border-red-200 bg-white text-red-600 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建对话
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearAll}
|
||||
disabled={conversations.length === 0}
|
||||
className={`inline-flex h-9 items-center justify-center gap-2 rounded-lg border text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Eraser className="h-4 w-4" />
|
||||
清空记录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`min-h-0 flex-1 space-y-1 overflow-y-auto px-3 py-3 transition-all duration-300 ease-out ${
|
||||
isOpen ? 'translate-x-0 opacity-100' : '-translate-x-2 opacity-0'
|
||||
}`}
|
||||
>
|
||||
{conversations.length === 0 ? (
|
||||
<div
|
||||
className={`rounded-xl border border-dashed px-3 py-8 text-center text-xs ${
|
||||
isDark ? 'border-slate-700 text-slate-500' : 'border-gray-200 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
还没有任何对话记录
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conversation) => {
|
||||
const isActive = activeId === conversation.id;
|
||||
const isEditing = editingId === conversation.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={conversation.id}
|
||||
role={isEditing ? undefined : 'button'}
|
||||
tabIndex={isEditing ? -1 : 0}
|
||||
onClick={() => !isEditing && onSelect(conversation.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (!isEditing && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
onSelect(conversation.id);
|
||||
}
|
||||
}}
|
||||
className={`group flex cursor-pointer items-center gap-2 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 ${
|
||||
isActive
|
||||
? isDark
|
||||
? 'border-cyan-400/40 bg-cyan-500/10 text-slate-50 shadow-sm'
|
||||
: 'border-red-200 bg-red-50/70 text-gray-900 shadow-sm'
|
||||
: isDark
|
||||
? 'border-transparent text-slate-300 hover:border-slate-700 hover:bg-slate-800/60 hover:text-slate-100'
|
||||
: 'border-transparent text-gray-700 hover:border-gray-200 hover:bg-white hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
|
||||
isActive
|
||||
? isDark
|
||||
? 'bg-cyan-500/20 text-cyan-200'
|
||||
: 'bg-white text-red-500 ring-1 ring-red-100'
|
||||
: isDark
|
||||
? 'bg-slate-800 text-slate-400'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<MessageSquareText className="h-4 w-4" />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draftTitle}
|
||||
onChange={(event) => setDraftTitle(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commitEdit();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
}}
|
||||
onBlur={commitEdit}
|
||||
className={`w-full border-b bg-transparent text-sm font-medium outline-none ${
|
||||
isDark ? 'border-cyan-400/60 text-white' : 'border-red-300 text-gray-900'
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="truncate text-sm font-medium">{conversation.title || UNTITLED}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
commitEdit();
|
||||
}}
|
||||
title="保存"
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
|
||||
isDark
|
||||
? 'text-emerald-300 hover:bg-emerald-500/15'
|
||||
: 'text-emerald-600 hover:bg-emerald-50'
|
||||
}`}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
startEdit(conversation);
|
||||
}}
|
||||
title="重命名"
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
|
||||
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
} ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleDelete(conversation);
|
||||
}}
|
||||
title="删除对话"
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
|
||||
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
} ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-rose-500/15 hover:text-rose-300'
|
||||
: 'text-gray-400 hover:bg-rose-50 hover:text-rose-500'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center gap-3 px-2 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCollapsedNew}
|
||||
title="新建对话"
|
||||
className={`${railButtonClass(false)} sixstep-fade-up`}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="min-h-0 w-full flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{conversations.map((conversation, index) => (
|
||||
<button
|
||||
key={conversation.id}
|
||||
type="button"
|
||||
onClick={() => handleCollapsedSelect(conversation.id)}
|
||||
title={conversation.title || UNTITLED}
|
||||
className={`${railButtonClass(activeId === conversation.id)} transition-all duration-300 ease-out`}
|
||||
style={{ transitionDelay: `${Math.min(index, 6) * 25}ms` }}
|
||||
>
|
||||
<MessageSquareText className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default SixStepChatHistory;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,660 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { Bot, CheckCircle2, Database, FileCheck2, Sparkles, UserRound } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import ExpertMatchCards from './ExpertMatchCards';
|
||||
import StreamErrorNotice from './StreamErrorNotice';
|
||||
import {
|
||||
ActorCardItem,
|
||||
BehaviorCardItem,
|
||||
InlineCollapsible,
|
||||
NarrativeCardItem,
|
||||
ParentGroupHeader,
|
||||
PurposeCardItem,
|
||||
STAGE_ICONS,
|
||||
StageHeader,
|
||||
ThinkingBlock,
|
||||
} from './SixStepCards';
|
||||
import type {
|
||||
ActorCard,
|
||||
BehaviorDriverCard,
|
||||
InfoStageData,
|
||||
NarrativeCard,
|
||||
PurposeCard,
|
||||
SixStepMessage,
|
||||
} from './types';
|
||||
|
||||
export interface ExpertMatchHandlers {
|
||||
onSelectExpert: (messageId: string, expertId: string) => void;
|
||||
onRefetchExpert: (messageId: string) => void;
|
||||
onCreateExpert: (messageId: string) => void;
|
||||
onConfirmExpert: (messageId: string) => void;
|
||||
}
|
||||
|
||||
interface SixStepConversationProps {
|
||||
messages: SixStepMessage[];
|
||||
isGenerating: boolean;
|
||||
activeMessageId?: string | null;
|
||||
expertHandlers?: ExpertMatchHandlers;
|
||||
}
|
||||
|
||||
interface AvatarProps {
|
||||
role: 'user' | 'assistant';
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const Avatar: React.FC<AvatarProps> = ({ role, active }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const Icon = role === 'assistant' ? Bot : UserRound;
|
||||
return (
|
||||
<div className={`relative w-9 h-9 rounded-2xl flex items-center justify-center shrink-0 shadow-sm overflow-hidden ${
|
||||
role === 'assistant'
|
||||
? isDark
|
||||
? 'bg-gradient-to-br from-cyan-500 via-blue-600 to-violet-600 text-white'
|
||||
: 'bg-gradient-to-br from-red-400 via-rose-500 to-orange-400 text-white'
|
||||
: isDark
|
||||
? 'bg-gradient-to-br from-slate-500 via-slate-700 to-slate-900 text-white'
|
||||
: 'bg-gradient-to-br from-sky-400 via-indigo-500 to-violet-500 text-white'
|
||||
} ${active ? 'sixstep-pulse' : ''}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
{role === 'assistant' && active && (
|
||||
<Sparkles className="absolute -right-0.5 -top-0.5 w-3.5 h-3.5 text-yellow-200" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InfoStage: React.FC<{ data: InfoStageData; running: boolean }> = ({ data, running }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<InlineCollapsible
|
||||
defaultOpen={false}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center shrink-0 ${
|
||||
isDark
|
||||
? `bg-blue-500/15 text-blue-200 ${running ? 'sixstep-pulse' : ''}`
|
||||
: `bg-blue-50 text-blue-500 ${running ? 'sixstep-pulse' : ''}`
|
||||
}`}>
|
||||
<Database className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
<span>信息准备</span>
|
||||
{data.complete && (
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-emerald-500/20 text-emerald-300' : 'bg-emerald-50 text-emerald-600'
|
||||
}`}>已完成</span>
|
||||
)}
|
||||
{running && (
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-blue-500/20 text-blue-200' : 'bg-blue-50 text-blue-600'
|
||||
}`}>收集中</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-xs mt-0.5 ${
|
||||
running
|
||||
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
|
||||
: isDark ? 'text-slate-400' : 'text-gray-500'
|
||||
}`}>
|
||||
{running ? '正在收集热搜与上下文信息...' : '已整合信息准备素材'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{data.content ? (
|
||||
<div className={`prose prose-sm max-w-none text-sm leading-relaxed ${
|
||||
isDark ? 'prose-invert text-slate-300' : 'text-gray-700'
|
||||
}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
{running ? '正在获取信息...' : '无内容'}
|
||||
</div>
|
||||
)}
|
||||
</InlineCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
const MarkdownMessage: React.FC<{ content: string; isDark: boolean }> = ({ content, isDark }) => (
|
||||
<div className={`rounded-2xl border px-5 py-4 ${
|
||||
isDark
|
||||
? 'border-slate-700/50 bg-slate-800/30'
|
||||
: 'border-gray-100 bg-white'
|
||||
}`}>
|
||||
<div className={`prose prose-sm max-w-none leading-relaxed ${isDark ? 'prose-invert' : ''}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PurposeStage: React.FC<{ data: NonNullable<SixStepMessage['stages']>['purpose']; running: boolean }> = ({ data, running }) => {
|
||||
if (!data) return null;
|
||||
const Icon = STAGE_ICONS.purpose;
|
||||
const cards = data.cards ?? [];
|
||||
return (
|
||||
<InlineCollapsible
|
||||
defaultOpen
|
||||
title={
|
||||
<StageHeader
|
||||
title="目的生成"
|
||||
desc={running ? '正在推演核心目的...' : '已识别核心目的方向'}
|
||||
count={cards.length}
|
||||
loading={running}
|
||||
done={data.complete}
|
||||
icon={Icon}
|
||||
accent="purpose"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
|
||||
<InlineCollapsible
|
||||
variant="subtle"
|
||||
defaultOpen
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">目的卡片列表</span>
|
||||
<span className="text-[10px] opacity-70">{cards.length} 张</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{cards.length === 0 ? (
|
||||
<div className="text-xs opacity-60">等待生成目的卡片...</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{cards.map((card) => (
|
||||
<PurposeCardItem key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</InlineCollapsible>
|
||||
</div>
|
||||
</InlineCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
const ActorStage: React.FC<{
|
||||
data: NonNullable<SixStepMessage['stages']>['actor'];
|
||||
purposes: PurposeCard[];
|
||||
running: boolean;
|
||||
}> = ({ data, purposes, running }) => {
|
||||
if (!data) return null;
|
||||
const Icon = STAGE_ICONS.actor;
|
||||
const cards = data.cards ?? [];
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, ActorCard[]>();
|
||||
cards.forEach((card) => {
|
||||
const list = map.get(card.purposeId) ?? [];
|
||||
list.push(card);
|
||||
map.set(card.purposeId, list);
|
||||
});
|
||||
return purposes
|
||||
.map((purpose) => ({ purpose, items: map.get(purpose.id) ?? [] }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
}, [cards, purposes]);
|
||||
|
||||
return (
|
||||
<InlineCollapsible
|
||||
defaultOpen
|
||||
title={
|
||||
<StageHeader
|
||||
title="行为体生成"
|
||||
desc={running ? '正在匹配行为体角色...' : '已生成行为体清单'}
|
||||
count={cards.length}
|
||||
loading={running}
|
||||
done={data.complete}
|
||||
icon={Icon}
|
||||
accent="actor"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
|
||||
<InlineCollapsible
|
||||
variant="subtle"
|
||||
defaultOpen
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">行为体卡片列表</span>
|
||||
<span className="text-[10px] opacity-70">{cards.length} 张</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{groups.length === 0 ? (
|
||||
<div className="text-xs opacity-60">等待生成行为体卡片...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.purpose.id} className="space-y-2 sixstep-fade-up">
|
||||
<ParentGroupHeader label="所属目的" parentTitle={group.purpose.title} count={group.items.length} variant="actor" />
|
||||
<div className="space-y-2 pl-2 border-l-2 border-dashed border-slate-400/20">
|
||||
{group.items.map((card) => (
|
||||
<ActorCardItem key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</InlineCollapsible>
|
||||
</div>
|
||||
</InlineCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
const BehaviorStage: React.FC<{
|
||||
data: NonNullable<SixStepMessage['stages']>['behavior'];
|
||||
actors: ActorCard[];
|
||||
running: boolean;
|
||||
}> = ({ data, actors, running }) => {
|
||||
if (!data) return null;
|
||||
const Icon = STAGE_ICONS.behavior;
|
||||
const cards = data.cards ?? [];
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, BehaviorDriverCard[]>();
|
||||
cards.forEach((card) => {
|
||||
const list = map.get(card.actorId) ?? [];
|
||||
list.push(card);
|
||||
map.set(card.actorId, list);
|
||||
});
|
||||
return actors
|
||||
.map((actor) => ({ actor, items: map.get(actor.id) ?? [] }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
}, [cards, actors]);
|
||||
|
||||
return (
|
||||
<InlineCollapsible
|
||||
defaultOpen
|
||||
title={
|
||||
<StageHeader
|
||||
title="预期行为与驱动因素生成"
|
||||
desc={running ? '正在推演行为及驱动因素...' : '已生成预期行为与驱动因素'}
|
||||
count={cards.length}
|
||||
loading={running}
|
||||
done={data.complete}
|
||||
icon={Icon}
|
||||
accent="behavior"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
|
||||
<InlineCollapsible
|
||||
variant="subtle"
|
||||
defaultOpen
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">预期行为与驱动因素卡片列表</span>
|
||||
<span className="text-[10px] opacity-70">{cards.length} 张</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{groups.length === 0 ? (
|
||||
<div className="text-xs opacity-60">等待生成预期行为与驱动因素卡片...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.actor.id} className="space-y-2 sixstep-fade-up">
|
||||
<ParentGroupHeader label="所属行为体" parentTitle={group.actor.title} count={group.items.length} variant="behavior" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2 pl-2 border-l-2 border-dashed border-slate-400/20">
|
||||
{group.items.map((card) => (
|
||||
<BehaviorCardItem key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</InlineCollapsible>
|
||||
</div>
|
||||
</InlineCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
const NarrativeStage: React.FC<{
|
||||
data: NonNullable<SixStepMessage['stages']>['narrative'];
|
||||
behaviors: BehaviorDriverCard[];
|
||||
running: boolean;
|
||||
}> = ({ data, behaviors, running }) => {
|
||||
if (!data) return null;
|
||||
const Icon = STAGE_ICONS.narrative;
|
||||
const cards = data.cards ?? [];
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, NarrativeCard[]>();
|
||||
cards.forEach((card) => {
|
||||
const list = map.get(card.driverId) ?? [];
|
||||
list.push(card);
|
||||
map.set(card.driverId, list);
|
||||
});
|
||||
return behaviors
|
||||
.map((behavior) => ({ behavior, items: map.get(behavior.id) ?? [] }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
}, [cards, behaviors]);
|
||||
|
||||
return (
|
||||
<InlineCollapsible
|
||||
defaultOpen
|
||||
title={
|
||||
<StageHeader
|
||||
title="叙事生成"
|
||||
desc={running ? '正在构建叙事框架...' : '已生成叙事卡片'}
|
||||
count={cards.length}
|
||||
loading={running}
|
||||
done={data.complete}
|
||||
icon={Icon}
|
||||
accent="narrative"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
|
||||
<InlineCollapsible
|
||||
variant="subtle"
|
||||
defaultOpen
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">叙事卡片列表</span>
|
||||
<span className="text-[10px] opacity-70">{cards.length} 张</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{groups.length === 0 ? (
|
||||
<div className="text-xs opacity-60">等待生成叙事卡片...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.behavior.id} className="space-y-2 sixstep-fade-up">
|
||||
<ParentGroupHeader
|
||||
label="所属驱动因素"
|
||||
parentTitle={group.behavior.driver}
|
||||
count={group.items.length}
|
||||
variant="narrative"
|
||||
/>
|
||||
<div className="space-y-2 pl-2 border-l-2 border-dashed border-slate-400/20">
|
||||
{group.items.map((card) => (
|
||||
<NarrativeCardItem key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</InlineCollapsible>
|
||||
</div>
|
||||
</InlineCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
const FinalAnswer: React.FC<{ text: string; running: boolean }> = ({ text, running }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
if (!text && !running) return null;
|
||||
return (
|
||||
<div className={`rounded-2xl border p-4 ${
|
||||
isDark
|
||||
? 'border-slate-600/40 bg-slate-700/40 shadow-lg shadow-black/10'
|
||||
: 'border-red-100 bg-white shadow-sm'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileCheck2 className={`w-4 h-4 ${isDark ? 'text-emerald-300' : 'text-emerald-600'}`} />
|
||||
<span className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>最终结果</span>
|
||||
{running && (
|
||||
<span className={`text-[11px] ${isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}`}>
|
||||
正在汇总输出
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className={`whitespace-pre-wrap text-sm leading-relaxed font-sans ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
|
||||
{text || '准备汇总输出...'}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectedSummaryPill: React.FC<{ message: SixStepMessage }> = ({ message }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
if (!message.selectedExpert) return null;
|
||||
const mcps = message.selectedMcps ?? [];
|
||||
const labelMap: Record<string, string> = {
|
||||
purpose: '目的',
|
||||
actor: '行为体',
|
||||
behavior_factor: '预期行为和驱动因素',
|
||||
narrative: '叙事',
|
||||
};
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-2 rounded-2xl border px-3 py-2 ${
|
||||
isDark ? 'border-cyan-400/30 bg-cyan-500/10 text-cyan-100' : 'border-red-200 bg-red-50/70 text-red-700'
|
||||
}`}>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-xs font-semibold">已选专家</span>
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-slate-900/40 text-cyan-100' : 'bg-white text-red-600 shadow-sm'
|
||||
}`}>
|
||||
{message.selectedExpert.name}
|
||||
</span>
|
||||
{mcps.length > 0 && (
|
||||
<>
|
||||
<span className={`text-[11px] ${isDark ? 'text-slate-300' : 'text-gray-500'}`}>·</span>
|
||||
<span className={`text-[11px] ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
|
||||
执行步骤:{mcps.map((k) => labelMap[k] || k).join(' → ')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AssistantMessageProps {
|
||||
message: SixStepMessage;
|
||||
active: boolean;
|
||||
expertHandlers?: ExpertMatchHandlers;
|
||||
}
|
||||
|
||||
const AssistantMessage: React.FC<AssistantMessageProps> = ({ message, active, expertHandlers }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const isMarkdown = message.outputFormat === 'markdown';
|
||||
const isFollowUp = message.phase === 'follow-up';
|
||||
const stages = message.stages ?? {};
|
||||
const purposes = stages.purpose?.cards ?? [];
|
||||
const actors = stages.actor?.cards ?? [];
|
||||
const behaviors = stages.behavior?.cards ?? [];
|
||||
|
||||
const isInfoRunning = message.mainStepStatus?.info === 'running';
|
||||
const isPurposeRunning = message.subStepStatus?.purpose === 'running';
|
||||
const isActorRunning = message.subStepStatus?.actor === 'running';
|
||||
const isBehaviorRunning = message.subStepStatus?.behavior === 'running';
|
||||
const isNarrativeRunning = message.subStepStatus?.narrative === 'running';
|
||||
const isFinalRunning = message.mainStepStatus?.final === 'running';
|
||||
const generating =
|
||||
isInfoRunning ||
|
||||
message.mainStepStatus?.sixstep === 'running' ||
|
||||
isFinalRunning;
|
||||
// follow-up 在等待第一个 delta 时显示加载态
|
||||
const followUpGenerating = isFollowUp && active && !message.markdownContent;
|
||||
|
||||
const noStageYet = !stages.info && !stages.purpose && !stages.actor && !stages.behavior && !stages.narrative;
|
||||
|
||||
const expertMatch = message.expertMatch;
|
||||
const inExpertPhase = message.phase === 'expert-match' && !!expertMatch;
|
||||
const expertConfirmed = expertMatch?.status === 'confirmed' || !!message.selectedExpert;
|
||||
const expertActive = active && inExpertPhase && !expertConfirmed;
|
||||
|
||||
// Only render info/stages/finalAnswer when we're in an analysis phase.
|
||||
// Expert-match, clarifying, and follow-up phases show their own dedicated UI instead.
|
||||
const showAnalysisContent =
|
||||
!inExpertPhase &&
|
||||
message.phase !== 'clarifying' &&
|
||||
message.phase !== 'follow-up';
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<Avatar role="assistant" active={generating || expertActive || followUpGenerating} />
|
||||
<div className={`flex-1 min-w-0 max-w-full rounded-2xl px-4 py-3 border ${
|
||||
isDark
|
||||
? 'bg-slate-800/40 border-slate-700/50'
|
||||
: 'bg-transparent border-transparent shadow-none px-0 py-0'
|
||||
}`}>
|
||||
<div className="space-y-3">
|
||||
{/* Expert match phase: show expert cards before stages */}
|
||||
{inExpertPhase && expertMatch && (
|
||||
<ExpertMatchCards
|
||||
data={expertMatch}
|
||||
locked={expertConfirmed || !active}
|
||||
onSelectExpert={(id) => expertHandlers?.onSelectExpert(message.id, id)}
|
||||
onRefetch={() => expertHandlers?.onRefetchExpert(message.id)}
|
||||
onCreateExpert={() => expertHandlers?.onCreateExpert(message.id)}
|
||||
onConfirm={() => expertHandlers?.onConfirmExpert(message.id)}
|
||||
/>
|
||||
)}
|
||||
{inExpertPhase && (message.errors?.length ?? 0) > 0 && (
|
||||
<StreamErrorNotice errors={message.errors!} />
|
||||
)}
|
||||
|
||||
{/* Selected expert / MCP summary once moved to generate phase */}
|
||||
{message.selectedExpert && message.phase !== 'expert-match' && (
|
||||
<SelectedSummaryPill message={message} />
|
||||
)}
|
||||
|
||||
{showAnalysisContent && (
|
||||
<>
|
||||
{/* Info preparing placeholder when no stage data yet */}
|
||||
{isInfoRunning && noStageYet && (
|
||||
<div className={`rounded-2xl border px-4 py-3 ${
|
||||
isDark ? 'border-slate-700/60 bg-slate-900/40 text-slate-300' : 'border-blue-100 bg-white text-gray-600'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Database className={`w-4 h-4 ${isDark ? 'text-blue-300' : 'text-blue-500'}`} />
|
||||
<span className={isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}>
|
||||
正在调用信息准备,收集热搜与上下文...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info prepare result (collapsible) */}
|
||||
{stages.info && <InfoStage data={stages.info} running={isInfoRunning} />}
|
||||
|
||||
{(isMarkdown || isFollowUp) ? (
|
||||
/* ---- Markdown / Follow-up mode ---- */
|
||||
<>
|
||||
{(message.markdownContent || generating || followUpGenerating) && (
|
||||
message.markdownContent ? (
|
||||
<MarkdownMessage content={message.markdownContent} isDark={isDark} />
|
||||
) : (
|
||||
<div className={`rounded-2xl border px-4 py-3 ${
|
||||
isDark ? 'border-slate-700/60 bg-slate-900/40' : 'border-gray-100 bg-white'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Sparkles className={`w-4 h-4 ${isDark ? 'text-cyan-300' : 'text-red-500'}`} />
|
||||
<span className={isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}>
|
||||
{isFollowUp ? '正在回答你的问题...' : '正在生成推演内容...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{(message.errors?.length ?? 0) > 0 && (
|
||||
<StreamErrorNotice errors={message.errors!} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* ---- Structured mode ---- */
|
||||
<>
|
||||
{stages.purpose && <PurposeStage data={stages.purpose} running={isPurposeRunning} />}
|
||||
{stages.actor && <ActorStage data={stages.actor} purposes={purposes} running={isActorRunning} />}
|
||||
{stages.behavior && <BehaviorStage data={stages.behavior} actors={actors} running={isBehaviorRunning} />}
|
||||
{stages.narrative && (
|
||||
<NarrativeStage data={stages.narrative} behaviors={behaviors} running={isNarrativeRunning} />
|
||||
)}
|
||||
{(message.finalAnswer || isFinalRunning) && (
|
||||
<FinalAnswer text={message.finalAnswer ?? ''} running={isFinalRunning} />
|
||||
)}
|
||||
{(message.errors?.length ?? 0) > 0 && (
|
||||
<StreamErrorNotice errors={message.errors!} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UserMessage: React.FC<{ message: SixStepMessage }> = ({ message }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<div className="flex gap-3 justify-end">
|
||||
<div className={`max-w-[72%] rounded-2xl px-4 py-3 shadow-sm ${
|
||||
isDark ? 'bg-blue-600/70 text-white' : 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
|
||||
}`}>
|
||||
<p className="whitespace-pre-wrap leading-relaxed text-sm">{message.content}</p>
|
||||
</div>
|
||||
<Avatar role="user" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SixStepConversation: React.FC<SixStepConversationProps> = ({ messages, isGenerating, activeMessageId, expertHandlers }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [messages, isGenerating]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className={`h-full flex flex-col items-center justify-center px-6 text-center ${
|
||||
isDark ? 'text-slate-400' : 'text-gray-500'
|
||||
}`}>
|
||||
<div className={`w-20 h-20 rounded-3xl mb-5 flex items-center justify-center ${
|
||||
isDark ? 'bg-slate-700/40 text-cyan-300' : 'bg-gradient-to-br from-red-50 to-orange-50 text-red-500'
|
||||
}`}>
|
||||
<Sparkles className="w-10 h-10 opacity-80" />
|
||||
</div>
|
||||
<p className={`text-lg font-medium ${isDark ? 'text-white' : 'text-gray-800'}`}>开始 6 步法推演</p>
|
||||
<p className="text-sm mt-2 max-w-md">
|
||||
输入问题或场景描述,模型会按照"信息准备 → 6 步法推演 → 最终结果"的流程逐步生成结构化输出。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="h-full overflow-y-auto px-5 py-5 space-y-5">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id}>
|
||||
{msg.role === 'user' ? (
|
||||
<UserMessage message={msg} />
|
||||
) : (
|
||||
<AssistantMessage
|
||||
message={msg}
|
||||
active={msg.id === activeMessageId}
|
||||
expertHandlers={expertHandlers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SixStepConversation;
|
||||
@@ -0,0 +1,653 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
Compass,
|
||||
Crosshair,
|
||||
Download,
|
||||
Megaphone,
|
||||
Minus,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
ScrollText,
|
||||
Target,
|
||||
TrendingUp,
|
||||
UserCheck,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import type { ExpertCard, SixStepMessage } from './types';
|
||||
import {
|
||||
CURRENT_PAIR_BY_STAGE,
|
||||
EDGE_COLOR,
|
||||
EDGE_LABEL,
|
||||
STATUS_TITLE,
|
||||
VARIANT_LABEL,
|
||||
buildEdgePath,
|
||||
buildLayout,
|
||||
type LayoutEdge,
|
||||
type LayoutNode,
|
||||
} from './flowLayout';
|
||||
|
||||
interface SixStepFlowFullscreenProps {
|
||||
message?: SixStepMessage;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SCALE_MIN = 0.2;
|
||||
const SCALE_MAX = 3;
|
||||
|
||||
const buildExpertRootDetail = (expert: ExpertCard): string => {
|
||||
const segments = [expert.title, expert.detail].filter((item): item is string => Boolean(item && item.trim()));
|
||||
return segments.join(' · ') || '已确认专家';
|
||||
};
|
||||
|
||||
const VARIANT_TOKEN: Record<LayoutNode['variant'], {
|
||||
dark: string; light: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
iconCls: { dark: string; light: string };
|
||||
}> = {
|
||||
agent: { dark: 'from-slate-700/80 to-slate-900/80 border-slate-500/60', light: 'from-white to-slate-50 border-slate-300', icon: Bot, iconCls: { dark: 'bg-slate-500/30 text-slate-200', light: 'bg-slate-100 text-slate-600' } },
|
||||
purpose: { dark: 'from-violet-700/40 to-violet-900/40 border-violet-400/50', light: 'from-orange-50 to-white border-orange-300', icon: Target, iconCls: { dark: 'bg-violet-500/30 text-violet-200', light: 'bg-orange-100 text-orange-600' } },
|
||||
actor: { dark: 'from-cyan-700/40 to-cyan-900/40 border-cyan-400/50', light: 'from-red-50 to-white border-red-300', icon: UserCheck, iconCls: { dark: 'bg-cyan-500/30 text-cyan-200', light: 'bg-red-100 text-red-600' } },
|
||||
behavior: { dark: 'from-emerald-700/40 to-emerald-900/40 border-emerald-400/50', light: 'from-emerald-50 to-white border-emerald-300', icon: TrendingUp,iconCls: { dark: 'bg-emerald-500/30 text-emerald-200', light: 'bg-emerald-100 text-emerald-700' } },
|
||||
driver: { dark: 'from-emerald-800/40 to-teal-900/40 border-teal-400/50', light: 'from-teal-50 to-white border-teal-300', icon: Zap, iconCls: { dark: 'bg-teal-500/30 text-teal-200', light: 'bg-teal-100 text-teal-700' } },
|
||||
narrative: { dark: 'from-amber-700/40 to-amber-900/40 border-amber-400/50', light: 'from-amber-50 to-white border-amber-300', icon: ScrollText,iconCls: { dark: 'bg-amber-500/30 text-amber-200', light: 'bg-amber-100 text-amber-600' } },
|
||||
};
|
||||
|
||||
const MINIMAP_VARIANT_FILL: Record<LayoutNode['variant'], { dark: string; light: string }> = {
|
||||
agent: { dark: '#475569', light: '#cbd5e1' },
|
||||
purpose: { dark: '#7c3aed', light: '#fb923c' },
|
||||
actor: { dark: '#06b6d4', light: '#ef4444' },
|
||||
behavior: { dark: '#10b981', light: '#10b981' },
|
||||
driver: { dark: '#0d9488', light: '#0d9488' },
|
||||
narrative: { dark: '#f59e0b', light: '#f59e0b' },
|
||||
};
|
||||
|
||||
const DetailNode: React.FC<{
|
||||
node: LayoutNode;
|
||||
isDark: boolean;
|
||||
collapsible: boolean;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse?: (nodeId: string) => void;
|
||||
entering: boolean;
|
||||
exiting: boolean;
|
||||
}> = ({ node, isDark, collapsible, collapsed, onToggleCollapse, entering, exiting }) => {
|
||||
const token = VARIANT_TOKEN[node.variant];
|
||||
const Icon = token.icon;
|
||||
const grad = isDark ? token.dark : token.light;
|
||||
const canToggle = collapsible && Boolean(onToggleCollapse);
|
||||
const handleClick = (e: React.MouseEvent<SVGGElement>) => {
|
||||
if (!canToggle) return;
|
||||
e.stopPropagation();
|
||||
onToggleCollapse?.(node.id);
|
||||
};
|
||||
const handleMouseDown = (e: React.MouseEvent<SVGGElement>) => {
|
||||
if (canToggle) e.stopPropagation();
|
||||
};
|
||||
return (
|
||||
<g
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
cursor: canToggle ? 'pointer' : undefined,
|
||||
opacity: exiting ? 0 : 1,
|
||||
transform: entering || exiting ? 'translateY(8px)' : 'translateY(0)',
|
||||
transformOrigin: `${node.x + node.width / 2}px ${node.y + node.height / 2}px`,
|
||||
transition: `opacity ${COLLAPSE_TRANSITION_MS}ms ease, transform ${COLLAPSE_TRANSITION_MS}ms ease`,
|
||||
}}
|
||||
>
|
||||
<foreignObject x={node.x} y={node.y} width={node.width} height={node.height}>
|
||||
<div className={`relative w-full h-full rounded-2xl border bg-gradient-to-br ${grad} backdrop-blur-sm p-2.5 shadow-sm overflow-hidden`}>
|
||||
{collapsible && (
|
||||
<div
|
||||
className={`absolute right-2 top-2 w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-bold ${
|
||||
isDark ? 'bg-slate-800/75 text-slate-200' : 'bg-white text-slate-600 border border-slate-200'
|
||||
}`}
|
||||
title={collapsed ? '展开子节点' : '收起子节点'}
|
||||
>
|
||||
{collapsed ? '+' : '-'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<div className={`w-5 h-5 rounded-md flex items-center justify-center ${token.iconCls[isDark ? 'dark' : 'light']}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
</div>
|
||||
<span className={`text-[10px] uppercase tracking-wider font-semibold ${isDark ? 'text-white/70' : 'text-slate-500'}`}>
|
||||
{VARIANT_LABEL[node.variant]}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-xs font-semibold leading-tight line-clamp-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{node.title}
|
||||
</div>
|
||||
<div className={`text-[11px] leading-snug line-clamp-2 mt-0.5 ${isDark ? 'text-slate-300' : 'text-slate-600'}`}>
|
||||
{node.detail || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolbarButton: React.FC<{ title: string; onClick: () => void; isDark: boolean; children: React.ReactNode }> = ({ title, onClick, isDark, children }) => (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className={`sixstep-toolbar-btn ${
|
||||
isDark ? 'text-slate-300 hover:bg-slate-700/70 hover:text-white' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
interface MinimapProps {
|
||||
layout: { nodes: LayoutNode[]; width: number; height: number };
|
||||
transform: { tx: number; ty: number; scale: number };
|
||||
viewportSize: { width: number; height: number };
|
||||
onJump: (worldX: number, worldY: number) => void;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const MINIMAP_W = 220;
|
||||
const MINIMAP_H = 150;
|
||||
const COLLAPSE_TRANSITION_MS = 240;
|
||||
|
||||
const Minimap: React.FC<MinimapProps> = ({ layout, transform, viewportSize, onJump, isDark }) => {
|
||||
const ratio = layout.width > 0 && layout.height > 0
|
||||
? Math.min((MINIMAP_W - 10) / layout.width, (MINIMAP_H - 10) / layout.height)
|
||||
: 0.1;
|
||||
const renderW = layout.width * ratio;
|
||||
const renderH = layout.height * ratio;
|
||||
const offsetX = (MINIMAP_W - renderW) / 2;
|
||||
const offsetY = (MINIMAP_H - renderH) / 2;
|
||||
|
||||
// Visible area (in world coords): from world (-tx/scale, -ty/scale) with size viewportW/scale × viewportH/scale
|
||||
const viewW = viewportSize.width / transform.scale;
|
||||
const viewH = viewportSize.height / transform.scale;
|
||||
const viewX = -transform.tx / transform.scale;
|
||||
const viewY = -transform.ty / transform.scale;
|
||||
|
||||
const handleClick = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left - offsetX;
|
||||
const my = e.clientY - rect.top - offsetY;
|
||||
const wx = mx / ratio;
|
||||
const wy = my / ratio;
|
||||
onJump(wx, wy);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl overflow-hidden border shadow-lg backdrop-blur-md ${
|
||||
isDark ? 'bg-slate-900/80 border-slate-700/70' : 'bg-white/90 border-gray-200'
|
||||
}`}>
|
||||
<div className={`px-3 py-1.5 text-[10px] uppercase tracking-wider border-b font-medium ${
|
||||
isDark ? 'text-slate-400 border-slate-700/50' : 'text-gray-500 border-gray-100'
|
||||
}`}>
|
||||
缩略图
|
||||
</div>
|
||||
<svg
|
||||
width={MINIMAP_W}
|
||||
height={MINIMAP_H}
|
||||
onClick={handleClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<rect width={MINIMAP_W} height={MINIMAP_H} fill={isDark ? '#0f172a' : '#f8fafc'} />
|
||||
<g transform={`translate(${offsetX}, ${offsetY}) scale(${ratio})`}>
|
||||
{layout.nodes.map((n) => (
|
||||
<rect
|
||||
key={n.id}
|
||||
x={n.x}
|
||||
y={n.y}
|
||||
width={n.width}
|
||||
height={n.height}
|
||||
rx={6}
|
||||
fill={MINIMAP_VARIANT_FILL[n.variant][isDark ? 'dark' : 'light']}
|
||||
opacity={0.75}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
<rect
|
||||
className="sixstep-minimap-rect"
|
||||
x={offsetX + viewX * ratio}
|
||||
y={offsetY + viewY * ratio}
|
||||
width={Math.max(6, viewW * ratio)}
|
||||
height={Math.max(6, viewH * ratio)}
|
||||
rx={3}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SixStepFlowFullscreen: React.FC<SixStepFlowFullscreenProps> = ({ message, onClose }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const purposes = message?.stages?.purpose?.cards ?? [];
|
||||
const actors = message?.stages?.actor?.cards ?? [];
|
||||
const behaviors = message?.stages?.behavior?.cards ?? [];
|
||||
const narratives = message?.stages?.narrative?.cards ?? [];
|
||||
const selectedExpert = message?.selectedExpert;
|
||||
|
||||
const layout = useMemo(
|
||||
() => buildLayout(purposes, actors, behaviors, narratives, {
|
||||
rootTitle: selectedExpert?.name,
|
||||
rootDetail: selectedExpert ? buildExpertRootDetail(selectedExpert) : undefined,
|
||||
}),
|
||||
[purposes, actors, behaviors, narratives, selectedExpert],
|
||||
);
|
||||
|
||||
const currentSub = message?.currentSubStep;
|
||||
const activePair = currentSub ? CURRENT_PAIR_BY_STAGE[currentSub] : null;
|
||||
const statusTitle = currentSub ? STATUS_TITLE[currentSub] : '推演结果可视化';
|
||||
const generating = Boolean(currentSub);
|
||||
|
||||
const [transform, setTransform] = useState({ tx: 0, ty: 0, scale: 1 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [viewportSize, setViewportSize] = useState({ width: 800, height: 600 });
|
||||
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set());
|
||||
const [displayedNodeIds, setDisplayedNodeIds] = useState<Set<string>>(new Set());
|
||||
const [enteringNodeIds, setEnteringNodeIds] = useState<Set<string>>(new Set());
|
||||
const [exitingNodeIds, setExitingNodeIds] = useState<Set<string>>(new Set());
|
||||
const [displayedEdgeIds, setDisplayedEdgeIds] = useState<Set<string>>(new Set());
|
||||
const [exitingEdgeIds, setExitingEdgeIds] = useState<Set<string>>(new Set());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const panStart = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
|
||||
const transitionTimerRef = useRef<number | null>(null);
|
||||
|
||||
const nodeMap = useMemo(() => new Map(layout.nodes.map((node) => [node.id, node])), [layout.nodes]);
|
||||
|
||||
const targetVisibleNodeSet = useMemo(() => {
|
||||
const visible = new Set<string>();
|
||||
layout.nodes.forEach((node) => {
|
||||
let parentId = node.parent;
|
||||
let hidden = false;
|
||||
while (parentId) {
|
||||
if (collapsedNodeIds.has(parentId)) {
|
||||
hidden = true;
|
||||
break;
|
||||
}
|
||||
parentId = nodeMap.get(parentId)?.parent;
|
||||
}
|
||||
if (!hidden) visible.add(node.id);
|
||||
});
|
||||
return visible;
|
||||
}, [collapsedNodeIds, layout.nodes, nodeMap]);
|
||||
|
||||
const targetVisibleEdgeSet = useMemo(() => {
|
||||
const visible = new Set<string>();
|
||||
layout.edges.forEach((edge) => {
|
||||
if (targetVisibleNodeSet.has(edge.fromId) && targetVisibleNodeSet.has(edge.toId)) {
|
||||
visible.add(edge.id);
|
||||
}
|
||||
});
|
||||
return visible;
|
||||
}, [layout.edges, targetVisibleNodeSet]);
|
||||
|
||||
const renderedNodeSet = displayedNodeIds.size > 0 ? displayedNodeIds : targetVisibleNodeSet;
|
||||
const renderedEdgeSet = displayedEdgeIds.size > 0 ? displayedEdgeIds : targetVisibleEdgeSet;
|
||||
const renderedNodes = useMemo(
|
||||
() => layout.nodes.filter((node) => renderedNodeSet.has(node.id)),
|
||||
[layout.nodes, renderedNodeSet],
|
||||
);
|
||||
const renderedEdges = useMemo(
|
||||
() => layout.edges.filter((edge) => renderedEdgeSet.has(edge.id)),
|
||||
[layout.edges, renderedEdgeSet],
|
||||
);
|
||||
|
||||
const toggleNodeCollapse = (nodeId: string) => {
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node || node.children.length === 0) return;
|
||||
setCollapsedNodeIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) next.delete(nodeId);
|
||||
else next.add(nodeId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setViewportSize({ width: rect.width, height: rect.height });
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
// Native wheel listener with passive:false so preventDefault actually blocks browser zoom
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
const delta = -e.deltaY * 0.0015;
|
||||
setTransform((prev) => {
|
||||
const nextScale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, prev.scale * (1 + delta)));
|
||||
const ratio = nextScale / prev.scale;
|
||||
return { tx: mx - (mx - prev.tx) * ratio, ty: my - (my - prev.ty) * ratio, scale: nextScale };
|
||||
});
|
||||
};
|
||||
container.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => container.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
const handlePanStart = (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
panStart.current = { x: e.clientX, y: e.clientY, tx: transform.tx, ty: transform.ty };
|
||||
setIsPanning(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPanning) return;
|
||||
const move = (e: MouseEvent) => {
|
||||
const start = panStart.current;
|
||||
if (!start) return;
|
||||
setTransform((prev) => ({ ...prev, tx: start.tx + (e.clientX - start.x), ty: start.ty + (e.clientY - start.y) }));
|
||||
};
|
||||
const up = () => {
|
||||
panStart.current = null;
|
||||
setIsPanning(false);
|
||||
};
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', move);
|
||||
window.removeEventListener('mouseup', up);
|
||||
};
|
||||
}, [isPanning]);
|
||||
|
||||
const fitToView = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const scale = Math.min((rect.width - 80) / Math.max(layout.width, 1), (rect.height - 80) / Math.max(layout.height, 1), 1);
|
||||
const tx = (rect.width - layout.width * scale) / 2;
|
||||
const ty = (rect.height - layout.height * scale) / 2;
|
||||
setTransform({ tx, ty, scale });
|
||||
};
|
||||
|
||||
const centerView = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
setTransform((prev) => ({
|
||||
...prev,
|
||||
tx: (rect.width - layout.width * prev.scale) / 2,
|
||||
ty: (rect.height - layout.height * prev.scale) / 2,
|
||||
}));
|
||||
};
|
||||
|
||||
const zoomIn = () => setTransform((prev) => ({ ...prev, scale: Math.min(SCALE_MAX, prev.scale * 1.2) }));
|
||||
const zoomOut = () => setTransform((prev) => ({ ...prev, scale: Math.max(SCALE_MIN, prev.scale / 1.2) }));
|
||||
const reset = () => setTransform({ tx: 0, ty: 0, scale: 1 });
|
||||
|
||||
const fitAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!fitAppliedRef.current && viewportSize.width > 0 && layout.nodes.length > 1) {
|
||||
fitToView();
|
||||
fitAppliedRef.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewportSize.width, layout.nodes.length]);
|
||||
|
||||
const handleMinimapJump = (wx: number, wy: number) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
setTransform((prev) => ({
|
||||
...prev,
|
||||
tx: rect.width / 2 - wx * prev.scale,
|
||||
ty: rect.height / 2 - wy * prev.scale,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const cloned = svg.cloneNode(true) as SVGSVGElement;
|
||||
cloned.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
cloned.setAttribute('width', String(layout.width));
|
||||
cloned.setAttribute('height', String(layout.height));
|
||||
cloned.setAttribute('viewBox', `0 0 ${layout.width} ${layout.height}`);
|
||||
const inner = cloned.querySelector('g');
|
||||
if (inner) inner.setAttribute('transform', '');
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(cloned);
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `6步法推演-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.svg`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const validIds = new Set(layout.nodes.filter((node) => node.children.length > 0).map((node) => node.id));
|
||||
setCollapsedNodeIds((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((id) => {
|
||||
if (validIds.has(id)) next.add(id);
|
||||
else changed = true;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [layout.nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (transitionTimerRef.current) {
|
||||
window.clearTimeout(transitionTimerRef.current);
|
||||
transitionTimerRef.current = null;
|
||||
}
|
||||
|
||||
const prevNodes = displayedNodeIds;
|
||||
const nextNodes = targetVisibleNodeSet;
|
||||
const prevEdges = displayedEdgeIds;
|
||||
const nextEdges = targetVisibleEdgeSet;
|
||||
|
||||
const enteringNodes = new Set([...nextNodes].filter((id) => !prevNodes.has(id)));
|
||||
const leavingNodes = new Set([...prevNodes].filter((id) => !nextNodes.has(id)));
|
||||
const leavingEdges = new Set([...prevEdges].filter((id) => !nextEdges.has(id)));
|
||||
const mergedNodes = new Set([...prevNodes, ...nextNodes]);
|
||||
const mergedEdges = new Set([...prevEdges, ...nextEdges]);
|
||||
|
||||
setEnteringNodeIds(enteringNodes);
|
||||
setExitingNodeIds(leavingNodes);
|
||||
setExitingEdgeIds(leavingEdges);
|
||||
setDisplayedNodeIds(mergedNodes);
|
||||
setDisplayedEdgeIds(mergedEdges);
|
||||
|
||||
transitionTimerRef.current = window.setTimeout(() => {
|
||||
setDisplayedNodeIds(new Set(nextNodes));
|
||||
setDisplayedEdgeIds(new Set(nextEdges));
|
||||
setEnteringNodeIds(new Set());
|
||||
setExitingNodeIds(new Set());
|
||||
setExitingEdgeIds(new Set());
|
||||
transitionTimerRef.current = null;
|
||||
}, COLLAPSE_TRANSITION_MS);
|
||||
}, [targetVisibleNodeSet, targetVisibleEdgeSet]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (transitionTimerRef.current) window.clearTimeout(transitionTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-50 flex flex-col ${isDark ? 'bg-slate-950/95' : 'bg-white/95'} backdrop-blur-sm`}>
|
||||
<div className={`flex items-center justify-between gap-3 px-5 py-3 border-b ${
|
||||
isDark ? 'border-slate-700/60 bg-slate-900/70' : 'border-red-100 bg-white'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${
|
||||
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
|
||||
} ${generating ? 'sixstep-pulse' : ''}`}>
|
||||
<Compass className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className={`text-base font-semibold ${
|
||||
generating ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-white' : 'text-gray-900')
|
||||
}`}>
|
||||
{statusTitle}
|
||||
</div>
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
全屏视图 · 完整内容展示
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton title="放大" onClick={zoomIn} isDark={isDark}><Plus className="w-4 h-4" /></ToolbarButton>
|
||||
<ToolbarButton title="缩小" onClick={zoomOut} isDark={isDark}><Minus className="w-4 h-4" /></ToolbarButton>
|
||||
<ToolbarButton title="恢复 1:1" onClick={reset} isDark={isDark}><RotateCcw className="w-4 h-4" /></ToolbarButton>
|
||||
<ToolbarButton title="定位到画布中央" onClick={centerView} isDark={isDark}><Crosshair className="w-4 h-4" /></ToolbarButton>
|
||||
<ToolbarButton title="下载流程图(SVG)" onClick={handleDownload} isDark={isDark}><Download className="w-4 h-4" /></ToolbarButton>
|
||||
<span className={`mx-2 h-5 w-px ${isDark ? 'bg-slate-700' : 'bg-gray-200'}`} />
|
||||
<span className={`text-[11px] tabular-nums ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{Math.round(transform.scale * 100)}%
|
||||
</span>
|
||||
<ToolbarButton title="关闭全屏 (Esc)" onClick={onClose} isDark={isDark}>
|
||||
<X className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center gap-3 px-5 py-2 border-b text-[11px] flex-wrap ${
|
||||
isDark ? 'border-slate-700/60 text-slate-400 bg-slate-900/40' : 'border-red-100 text-gray-500 bg-white'
|
||||
}`}>
|
||||
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
|
||||
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
|
||||
const active = activePair === pair;
|
||||
return (
|
||||
<span key={pair} className="inline-flex items-center gap-1.5">
|
||||
<span className={`inline-block w-6 h-0.5 rounded-full ${active ? 'sixstep-ant-line' : ''}`} style={{ background: color, stroke: color }} />
|
||||
<span className={active ? (isDark ? 'text-cyan-200' : 'text-red-600') : ''}>{EDGE_LABEL[pair]}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`flex-1 overflow-hidden relative ${isPanning ? 'sixstep-canvas-grabbing' : 'sixstep-canvas-grab'}`}
|
||||
onMouseDown={handlePanStart}
|
||||
>
|
||||
{renderedNodes.length <= 1 ? (
|
||||
<div className={`h-full flex flex-col items-center justify-center text-center ${
|
||||
isDark ? 'text-slate-500' : 'text-gray-400'
|
||||
}`}>
|
||||
<Megaphone className="w-12 h-12 opacity-40 mb-3" />
|
||||
<div className={`text-base font-medium ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>等待推演开始</div>
|
||||
<div className="text-sm mt-1">推演产出将以流程图形式呈现</div>
|
||||
</div>
|
||||
) : (
|
||||
<svg ref={svgRef} width="100%" height="100%" className="block select-none">
|
||||
<defs>
|
||||
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
|
||||
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
|
||||
return (
|
||||
<marker
|
||||
key={pair}
|
||||
id={`sixstep-fs-arrow-${pair}`}
|
||||
viewBox="0 0 8 8"
|
||||
refX="7"
|
||||
refY="4"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 8 4 L 0 8 z" fill={color} />
|
||||
</marker>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
|
||||
<g transform={`translate(${transform.tx}, ${transform.ty}) scale(${transform.scale})`}>
|
||||
{renderedEdges.map((edge) => {
|
||||
const from = nodeMap.get(edge.fromId);
|
||||
const to = nodeMap.get(edge.toId);
|
||||
if (!from || !to) return null;
|
||||
const color = EDGE_COLOR[edge.pair][isDark ? 'dark' : 'light'];
|
||||
const isActive = activePair === edge.pair;
|
||||
const exiting = exitingEdgeIds.has(edge.id);
|
||||
return (
|
||||
<path
|
||||
key={edge.id}
|
||||
d={buildEdgePath(from, to)}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={isActive ? 2.4 : 1.8}
|
||||
strokeOpacity={exiting ? 0 : isActive ? 1 : 0.75}
|
||||
className={isActive ? 'sixstep-ant-line' : ''}
|
||||
markerEnd={`url(#sixstep-fs-arrow-${edge.pair})`}
|
||||
style={{
|
||||
opacity: exiting ? 0 : 1,
|
||||
transition: `opacity ${COLLAPSE_TRANSITION_MS}ms ease`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{renderedNodes.map((node) => (
|
||||
<DetailNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
isDark={isDark}
|
||||
collapsible={node.children.length > 0}
|
||||
collapsed={collapsedNodeIds.has(node.id)}
|
||||
onToggleCollapse={toggleNodeCollapse}
|
||||
entering={enteringNodeIds.has(node.id)}
|
||||
exiting={exitingNodeIds.has(node.id)}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{targetVisibleNodeSet.size > 1 && (
|
||||
<div className="absolute bottom-4 right-4">
|
||||
<Minimap
|
||||
layout={{
|
||||
nodes: layout.nodes.filter((node) => targetVisibleNodeSet.has(node.id)),
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
}}
|
||||
transform={transform}
|
||||
viewportSize={viewportSize}
|
||||
onJump={handleMinimapJump}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`absolute bottom-4 left-4 px-2 py-1 rounded-md text-[10px] backdrop-blur-md ${
|
||||
isDark ? 'bg-slate-800/70 text-slate-400 border border-slate-700/50' : 'bg-white/90 text-gray-500 border border-gray-200'
|
||||
}`}>
|
||||
点击父节点可收起/展开子节点
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SixStepFlowFullscreen;
|
||||
@@ -0,0 +1,520 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
Compass,
|
||||
Crosshair,
|
||||
Maximize2,
|
||||
Megaphone,
|
||||
Minus,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
ScrollText,
|
||||
Target,
|
||||
TrendingUp,
|
||||
UserCheck,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import type { ExpertCard, SixStepMessage } from './types';
|
||||
import {
|
||||
CURRENT_PAIR_BY_STAGE,
|
||||
EDGE_COLOR,
|
||||
EDGE_LABEL,
|
||||
STATUS_TITLE,
|
||||
VARIANT_ABBREV,
|
||||
VARIANT_LABEL,
|
||||
buildEdgePath,
|
||||
buildLayout,
|
||||
type LayoutEdge,
|
||||
type LayoutNode,
|
||||
} from './flowLayout';
|
||||
|
||||
interface SixStepFlowSandboxProps {
|
||||
message?: SixStepMessage;
|
||||
width: number;
|
||||
onResizeStart: (e: React.MouseEvent) => void;
|
||||
onClose: () => void;
|
||||
onOpenFullscreen: () => void;
|
||||
}
|
||||
|
||||
const COMPACT_OPTIONS = {
|
||||
nodeWidth: 120,
|
||||
nodeHeight: 60,
|
||||
levelGap: 80,
|
||||
nodeGap: 24,
|
||||
paddingX: 32,
|
||||
paddingY: 28,
|
||||
};
|
||||
|
||||
const VARIANT_GRADIENT: Record<LayoutNode['variant'], { dark: string; light: string }> = {
|
||||
agent: { dark: 'from-slate-700/80 to-slate-900/80 border-slate-500/60', light: 'from-white to-slate-50 border-slate-300' },
|
||||
purpose: { dark: 'from-violet-700/40 to-violet-900/40 border-violet-400/50', light: 'from-orange-50 to-white border-orange-300' },
|
||||
actor: { dark: 'from-cyan-700/40 to-cyan-900/40 border-cyan-400/50', light: 'from-red-50 to-white border-red-300' },
|
||||
behavior: { dark: 'from-emerald-700/40 to-emerald-900/40 border-emerald-400/50', light: 'from-emerald-50 to-white border-emerald-300' },
|
||||
driver: { dark: 'from-emerald-800/40 to-teal-900/40 border-teal-400/50', light: 'from-teal-50 to-white border-teal-300' },
|
||||
narrative: { dark: 'from-amber-700/40 to-amber-900/40 border-amber-400/50', light: 'from-amber-50 to-white border-amber-300' },
|
||||
};
|
||||
|
||||
const VARIANT_ICON: Record<LayoutNode['variant'], React.ComponentType<{ className?: string }>> = {
|
||||
agent: Bot,
|
||||
purpose: Target,
|
||||
actor: UserCheck,
|
||||
behavior: TrendingUp,
|
||||
driver: Zap,
|
||||
narrative: ScrollText,
|
||||
};
|
||||
|
||||
// Colored icon wrapper per variant (dark / light)
|
||||
const VARIANT_ICON_CLS: Record<LayoutNode['variant'], { dark: string; light: string }> = {
|
||||
agent: { dark: 'bg-slate-600/60 text-slate-200', light: 'bg-slate-100 text-slate-600' },
|
||||
purpose: { dark: 'bg-violet-500/25 text-violet-200', light: 'bg-orange-100 text-orange-600' },
|
||||
actor: { dark: 'bg-cyan-500/25 text-cyan-200', light: 'bg-red-100 text-red-600' },
|
||||
behavior: { dark: 'bg-emerald-500/25 text-emerald-200', light: 'bg-emerald-100 text-emerald-700' },
|
||||
driver: { dark: 'bg-teal-500/25 text-teal-200', light: 'bg-teal-100 text-teal-700' },
|
||||
narrative: { dark: 'bg-amber-500/25 text-amber-200', light: 'bg-amber-100 text-amber-600' },
|
||||
};
|
||||
|
||||
const SCALE_MIN = 0.3;
|
||||
const SCALE_MAX = 2.5;
|
||||
|
||||
const buildExpertRootDetail = (expert: ExpertCard): string => {
|
||||
const segments = [expert.title, expert.detail].filter((item): item is string => Boolean(item && item.trim()));
|
||||
return segments.join(' · ') || '已确认专家';
|
||||
};
|
||||
|
||||
const CompactNode: React.FC<{
|
||||
node: LayoutNode;
|
||||
isDark: boolean;
|
||||
appearing: boolean;
|
||||
collapsible: boolean;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse?: (nodeId: string) => void;
|
||||
}> = ({ node, isDark, appearing, collapsible, collapsed, onToggleCollapse }) => {
|
||||
const Icon = VARIANT_ICON[node.variant];
|
||||
const grad = VARIANT_GRADIENT[node.variant][isDark ? 'dark' : 'light'];
|
||||
const canToggle = collapsible && Boolean(onToggleCollapse);
|
||||
const handleClick = (e: React.MouseEvent<SVGGElement>) => {
|
||||
if (!canToggle) return;
|
||||
e.stopPropagation();
|
||||
onToggleCollapse(node.id);
|
||||
};
|
||||
const handleMouseDown = (e: React.MouseEvent<SVGGElement>) => {
|
||||
if (canToggle) e.stopPropagation();
|
||||
};
|
||||
return (
|
||||
<g
|
||||
className={appearing ? 'sixstep-node-pop' : ''}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
style={canToggle ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
{appearing && (
|
||||
<circle
|
||||
cx={node.x + node.width / 2}
|
||||
cy={node.y + node.height / 2}
|
||||
r={0}
|
||||
stroke={isDark ? '#22d3ee' : '#ef4444'}
|
||||
className="sixstep-ring-burst"
|
||||
/>
|
||||
)}
|
||||
<foreignObject x={node.x} y={node.y} width={node.width} height={node.height}>
|
||||
<div className={`relative w-full h-full rounded-2xl border bg-gradient-to-br ${grad} backdrop-blur-sm flex items-center justify-center gap-1.5 px-2 shadow-sm`}>
|
||||
{collapsible && (
|
||||
<div
|
||||
className={`absolute right-1.5 top-1.5 w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-bold ${
|
||||
isDark ? 'bg-slate-800/75 text-slate-200' : 'bg-white text-slate-600 border border-slate-200'
|
||||
}`}
|
||||
title={collapsed ? '展开子节点' : '收起子节点'}
|
||||
>
|
||||
{collapsed ? '+' : '-'}
|
||||
</div>
|
||||
)}
|
||||
<div className={`w-7 h-7 rounded-lg flex items-center justify-center ${VARIANT_ICON_CLS[node.variant][isDark ? 'dark' : 'light']}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<span className={`text-sm font-bold tracking-wider ${isDark ? 'text-white' : 'text-slate-800'}`}>
|
||||
{VARIANT_ABBREV[node.variant]}
|
||||
</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolbarButton: React.FC<{ title: string; onClick: () => void; isDark: boolean; children: React.ReactNode }> = ({ title, onClick, isDark, children }) => (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className={`sixstep-toolbar-btn ${
|
||||
isDark ? 'text-slate-300 hover:bg-slate-700/70 hover:text-white' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const SixStepFlowSandbox: React.FC<SixStepFlowSandboxProps> = ({ message, width, onResizeStart, onClose, onOpenFullscreen }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const purposes = message?.stages?.purpose?.cards ?? [];
|
||||
const actors = message?.stages?.actor?.cards ?? [];
|
||||
const behaviors = message?.stages?.behavior?.cards ?? [];
|
||||
const narratives = message?.stages?.narrative?.cards ?? [];
|
||||
const selectedExpert = message?.selectedExpert;
|
||||
|
||||
const layout = useMemo(
|
||||
() => buildLayout(purposes, actors, behaviors, narratives, {
|
||||
...COMPACT_OPTIONS,
|
||||
rootTitle: selectedExpert?.name,
|
||||
rootDetail: selectedExpert ? buildExpertRootDetail(selectedExpert) : undefined,
|
||||
}),
|
||||
[purposes, actors, behaviors, narratives, selectedExpert],
|
||||
);
|
||||
|
||||
const currentSub = message?.currentSubStep;
|
||||
const activePair = currentSub ? CURRENT_PAIR_BY_STAGE[currentSub] : null;
|
||||
const statusTitle = currentSub ? STATUS_TITLE[currentSub] : '推演结果可视化';
|
||||
const generating = Boolean(currentSub);
|
||||
|
||||
const [transform, setTransform] = useState({ tx: 0, ty: 0, scale: 1 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const panStart = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
|
||||
|
||||
// Track newly appearing nodes for animation
|
||||
const seenIdsRef = useRef<Set<string>>(new Set());
|
||||
const [appearingIds, setAppearingIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const nodeMap = useMemo(() => {
|
||||
return new Map(layout.nodes.map((node) => [node.id, node]));
|
||||
}, [layout.nodes]);
|
||||
|
||||
const visibleNodeSet = useMemo(() => {
|
||||
const visible = new Set<string>();
|
||||
layout.nodes.forEach((node) => {
|
||||
let parentId = node.parent;
|
||||
let hidden = false;
|
||||
while (parentId) {
|
||||
if (collapsedNodeIds.has(parentId)) {
|
||||
hidden = true;
|
||||
break;
|
||||
}
|
||||
parentId = nodeMap.get(parentId)?.parent;
|
||||
}
|
||||
if (!hidden) visible.add(node.id);
|
||||
});
|
||||
return visible;
|
||||
}, [collapsedNodeIds, layout.nodes, nodeMap]);
|
||||
|
||||
const visibleNodes = useMemo(
|
||||
() => layout.nodes.filter((node) => visibleNodeSet.has(node.id)),
|
||||
[layout.nodes, visibleNodeSet],
|
||||
);
|
||||
|
||||
const visibleEdges = useMemo(
|
||||
() => layout.edges.filter((edge) => visibleNodeSet.has(edge.fromId) && visibleNodeSet.has(edge.toId)),
|
||||
[layout.edges, visibleNodeSet],
|
||||
);
|
||||
|
||||
const toggleNodeCollapse = (nodeId: string) => {
|
||||
const targetNode = nodeMap.get(nodeId);
|
||||
if (!targetNode || targetNode.children.length === 0) return;
|
||||
setCollapsedNodeIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) next.delete(nodeId);
|
||||
else next.add(nodeId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const validIds = new Set(
|
||||
layout.nodes.filter((node) => node.children.length > 0).map((node) => node.id),
|
||||
);
|
||||
setCollapsedNodeIds((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((id) => {
|
||||
if (validIds.has(id)) next.add(id);
|
||||
else changed = true;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [layout.nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const newIds: string[] = [];
|
||||
visibleNodes.forEach((n) => {
|
||||
if (!seenIdsRef.current.has(n.id)) {
|
||||
seenIdsRef.current.add(n.id);
|
||||
newIds.push(n.id);
|
||||
}
|
||||
});
|
||||
if (newIds.length > 0) {
|
||||
setAppearingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
newIds.forEach((id) => next.add(id));
|
||||
return next;
|
||||
});
|
||||
const timer = window.setTimeout(() => {
|
||||
setAppearingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
newIds.forEach((id) => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
}, 700);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [visibleNodes]);
|
||||
|
||||
// Native wheel listener with passive:false so preventDefault actually blocks browser zoom
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
const delta = -e.deltaY * 0.0015;
|
||||
setTransform((prev) => {
|
||||
const nextScale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, prev.scale * (1 + delta)));
|
||||
const ratio = nextScale / prev.scale;
|
||||
return { tx: mx - (mx - prev.tx) * ratio, ty: my - (my - prev.ty) * ratio, scale: nextScale };
|
||||
});
|
||||
};
|
||||
container.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => container.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
const handlePanStart = (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
panStart.current = { x: e.clientX, y: e.clientY, tx: transform.tx, ty: transform.ty };
|
||||
setIsPanning(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPanning) return;
|
||||
const move = (e: MouseEvent) => {
|
||||
const start = panStart.current;
|
||||
if (!start) return;
|
||||
setTransform((prev) => ({ ...prev, tx: start.tx + (e.clientX - start.x), ty: start.ty + (e.clientY - start.y) }));
|
||||
};
|
||||
const up = () => {
|
||||
panStart.current = null;
|
||||
setIsPanning(false);
|
||||
};
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', move);
|
||||
window.removeEventListener('mouseup', up);
|
||||
};
|
||||
}, [isPanning]);
|
||||
|
||||
const fitToView = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container || layout.width <= 0 || layout.height <= 0) {
|
||||
setTransform({ tx: 0, ty: 0, scale: 1 });
|
||||
return;
|
||||
}
|
||||
const rect = container.getBoundingClientRect();
|
||||
const scale = Math.min((rect.width - 40) / layout.width, (rect.height - 40) / layout.height, 1);
|
||||
const tx = (rect.width - layout.width * scale) / 2;
|
||||
const ty = (rect.height - layout.height * scale) / 2;
|
||||
setTransform({ tx, ty, scale });
|
||||
};
|
||||
|
||||
const centerView = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
setTransform((prev) => ({
|
||||
...prev,
|
||||
tx: (rect.width - layout.width * prev.scale) / 2,
|
||||
ty: (rect.height - layout.height * prev.scale) / 2,
|
||||
}));
|
||||
};
|
||||
|
||||
const zoomIn = () => setTransform((prev) => ({ ...prev, scale: Math.min(SCALE_MAX, prev.scale * 1.2) }));
|
||||
const zoomOut = () => setTransform((prev) => ({ ...prev, scale: Math.max(SCALE_MIN, prev.scale / 1.2) }));
|
||||
const reset = () => setTransform({ tx: 0, ty: 0, scale: 1 });
|
||||
|
||||
// Auto-fit on first render with content
|
||||
const fitAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!fitAppliedRef.current && layout.nodes.length > 1) {
|
||||
fitToView();
|
||||
fitAppliedRef.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [layout.nodes.length]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative h-full flex flex-col border-l ${
|
||||
isDark ? 'border-slate-700/60 bg-slate-900/60' : 'border-red-100 bg-white'
|
||||
}`}
|
||||
style={{ width }}
|
||||
>
|
||||
<div className="sixstep-resize-handle" onMouseDown={onResizeStart} />
|
||||
|
||||
<div className={`flex items-center justify-between gap-3 px-4 py-3 border-b ${
|
||||
isDark ? 'border-slate-700/60 bg-slate-900/40' : 'border-red-100 bg-gradient-to-r from-red-50/60 to-orange-50/40'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={`w-8 h-8 rounded-2xl flex items-center justify-center ${
|
||||
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
|
||||
} ${generating ? 'sixstep-pulse' : ''}`}>
|
||||
<Compass className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className={`text-sm font-semibold truncate ${
|
||||
generating ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-white' : 'text-gray-900')
|
||||
}`}>
|
||||
{statusTitle}
|
||||
</div>
|
||||
<div className={`text-[11px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
简化视图 · 节点缩写显示
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
isDark ? 'text-slate-400 hover:text-white hover:bg-slate-700/60' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-100'
|
||||
}`}
|
||||
title="关闭沙箱"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center gap-1 px-3 py-2 border-b ${
|
||||
isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'
|
||||
}`}>
|
||||
<ToolbarButton title="放大" onClick={zoomIn} isDark={isDark}><Plus className="w-4 h-4" /></ToolbarButton>
|
||||
<ToolbarButton title="缩小" onClick={zoomOut} isDark={isDark}><Minus className="w-4 h-4" /></ToolbarButton>
|
||||
<ToolbarButton title="恢复 1:1" onClick={reset} isDark={isDark}><RotateCcw className="w-4 h-4" /></ToolbarButton>
|
||||
<ToolbarButton title="定位到画布中央" onClick={centerView} isDark={isDark}><Crosshair className="w-4 h-4" /></ToolbarButton>
|
||||
<span className={`mx-2 h-5 w-px ${isDark ? 'bg-slate-700' : 'bg-gray-200'}`} />
|
||||
<span className={`text-[11px] tabular-nums ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{Math.round(transform.scale * 100)}%
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<ToolbarButton title="全屏查看完整流程图" onClick={onOpenFullscreen} isDark={isDark}>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center gap-3 px-3 py-1.5 border-b text-[11px] flex-wrap ${
|
||||
isDark ? 'border-slate-700/60 text-slate-400 bg-slate-900/30' : 'border-red-100 text-gray-500 bg-white'
|
||||
}`}>
|
||||
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
|
||||
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
|
||||
const active = activePair === pair;
|
||||
return (
|
||||
<span key={pair} className="inline-flex items-center gap-1.5">
|
||||
<span className={`inline-block w-5 h-0.5 rounded-full ${active ? 'sixstep-ant-line' : ''}`} style={{ background: color, stroke: color }} />
|
||||
<span className={active ? (isDark ? 'text-cyan-200' : 'text-red-600') : ''}>{EDGE_LABEL[pair]}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`flex-1 overflow-hidden relative ${isPanning ? 'sixstep-canvas-grabbing' : 'sixstep-canvas-grab'}`}
|
||||
onMouseDown={handlePanStart}
|
||||
>
|
||||
{visibleNodes.length <= 1 ? (
|
||||
<div className={`h-full flex flex-col items-center justify-center text-center ${
|
||||
isDark ? 'text-slate-500' : 'text-gray-400'
|
||||
}`}>
|
||||
<Megaphone className="w-10 h-10 opacity-40 mb-3" />
|
||||
<div className={`text-sm font-medium ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>等待推演开始</div>
|
||||
<div className="text-xs mt-1">推演产出将以流程图形式呈现</div>
|
||||
</div>
|
||||
) : (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="block select-none"
|
||||
>
|
||||
<defs>
|
||||
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
|
||||
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
|
||||
return (
|
||||
<marker
|
||||
key={pair}
|
||||
id={`sixstep-sandbox-arrow-${pair}`}
|
||||
viewBox="0 0 8 8"
|
||||
refX="7"
|
||||
refY="4"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 8 4 L 0 8 z" fill={color} />
|
||||
</marker>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
|
||||
<g transform={`translate(${transform.tx}, ${transform.ty}) scale(${transform.scale})`}>
|
||||
{visibleEdges.map((edge) => {
|
||||
const from = nodeMap.get(edge.fromId);
|
||||
const to = nodeMap.get(edge.toId);
|
||||
if (!from || !to) return null;
|
||||
const color = EDGE_COLOR[edge.pair][isDark ? 'dark' : 'light'];
|
||||
const isActive = activePair === edge.pair;
|
||||
return (
|
||||
<path
|
||||
key={edge.id}
|
||||
d={buildEdgePath(from, to)}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={isActive ? 2.2 : 1.6}
|
||||
strokeOpacity={isActive ? 1 : 0.7}
|
||||
className={isActive ? 'sixstep-ant-line' : ''}
|
||||
markerEnd={`url(#sixstep-sandbox-arrow-${edge.pair})`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{visibleNodes.map((node) => (
|
||||
<CompactNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
isDark={isDark}
|
||||
appearing={appearingIds.has(node.id)}
|
||||
collapsible={node.children.length > 0}
|
||||
collapsed={collapsedNodeIds.has(node.id)}
|
||||
onToggleCollapse={toggleNodeCollapse}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
<div className={`absolute bottom-3 left-3 px-2 py-1 rounded-md text-[10px] backdrop-blur-md ${
|
||||
isDark ? 'bg-slate-800/70 text-slate-400 border border-slate-700/50' : 'bg-white/90 text-gray-500 border border-gray-200'
|
||||
}`}>
|
||||
{VARIANT_LABEL.purpose}=md · {VARIANT_LABEL.actor}=xwt · {VARIANT_LABEL.behavior}=yqxw · {VARIANT_LABEL.driver}=qdys · {VARIANT_LABEL.narrative}=xs
|
||||
</div>
|
||||
<div className={`absolute bottom-3 right-3 px-2 py-1 rounded-md text-[10px] backdrop-blur-md ${
|
||||
isDark ? 'bg-slate-800/70 text-slate-400 border border-slate-700/50' : 'bg-white/90 text-gray-500 border border-gray-200'
|
||||
}`}>
|
||||
点击父节点可收起/展开子节点
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SixStepFlowSandbox;
|
||||
@@ -0,0 +1,314 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Database,
|
||||
FileCheck2,
|
||||
Loader2,
|
||||
Megaphone,
|
||||
Sparkles,
|
||||
Target,
|
||||
Users,
|
||||
Workflow,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import type { MainStepKey, StepStatus, SubStepKey } from './types';
|
||||
|
||||
interface SixStepProgressProps {
|
||||
mainStatus: Record<MainStepKey, StepStatus>;
|
||||
subStatus: Record<SubStepKey, StepStatus>;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
interface StepItem<T extends string> {
|
||||
key: T;
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: { dark: string; light: string };
|
||||
}
|
||||
|
||||
const MAIN_STEPS: StepItem<MainStepKey>[] = [
|
||||
{ key: 'info', title: '信息准备', desc: '梳理输入素材与上下文', icon: Database, color: { dark: 'text-sky-300 bg-sky-500/15 border-sky-400/40', light: 'text-sky-600 bg-sky-50 border-sky-200' } },
|
||||
{ key: 'sixstep', title: '6 步法推演', desc: '依次完成四个阶段推演', icon: Workflow, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-violet-50 border-violet-200' } },
|
||||
{ key: 'final', title: '最终结果生成', desc: '汇总各阶段产出,输出结论', icon: FileCheck2, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
|
||||
];
|
||||
|
||||
const SUB_STEPS: StepItem<SubStepKey>[] = [
|
||||
{ key: 'purpose', title: '目的生成', desc: '识别核心目的方向', icon: Target, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-orange-50 border-orange-200' } },
|
||||
{ key: 'actor', title: '行为体生成', desc: '匹配行为体角色', icon: Users, color: { dark: 'text-cyan-300 bg-cyan-500/15 border-cyan-400/40', light: 'text-red-600 bg-red-50 border-red-200' } },
|
||||
{ key: 'behavior', title: '预期行为与驱动因素', desc: '推演行为与动因', icon: Zap, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
|
||||
{ key: 'narrative', title: '叙事生成', desc: '构建叙事框架', icon: Megaphone,color: { dark: 'text-amber-300 bg-amber-500/15 border-amber-400/40', light: 'text-amber-600 bg-amber-50 border-amber-200' } },
|
||||
];
|
||||
|
||||
function dotColor(status: StepStatus, isDark: boolean) {
|
||||
if (status === 'done') return isDark ? 'bg-emerald-400' : 'bg-emerald-500';
|
||||
if (status === 'running') return isDark ? 'bg-cyan-400 sixstep-pulse' : 'bg-red-500 sixstep-pulse';
|
||||
return isDark ? 'bg-slate-600' : 'bg-gray-300';
|
||||
}
|
||||
|
||||
function StatusIcon({ status, isDark }: { status: StepStatus; isDark: boolean }) {
|
||||
if (status === 'done') return <CheckCircle2 className={`w-4 h-4 ${isDark ? 'text-emerald-300' : 'text-emerald-500'}`} />;
|
||||
if (status === 'running') return <Loader2 className={`w-4 h-4 sixstep-spin-soft ${isDark ? 'text-cyan-300' : 'text-red-500'}`} />;
|
||||
return <Circle className={`w-3.5 h-3.5 ${isDark ? 'text-slate-500' : 'text-gray-300'}`} />;
|
||||
}
|
||||
|
||||
function cardBorder(status: StepStatus, isDark: boolean) {
|
||||
if (status === 'running') return isDark
|
||||
? 'border-cyan-400/40 bg-cyan-500/5 shadow-[0_0_0_1px_rgba(34,211,238,0.15)]'
|
||||
: 'border-red-300 bg-red-50/60 shadow-sm';
|
||||
return isDark ? 'border-slate-700/60 bg-slate-800/40' : 'border-gray-200 bg-white';
|
||||
}
|
||||
|
||||
function connectorColor(status: StepStatus, isDark: boolean) {
|
||||
return status === 'done'
|
||||
? isDark ? 'bg-emerald-400/40' : 'bg-emerald-300'
|
||||
: isDark ? 'bg-slate-700/60' : 'bg-gray-200';
|
||||
}
|
||||
|
||||
// ── Collapsed view ────────────────────────────────────────────────────────────
|
||||
const CollapsedView: React.FC<{
|
||||
mainStatus: Record<MainStepKey, StepStatus>;
|
||||
subStatus: Record<SubStepKey, StepStatus>;
|
||||
isDark: boolean;
|
||||
onExpand: () => void;
|
||||
}> = ({ mainStatus, subStatus, isDark, onExpand }) => {
|
||||
const sixstepUnlocked = mainStatus.sixstep !== 'pending';
|
||||
return (
|
||||
<div className={`h-full flex flex-col items-center py-3 gap-3 border-r ${
|
||||
isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/60'
|
||||
}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
title="展开进度面板"
|
||||
className={`w-9 h-9 rounded-xl flex items-center justify-center transition-colors ${
|
||||
isDark ? 'text-slate-400 hover:text-white hover:bg-slate-700/60' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 flex-1">
|
||||
{MAIN_STEPS.map((step) => {
|
||||
const status = mainStatus[step.key];
|
||||
const Icon = step.icon;
|
||||
const clr = step.color[isDark ? 'dark' : 'light'];
|
||||
return (
|
||||
<div key={step.key} className="relative flex flex-col items-center gap-1" title={step.title}>
|
||||
<div className={`w-9 h-9 rounded-xl border flex items-center justify-center ${clr} ${
|
||||
status === 'running' ? 'sixstep-pulse' : ''
|
||||
}`}>
|
||||
{status === 'running'
|
||||
? <Loader2 className="w-4 h-4 sixstep-spin-soft" />
|
||||
: <Icon className="w-4 h-4" />
|
||||
}
|
||||
</div>
|
||||
<span className={`w-2 h-2 rounded-full ${dotColor(status, isDark)}`} />
|
||||
|
||||
{step.key === 'sixstep' && sixstepUnlocked && (
|
||||
<div className="flex flex-col items-center gap-1 mt-1">
|
||||
{SUB_STEPS.map((sub) => {
|
||||
const ss = subStatus[sub.key];
|
||||
const SubIcon = sub.icon;
|
||||
const sc = sub.color[isDark ? 'dark' : 'light'];
|
||||
return (
|
||||
<div key={sub.key} title={sub.title} className={`w-7 h-7 rounded-lg border flex items-center justify-center ${sc} ${
|
||||
ss === 'running' ? 'sixstep-pulse' : ''
|
||||
}`}>
|
||||
{ss === 'running'
|
||||
? <Loader2 className="w-3 h-3 sixstep-spin-soft" />
|
||||
: <SubIcon className="w-3 h-3" />
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Expanded view ─────────────────────────────────────────────────────────────
|
||||
const SixStepProgress: React.FC<SixStepProgressProps> = ({
|
||||
mainStatus,
|
||||
subStatus,
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const sixstepUnlocked = mainStatus.sixstep !== 'pending';
|
||||
const sixstepRunning = mainStatus.sixstep === 'running';
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<CollapsedView
|
||||
mainStatus={mainStatus}
|
||||
subStatus={subStatus}
|
||||
isDark={isDark}
|
||||
onExpand={() => onToggleCollapse?.()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-full flex flex-col overflow-hidden border-r ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/60'}`}>
|
||||
{/* Header */}
|
||||
<div className={`px-4 py-3 border-b flex items-center gap-2 ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
|
||||
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center shrink-0 ${
|
||||
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
|
||||
}`}>
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>生成进度</div>
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>实时跟踪推演阶段</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
title="收起进度面板"
|
||||
className={`w-8 h-8 rounded-xl flex items-center justify-center transition-colors shrink-0 ${
|
||||
isDark ? 'text-slate-400 hover:text-white hover:bg-slate-700/60' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
|
||||
{MAIN_STEPS.map((step, idx) => {
|
||||
const status = mainStatus[step.key];
|
||||
const Icon = step.icon;
|
||||
const clr = step.color[isDark ? 'dark' : 'light'];
|
||||
const showSubBlock = step.key === 'sixstep' && sixstepUnlocked;
|
||||
|
||||
return (
|
||||
<div key={step.key} className="relative">
|
||||
{idx < MAIN_STEPS.length - 1 && (
|
||||
<div className={`absolute left-[22px] top-12 bottom-[-12px] w-px ${connectorColor(status, isDark)}`} />
|
||||
)}
|
||||
<div className={`relative rounded-2xl border p-3 transition-all ${cardBorder(status, isDark)}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-11 h-11 rounded-2xl border flex items-center justify-center shrink-0 transition-colors ${clr} ${
|
||||
status === 'running' ? 'sixstep-pulse' : ''
|
||||
}`}>
|
||||
{status === 'running'
|
||||
? <Loader2 className="w-5 h-5 sixstep-spin-soft" />
|
||||
: <Icon className="w-5 h-5" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className={`text-sm font-semibold truncate ${
|
||||
status === 'running'
|
||||
? isDark ? 'text-white' : 'text-gray-900'
|
||||
: status === 'done'
|
||||
? isDark ? 'text-slate-100' : 'text-gray-800'
|
||||
: isDark ? 'text-slate-300' : 'text-gray-600'
|
||||
}`}>{step.title}</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] flex items-center gap-1 ${
|
||||
status === 'done'
|
||||
? isDark ? 'bg-emerald-500/20 text-emerald-300 border border-emerald-400/30' : 'bg-emerald-50 text-emerald-600 border border-emerald-200'
|
||||
: status === 'running'
|
||||
? isDark ? 'bg-cyan-500/20 text-cyan-200 border border-cyan-400/40' : 'bg-red-50 text-red-600 border border-red-200'
|
||||
: isDark ? 'bg-slate-700/40 text-slate-400 border border-slate-600/40' : 'bg-gray-100 text-gray-500 border border-gray-200'
|
||||
}`}>
|
||||
<StatusIcon status={status} isDark={isDark} />
|
||||
<span>{status === 'done' ? '已完成' : status === 'running' ? '生成中' : '待开始'}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-xs mt-1 ${
|
||||
status === 'running'
|
||||
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
|
||||
: isDark ? 'text-slate-400' : 'text-gray-500'
|
||||
}`}>{step.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6步法子阶段 */}
|
||||
{showSubBlock && (
|
||||
<div className="mt-3 pl-1">
|
||||
<div className={`text-[10px] font-medium tracking-wider uppercase mb-2 ${
|
||||
isDark ? 'text-slate-500' : 'text-gray-400'
|
||||
}`}>
|
||||
6 步法 · 子阶段
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{SUB_STEPS.map((sub, subIdx) => {
|
||||
const ss = subStatus[sub.key];
|
||||
const SubIcon = sub.icon;
|
||||
const sc = sub.color[isDark ? 'dark' : 'light'];
|
||||
return (
|
||||
<div key={sub.key} className="relative pl-4 sixstep-fade-up">
|
||||
{subIdx < SUB_STEPS.length - 1 && (
|
||||
<div className={`absolute left-[7px] top-7 bottom-[-8px] w-px ${connectorColor(ss, isDark)}`} />
|
||||
)}
|
||||
<div className={`absolute left-0 top-3 w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center ${
|
||||
ss === 'running'
|
||||
? isDark ? 'bg-cyan-500/30 border-cyan-300' : 'bg-red-100 border-red-400'
|
||||
: ss === 'done'
|
||||
? isDark ? 'bg-emerald-500/30 border-emerald-300' : 'bg-emerald-100 border-emerald-400'
|
||||
: isDark ? 'bg-slate-900 border-slate-600' : 'bg-white border-gray-300'
|
||||
}`} />
|
||||
<div className={`flex items-start gap-2 rounded-xl border p-2 ${
|
||||
ss === 'running'
|
||||
? isDark ? 'border-cyan-400/40 bg-cyan-500/5' : 'border-red-300 bg-red-50/70'
|
||||
: isDark ? 'border-slate-700/50 bg-slate-800/40' : 'border-gray-200 bg-white'
|
||||
}`}>
|
||||
<div className={`w-7 h-7 rounded-lg border flex items-center justify-center shrink-0 ${sc} ${
|
||||
ss === 'running' ? 'sixstep-pulse' : ''
|
||||
}`}>
|
||||
{ss === 'running'
|
||||
? <Loader2 className="w-3.5 h-3.5 sixstep-spin-soft" />
|
||||
: <SubIcon className="w-3.5 h-3.5" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className={`text-xs font-semibold truncate ${
|
||||
ss === 'done' ? isDark ? 'text-slate-100' : 'text-gray-800'
|
||||
: ss === 'running' ? isDark ? 'text-white' : 'text-gray-900'
|
||||
: isDark ? 'text-slate-300' : 'text-gray-600'
|
||||
}`}>{sub.title}</div>
|
||||
<StatusIcon status={ss} isDark={isDark} />
|
||||
</div>
|
||||
<div className={`text-[11px] mt-0.5 ${
|
||||
ss === 'running'
|
||||
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
|
||||
: isDark ? 'text-slate-500' : 'text-gray-400'
|
||||
}`}>{sub.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{sixstepRunning && (
|
||||
<div className={`mt-2 text-[11px] flex items-center gap-1.5 ${isDark ? 'text-cyan-300' : 'text-red-500'}`}>
|
||||
<Loader2 className="w-3 h-3 sixstep-spin-soft" />
|
||||
<span className={isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}>推演正在进行...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SixStepProgress;
|
||||
@@ -0,0 +1,196 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Database,
|
||||
FileCheck2,
|
||||
Loader2,
|
||||
Megaphone,
|
||||
Target,
|
||||
Users,
|
||||
Workflow,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import type { MainStepKey, StepStatus, SubStepKey } from './types';
|
||||
|
||||
interface Props {
|
||||
mainStatus: Record<MainStepKey, StepStatus>;
|
||||
subStatus: Record<SubStepKey, StepStatus>;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
interface StepDef<T extends string> {
|
||||
key: T;
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: { dark: string; light: string };
|
||||
}
|
||||
|
||||
const MAIN_STEPS: StepDef<MainStepKey>[] = [
|
||||
{ key: 'info', title: '信息准备', desc: '梳理输入素材与上下文', icon: Database, color: { dark: 'text-sky-300 bg-sky-500/15 border-sky-400/40', light: 'text-sky-600 bg-sky-50 border-sky-200' } },
|
||||
{ key: 'sixstep', title: '6 步法推演', desc: '依次完成四个阶段推演', icon: Workflow, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-violet-50 border-violet-200' } },
|
||||
{ key: 'final', title: '最终结果生成', desc: '汇总各阶段产出,输出结论', icon: FileCheck2, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
|
||||
];
|
||||
|
||||
const SUB_STEPS: StepDef<SubStepKey>[] = [
|
||||
{ key: 'purpose', title: '目的生成', desc: '识别核心目的方向', icon: Target, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-orange-50 border-orange-200' } },
|
||||
{ key: 'actor', title: '行为体生成', desc: '匹配行为体角色', icon: Users, color: { dark: 'text-cyan-300 bg-cyan-500/15 border-cyan-400/40', light: 'text-red-600 bg-red-50 border-red-200' } },
|
||||
{ key: 'behavior', title: '预期行为与驱动因素', desc: '推演行为与动因', icon: Zap, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
|
||||
{ key: 'narrative', title: '叙事生成', desc: '构建叙事框架', icon: Megaphone, color: { dark: 'text-amber-300 bg-amber-500/15 border-amber-400/40', light: 'text-amber-600 bg-amber-50 border-amber-200' } },
|
||||
];
|
||||
|
||||
function statusLabel(s: StepStatus) {
|
||||
return s === 'done' ? '已完成' : s === 'running' ? '生成中' : '待开始';
|
||||
}
|
||||
|
||||
function nodeStateClass(status: StepStatus, isDark: boolean) {
|
||||
if (status === 'running') {
|
||||
return `${isDark ? 'ring-2 ring-cyan-400/30' : 'ring-2 ring-red-200'} sixstep-pulse`;
|
||||
}
|
||||
if (status === 'done') {
|
||||
return isDark ? 'ring-1 ring-emerald-400/40' : 'ring-1 ring-emerald-300';
|
||||
}
|
||||
return isDark ? 'opacity-55 saturate-50' : 'opacity-70 saturate-50';
|
||||
}
|
||||
|
||||
function connectorClass(status: StepStatus, isDark: boolean) {
|
||||
if (status === 'done') return isDark ? 'bg-emerald-400/60' : 'bg-emerald-400';
|
||||
if (status === 'running') return isDark ? 'bg-cyan-400/50' : 'bg-red-300';
|
||||
return isDark ? 'bg-slate-700/60' : 'bg-gray-200';
|
||||
}
|
||||
|
||||
interface NodeProps {
|
||||
status: StepStatus;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
size?: 'md' | 'sm';
|
||||
title: string;
|
||||
desc: string;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const StepNode: React.FC<NodeProps> = ({ status, Icon, color, size = 'md', title, desc, isDark }) => {
|
||||
const wrapSize = size === 'md' ? 'w-9 h-9' : 'w-7 h-7';
|
||||
const iconSize = size === 'md' ? 'w-4 h-4' : 'w-3.5 h-3.5';
|
||||
const badgeSize = size === 'md' ? 'w-4 h-4' : 'w-3.5 h-3.5';
|
||||
const badgeIcon = size === 'md' ? 'w-3 h-3' : 'w-2.5 h-2.5';
|
||||
|
||||
return (
|
||||
<div className="relative group shrink-0">
|
||||
<div
|
||||
className={`${wrapSize} rounded-xl border flex items-center justify-center transition-all ${color} ${nodeStateClass(status, isDark)}`}
|
||||
>
|
||||
{status === 'running'
|
||||
? <Loader2 className={`${iconSize} sixstep-spin-soft`} />
|
||||
: <Icon className={iconSize} />
|
||||
}
|
||||
</div>
|
||||
|
||||
{status === 'done' && (
|
||||
<div
|
||||
className={`absolute -bottom-0.5 -right-0.5 ${badgeSize} rounded-full flex items-center justify-center shadow-sm ${
|
||||
isDark ? 'bg-emerald-500 text-slate-900' : 'bg-emerald-500 text-white'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className={badgeIcon} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2.5 py-1.5 rounded-lg whitespace-nowrap pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-50 shadow-lg border ${
|
||||
isDark
|
||||
? 'bg-slate-900 text-slate-100 border-slate-700'
|
||||
: 'bg-white text-gray-800 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-semibold">{title}</div>
|
||||
<div
|
||||
className={`text-[11px] ${
|
||||
status === 'running'
|
||||
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
|
||||
: isDark ? 'text-slate-400' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{desc}
|
||||
</div>
|
||||
<div
|
||||
className={`text-[10px] mt-0.5 flex items-center gap-1 ${
|
||||
status === 'done'
|
||||
? isDark ? 'text-emerald-300' : 'text-emerald-600'
|
||||
: status === 'running'
|
||||
? isDark ? 'text-cyan-300' : 'text-red-500'
|
||||
: isDark ? 'text-slate-500' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{status === 'running' && <Loader2 className="w-2.5 h-2.5 sixstep-spin-soft" />}
|
||||
<span>{statusLabel(status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SixStepProgressHorizontal: React.FC<Props> = ({ mainStatus, subStatus, isDark }) => {
|
||||
const sixstepUnlocked = mainStatus.sixstep !== 'pending';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-nowrap py-1">
|
||||
{MAIN_STEPS.map((step, idx) => {
|
||||
const status = mainStatus[step.key];
|
||||
const clr = step.color[isDark ? 'dark' : 'light'];
|
||||
const isSixStep = step.key === 'sixstep';
|
||||
const prevStatus = idx > 0 ? mainStatus[MAIN_STEPS[idx - 1].key] : 'pending';
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div className={`h-[2px] w-5 rounded shrink-0 transition-colors ${connectorClass(prevStatus, isDark)}`} />
|
||||
)}
|
||||
<StepNode
|
||||
status={status}
|
||||
Icon={step.icon}
|
||||
color={clr}
|
||||
title={step.title}
|
||||
desc={step.desc}
|
||||
isDark={isDark}
|
||||
/>
|
||||
{isSixStep && sixstepUnlocked && (
|
||||
<>
|
||||
<div className={`h-[2px] w-3 rounded shrink-0 transition-colors ${connectorClass(status, isDark)}`} />
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-xl border shrink-0 ${
|
||||
isDark ? 'bg-slate-900/40 border-slate-700/60' : 'bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{SUB_STEPS.map((sub, sidx) => {
|
||||
const ss = subStatus[sub.key];
|
||||
const sc = sub.color[isDark ? 'dark' : 'light'];
|
||||
const prevSub = sidx > 0 ? subStatus[SUB_STEPS[sidx - 1].key] : 'pending';
|
||||
return (
|
||||
<React.Fragment key={sub.key}>
|
||||
{sidx > 0 && (
|
||||
<div className={`h-[2px] w-2 rounded shrink-0 transition-colors ${connectorClass(prevSub, isDark)}`} />
|
||||
)}
|
||||
<StepNode
|
||||
status={ss}
|
||||
Icon={sub.icon}
|
||||
color={sc}
|
||||
size="sm"
|
||||
title={sub.title}
|
||||
desc={sub.desc}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SixStepProgressHorizontal;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, XCircle } from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import type { StreamErrorItem } from './types';
|
||||
|
||||
interface StreamErrorNoticeProps {
|
||||
errors: StreamErrorItem[];
|
||||
}
|
||||
|
||||
const StreamErrorNotice: React.FC<StreamErrorNoticeProps> = ({ errors }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!errors || errors.length === 0) return null;
|
||||
|
||||
const hasErrors = errors.some((e) => e.level === 'error');
|
||||
const errorCount = errors.filter((e) => e.level === 'error').length;
|
||||
const warnCount = errors.filter((e) => e.level === 'warning').length;
|
||||
|
||||
let summaryText: string;
|
||||
if (hasErrors && warnCount > 0) {
|
||||
summaryText = `${errorCount} 个步骤失败,${warnCount} 个能力调用降级`;
|
||||
} else if (hasErrors) {
|
||||
summaryText = `${errorCount} 个步骤执行失败`;
|
||||
} else {
|
||||
summaryText = `${warnCount} 个能力调用失败,系统已继续执行`;
|
||||
}
|
||||
|
||||
const borderClass = hasErrors
|
||||
? isDark ? 'border-red-500/40 bg-red-500/10 text-red-200' : 'border-red-200 bg-red-50 text-red-700'
|
||||
: isDark ? 'border-amber-500/40 bg-amber-500/10 text-amber-200' : 'border-amber-200 bg-amber-50 text-amber-700';
|
||||
|
||||
const dividerClass = hasErrors
|
||||
? isDark ? 'border-red-500/20' : 'border-red-200'
|
||||
: isDark ? 'border-amber-500/20' : 'border-amber-200';
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border text-xs ${borderClass}`}>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{hasErrors
|
||||
? <XCircle className="w-3.5 h-3.5 shrink-0" />
|
||||
: <AlertTriangle className="w-3.5 h-3.5 shrink-0" />}
|
||||
<span className="flex-1 font-medium">{summaryText}</span>
|
||||
{expanded
|
||||
? <ChevronUp className="w-3.5 h-3.5 shrink-0 opacity-50" />
|
||||
: <ChevronDown className="w-3.5 h-3.5 shrink-0 opacity-50" />}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className={`border-t px-3 py-2 space-y-2.5 ${dividerClass}`}>
|
||||
{errors.map((err) => (
|
||||
<div key={err.errorId}>
|
||||
<div className="flex items-start gap-1.5">
|
||||
{err.level === 'error'
|
||||
? <XCircle className="w-3 h-3 mt-0.5 shrink-0" />
|
||||
: <AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />}
|
||||
<span className="leading-snug">{err.message}</span>
|
||||
</div>
|
||||
<div className={`mt-0.5 pl-[18px] font-mono text-[10px] opacity-40`}>
|
||||
错误 ID:{err.errorId}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamErrorNotice;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,826 @@
|
||||
# 6 步法推演问答页面 — 开发说明文档
|
||||
|
||||
> 所在目录:`src/components/angentchat/`(主页面 `SixStepChatPage.tsx`)
|
||||
> 路由地址:`/qa-center/sixstep-chat`
|
||||
> 菜单入口:问答中心 → **6bf**(侧栏文案见 `src/components/Sidebar.tsx`)
|
||||
> 同模块相关路由:`/qa-center/sixstep-agents`、`/qa-center/sixstep-agent-edit`
|
||||
> **最后更新(摘要)**:2026-05-12 — 流式 **`error_log`** / **`errors[]`** 与 `StreamErrorNotice`(后端 skill 失败摘要可含异常片段);`buildLayout` 根节点 **`rootTitle` / `rootDetail`**(已选专家);**`SixStepFlowFullscreen`** 父节点 **收起/展开子树**(过渡动画、缩略图随可见节点);**`/observability`** 独立全站路由。
|
||||
|
||||
---
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
「6bf 推演问答」由 `SixStepChatPage` 对接后端 **SixStep MCP Backend**。主链路通过 **`POST /api/orchestrate/stream`** 统一流式返回:后端先做 **意图识别**(`intent` 事件),再分支为 **查找专家**(`expert_*`)、**6 步法编排**(信息准备 → 目的 → 行为体 → 预期行为与驱动因素 → 叙事 → 最终结果)、**追问**(`follow_up`,`delta` 流式 Markdown)或 **澄清**(`clarify`)。支持 **结构化卡片** 与 **Markdown** 输出切换;结构化模式下可打开 **流程图沙箱 / 全屏流程图**(`SixStepFlowSandbox` / `SixStepFlowFullscreen`),展示编排返回的实体关系。
|
||||
|
||||
单 MCP 调试仍可直接调用 **`POST /api/agents/{key}/invoke`**(例如 `find_expert`、`create_expert`);6bf 主页面以编排流为主,专家查找也可由流内 `intent` 分支触发。对接细节见同目录 **`INTENT_INTEGRATION.md`**。
|
||||
|
||||
页面布局为 **顶栏 + 下方主体**:顶栏含页面标题、`SixStepProgressHorizontal`(横向主/子步骤进度)、以及结构化·Markdown、聊天记录、流程图沙箱等控制;主体为 **可收起的会话列表轨**(`SixStepChatHistory`)、**中间对话区与输入**(`SixStepConversation` + `ChatInputComposer`,含专家匹配与 MCP 选择弹窗)、以及 **可选的右侧沙箱**(宽度可拖拽,可关闭)。全屏流程图仍以 `fixed` 遮罩覆盖视口,独立于上述布局。
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 顶栏:标题 · SixStepProgressHorizontal · 输出格式 · 聊天记录 · 流程图 │
|
||||
├───┬────────────────────────────────────────────────────┬─────────┤
|
||||
│会话│ SixStepConversation(多轮结构化阶段) │ 流程图 │
|
||||
│列表│ + ChatInputComposer │ 沙箱 │
|
||||
│轨 │ │(可关闭) │
|
||||
└───┴────────────────────────────────────────────────────┴─────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、目录结构与各文件职责
|
||||
|
||||
```
|
||||
src/components/angentchat/
|
||||
│
|
||||
├── types.ts # 数据模型类型(含 phase / intent / rawSteps 等)
|
||||
├── mockData.ts # 本地模拟流式控制器(与 api 侧事件对齐,便于离线演示)
|
||||
├── api.ts # runIntentOrchestrateStream、runSixStepDeduction、runExpertSearch/Create
|
||||
├── flowLayout.ts # SVG 流程图布局
|
||||
├── styles.css # 动画与工具类
|
||||
│
|
||||
├── SixStepChatPage.tsx # 推演主页面(状态中枢)
|
||||
├── SixStepProgressHorizontal.tsx# 顶栏横向主/子步骤进度(主页面使用)
|
||||
├── SixStepProgress.tsx # 纵向进度面板(组件保留;布局以横向为准)
|
||||
├── SixStepChatHistory.tsx # 会话列表轨(展开/收起)
|
||||
├── SixStepConversation.tsx # 对话区(阶段卡片 + 专家匹配)
|
||||
├── SixStepCards.tsx # 阶段卡片原子组件
|
||||
├── ExpertMatchCards.tsx # 专家匹配卡片
|
||||
├── McpSelectModal.tsx # MCP 多选确认弹窗
|
||||
├── StreamErrorNotice.tsx # 助手消息底部:汇总 SSE error_log / 网络类错误的可折叠提示条
|
||||
├── SixStepFlowSandbox.tsx # 右侧流程图沙箱
|
||||
├── SixStepFlowFullscreen.tsx # 全屏流程图
|
||||
├── AgentCardList.tsx # 路由 sixstep-agents:MCP 卡片列表
|
||||
├── AgentEditPage.tsx # 路由 sixstep-agent-edit:单 MCP 调试编辑
|
||||
├── AgentChatMessageList.tsx # 智能体对话消息列表(编辑页等)
|
||||
├── INTENT_INTEGRATION.md # 意图流与前后端字段说明(对接备忘)
|
||||
└── doc.md # 本文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、数据模型(`types.ts`)
|
||||
|
||||
所有类型均从此文件导出,是各组件之间传递数据的唯一契约。
|
||||
|
||||
### 3.1 基础枚举
|
||||
|
||||
| 类型 | 取值 | 说明 |
|
||||
|------|------|------|
|
||||
| `MainStepKey` | `'info' \| 'sixstep' \| 'final'` | 三个主步骤的键名 |
|
||||
| `SubStepKey` | `'purpose' \| 'actor' \| 'behavior' \| 'narrative'` | 6步法的四个子阶段键名 |
|
||||
| `StepStatus` | `'pending' \| 'running' \| 'done'` | 步骤状态,驱动进度面板和动画 |
|
||||
| `SixStepPhase` | `'idle'` \| `'intent-detecting'` \| `'expert-match'` \| `'expert-confirm'` \| `'generate'` \| `'paused'` \| `'completed'` \| `'clarifying'` \| `'follow-up'` | 助手消息所处交互阶段(与编排流、停止、追问等配合) |
|
||||
| `SelectableMcpKey` | `'purpose' \| 'actor' \| 'behavior_factor' \| 'narrative'` | MCP 弹窗可选子链,会随请求传给后端 `selected_mcps` |
|
||||
|
||||
`BackendStepResult`:与后端 `step_end` 原始结构对齐,用于 **`continue_analysis`** 时把 `last_steps` 回传编排接口;`SixStepMessage.rawSteps` 在流式过程中累积,`resumedFrom` 对应 **`resume`** 事件中的继续起点。
|
||||
|
||||
### 3.2 卡片数据结构
|
||||
|
||||
每类卡片都有独立接口,并通过外键(`purposeId`、`actorId`、`driverId`)建立父子关联,构成树形层级关系。
|
||||
|
||||
```
|
||||
PurposeCard (目的)
|
||||
└─ ActorCard.purposeId → PurposeCard.id
|
||||
└─ BehaviorDriverCard.actorId → ActorCard.id
|
||||
└─ NarrativeCard.driverId → BehaviorDriverCard.id
|
||||
```
|
||||
|
||||
| 接口 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| `PurposeCard` | `id, index, title, reason` | 目的卡片,包含标题和推演理由 |
|
||||
| `ActorCard` | `id, index, purposeId, title, content` | 行为体卡片,`purposeId` 关联所属目的 |
|
||||
| `BehaviorDriverCard` | `id, index, actorId, expectedBehavior, driver` | 预期行为 + 驱动因素二合一卡片,`actorId` 关联所属行为体 |
|
||||
| `NarrativeCard` | `id, index, driverId, title, reason` | 叙事卡片,`driverId` 关联所属驱动因素(`BehaviorDriverCard.id`) |
|
||||
|
||||
### 3.3 阶段数据结构
|
||||
|
||||
每个子阶段有独立的 `*StageData` 接口:
|
||||
|
||||
```typescript
|
||||
interface PurposeStageData {
|
||||
thinking: string; // 模型思考过程文本(流式填充)
|
||||
cards: PurposeCard[]; // 已生成的卡片列表(逐张追加)
|
||||
complete: boolean; // 该阶段是否完全结束
|
||||
}
|
||||
// ActorStageData / BehaviorStageData / NarrativeStageData 结构类似
|
||||
```
|
||||
|
||||
### 3.4 消息总线:`SixStepMessage`
|
||||
|
||||
这是整个页面最核心的数据结构,一条助手消息包含所有推演状态:
|
||||
|
||||
```typescript
|
||||
interface SixStepMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content?: string;
|
||||
timestamp: Date;
|
||||
outputFormat?: 'structured' | 'markdown';
|
||||
markdownContent?: string;
|
||||
stages?: SixStepStages;
|
||||
finalAnswer?: string;
|
||||
mainStepStatus?: Record<MainStepKey, StepStatus>;
|
||||
subStepStatus?: Record<SubStepKey, StepStatus>;
|
||||
currentSubStep?: SubStepKey;
|
||||
currentMainStep?: MainStepKey;
|
||||
phase?: SixStepPhase;
|
||||
expertMatch?: ExpertMatchData;
|
||||
selectedExpert?: ExpertCard;
|
||||
selectedMcps?: SelectableMcpKey[];
|
||||
intent?: { name: string; confidence?: number; reason?: string };
|
||||
clarifyQuestion?: string;
|
||||
rawSteps?: BackendStepResult[];
|
||||
resumedFrom?: string;
|
||||
effectiveUserInput?: string;
|
||||
errors?: StreamErrorItem[];
|
||||
}
|
||||
```
|
||||
|
||||
**`errors` / `StreamErrorItem`**:`runIntentOrchestrateStream` 收到 SSE **`error_log`** 时追加;致命失败仍会走 **`error`** 事件并映射为同类结构。`SixStepConversation` 中 **`StreamErrorNotice`** 读取 `message.errors` 展示摘要(可折叠列表)。
|
||||
|
||||
**关键设计**:`mainStepStatus` / `subStepStatus` 驱动顶栏 `SixStepProgressHorizontal`;`stages` 驱动中间对话渲染;`currentSubStep` 驱动流程图沙箱蚂蚁线。`phase` / `intent` / `rawSteps` 与编排流、暂停继续、追问分支一致。所有信息集中在一个对象内,组件通过 props 按需取用。
|
||||
|
||||
---
|
||||
|
||||
## 四、模拟数据生成器(`mockData.ts`)
|
||||
|
||||
> **线上主路径**:`SixStepChatPage` 使用 **`runIntentOrchestrateStream`** 消费真实 `/api/orchestrate/stream`。`mockData.ts` 中的 **`runSixStepDeduction`** 仍保留,用于本地演示或与 `api.ts` 中纯编排解析逻辑对齐;新功能(意图、专家 SSE、`resume` 等)以 `runIntentOrchestrateStream` 为准。
|
||||
|
||||
### 4.1 种子数据
|
||||
|
||||
文件顶部定义了四组静态种子数组:
|
||||
|
||||
| 变量 | 长度 | 用途 |
|
||||
|------|------|------|
|
||||
| `PURPOSE_SEED` | 3 | 目的卡片内容模板 |
|
||||
| `ACTOR_SEED` | 7 | 行为体卡片内容模板 |
|
||||
| `BEHAVIOR_SEED` | 8 | 预期行为与驱动因素卡片内容模板 |
|
||||
| `NARRATIVE_SEED` | 8 | 叙事卡片内容模板 |
|
||||
|
||||
使用时循环取模(`(counter - 1) % SEED.length`),确保不越界。
|
||||
|
||||
### 4.2 卡片生成规则
|
||||
|
||||
| 阶段 | 数量规则 |
|
||||
|------|---------|
|
||||
| 目的 | 固定 3 张(取自 `PURPOSE_SEED` 全量) |
|
||||
| 行为体 | 每个目的对应的数量:第 0 个目的 2 张,其余 1 张,共 4 张 |
|
||||
| 预期行为/驱动 | 每个行为体:偶数索引 2 张,奇数索引 1 张,共 6 张 |
|
||||
| 叙事 | 每个预期行为:偶数索引 2 张,奇数索引 1 张,共 9 张 |
|
||||
|
||||
最终流程图节点总数:`1(智能体)+ 3 + 4 + 6×2(预期行为+驱动因素)+ 9 = 29 节点`。
|
||||
|
||||
### 4.3 流式控制器:`runSixStepDeduction`
|
||||
|
||||
```typescript
|
||||
export function runSixStepDeduction(
|
||||
message: SixStepMessage, // 直接在此对象上原地修改字段
|
||||
options: {
|
||||
onUpdate: (msg: SixStepMessage) => void; // 每次有新内容时回调
|
||||
onComplete?: () => void; // 全部完成时回调
|
||||
}
|
||||
): SixStepMockController
|
||||
```
|
||||
|
||||
返回的 `SixStepMockController` 包含:
|
||||
- `message`: 正在被修改的消息对象引用
|
||||
- `stop()`: 立即取消所有待执行的 `setTimeout`,中止推演
|
||||
- `promise`: 推演完成后 resolve 的 Promise(stop 后不再 resolve)
|
||||
|
||||
**内部时序**(所有延迟均可通过顶部常量调整):
|
||||
|
||||
```
|
||||
常量 默认值 说明
|
||||
TYPE_INTERVAL 28ms 每次打字输出 4 个字符后的等待间隔
|
||||
STAGE_GAP 380ms 阶段切换之间的等待时长
|
||||
```
|
||||
|
||||
执行顺序(伪代码):
|
||||
|
||||
```
|
||||
setMain('info', 'running') → emit
|
||||
wait(800ms) ← 模拟信息准备耗时
|
||||
setMain('info', 'done')
|
||||
setMain('sixstep', 'running') → emit
|
||||
wait(STAGE_GAP)
|
||||
|
||||
setSub('purpose', 'running') → emit
|
||||
typeInto(PURPOSE_THINKING) ← 逐字打出思考过程
|
||||
append cards 逐张(wait 280ms/张)
|
||||
setSub('purpose', 'done') → emit
|
||||
wait(STAGE_GAP)
|
||||
|
||||
setSub('actor', 'running') → emit
|
||||
typeInto(ACTOR_THINKING)
|
||||
append cards 逐张(wait 220ms/张)
|
||||
setSub('actor', 'done') → emit
|
||||
wait(STAGE_GAP)
|
||||
|
||||
setSub('behavior', 'running') → emit
|
||||
typeInto(BEHAVIOR_THINKING)
|
||||
append cards 逐张(wait 200ms/张)
|
||||
setSub('behavior', 'done') → emit
|
||||
wait(STAGE_GAP)
|
||||
|
||||
setSub('narrative', 'running') → emit
|
||||
typeInto(NARRATIVE_THINKING)
|
||||
append cards 逐张(wait 200ms/张)
|
||||
setSub('narrative', 'done')
|
||||
setMain('sixstep', 'done')
|
||||
setMain('final', 'running') → emit
|
||||
wait(STAGE_GAP)
|
||||
|
||||
typeInto(FINAL_ANSWER) ← 逐字打出最终结论
|
||||
setMain('final', 'done') → emit
|
||||
onComplete()
|
||||
```
|
||||
|
||||
**`emit` 机制**:每次调用 `onUpdate({ ...message, stages: { ...message.stages } })`,将消息对象浅拷贝后传给父组件,触发 React re-render。
|
||||
|
||||
---
|
||||
|
||||
## 五、流程图布局工具(`flowLayout.ts`)
|
||||
|
||||
此文件被沙箱和全屏组件共同引用,避免重复计算。
|
||||
|
||||
### 5.1 核心函数:`buildLayout`
|
||||
|
||||
```typescript
|
||||
function buildLayout(
|
||||
purposes: PurposeCard[],
|
||||
actors: ActorCard[],
|
||||
behaviors: BehaviorDriverCard[],
|
||||
narratives: NarrativeCard[],
|
||||
options?: BuildLayoutOptions // 可覆盖节点尺寸和间距,以及根节点文案
|
||||
): FlowLayout
|
||||
```
|
||||
|
||||
**`BuildLayoutOptions` 扩展**:除 `nodeWidth`、`nodeHeight`、`levelGap`、`nodeGap`、`paddingX`、`paddingY` 外,可选 **`rootTitle`**、**`rootDetail`**。用于将层级 0 的 `agent-root` 从默认「智能体 / 协同推演…」改为 **当前已选专家**(`SixStepFlowSandbox` / `SixStepFlowFullscreen` 在存在 `message.selectedExpert` 时传入专家姓名与简介摘要)。
|
||||
|
||||
返回 `FlowLayout`:
|
||||
```typescript
|
||||
interface FlowLayout {
|
||||
nodes: LayoutNode[]; // 已计算好坐标的节点列表
|
||||
edges: LayoutEdge[]; // 已确定连接关系和颜色对的边列表
|
||||
width: number; // 整体画布宽度(用于 SVG viewBox)
|
||||
height: number; // 整体画布高度
|
||||
}
|
||||
```
|
||||
|
||||
**布局算法**:改良的"叶节点优先后序遍历":
|
||||
|
||||
1. 从 agent 根节点开始,递归 DFS 处理子树
|
||||
2. 叶节点(无子节点的节点)按从左到右顺序分配 x 坐标,每次递增 `nodeWidth + nodeGap`
|
||||
3. 非叶节点的 x = (最左子节点 x + 最右子节点 x) / 2,确保父节点居中于子节点上方
|
||||
4. 所有节点的 y = `paddingY + level × (nodeHeight + levelGap)`,level 即树的深度
|
||||
|
||||
节点层级(`level`)对应关系:
|
||||
|
||||
| level | variant | 说明 |
|
||||
|-------|---------|------|
|
||||
| 0 | `agent` | 智能体根节点 |
|
||||
| 1 | `purpose` | 目的 |
|
||||
| 2 | `actor` | 行为体 |
|
||||
| 3 | `behavior` | 预期行为 |
|
||||
| 4 | `driver` | 驱动因素 |
|
||||
| 5 | `narrative` | 叙事 |
|
||||
|
||||
注意:`BehaviorDriverCard` 一张卡片会生成两个节点(`behavior` + `driver`),id 分别为 `${card.id}-behavior` 和 `${card.id}-driver`。
|
||||
|
||||
### 5.2 共享常量
|
||||
|
||||
| 常量 | 说明 |
|
||||
|------|------|
|
||||
| `EDGE_COLOR` | 按边类型分别定义深色/浅色主题颜色(5 组) |
|
||||
| `EDGE_LABEL` | 每种边类型的中文图例说明 |
|
||||
| `CURRENT_PAIR_BY_STAGE` | `SubStepKey → LayoutEdge['pair']` 映射,决定哪组边在哪个阶段展示蚂蚁线 |
|
||||
| `STATUS_TITLE` | `SubStepKey → string` 映射,沙箱/全屏顶部标题文字 |
|
||||
| `VARIANT_ABBREV` | 节点类型 → 简化缩写(沙箱专用,如 `purpose → 'md'`) |
|
||||
| `VARIANT_LABEL` | 节点类型 → 完整中文标签 |
|
||||
|
||||
### 5.3 辅助函数:`buildEdgePath`
|
||||
|
||||
生成 SVG 三次贝塞尔曲线路径字符串:
|
||||
|
||||
```
|
||||
M x1 y1 C x1 my, x2 my, x2 y2
|
||||
```
|
||||
|
||||
控制点均取中间 y 坐标(`(y1+y2)/2`),形成平滑 S 型连线,不与节点重叠。
|
||||
|
||||
---
|
||||
|
||||
## 六、CSS 动画(`styles.css`)
|
||||
|
||||
| 类名 | 动画 | 用途 |
|
||||
|------|------|------|
|
||||
| `.sixstep-ant-line` | `stroke-dasharray: 6 6` + `dashoffset` 循环 | SVG 边上的蚂蚁线流动效果(当阶段正在生成时) |
|
||||
| `.sixstep-pulse` | box-shadow 呼吸光晕 | 进度面板当前步骤图标、沙箱标题图标 |
|
||||
| `.sixstep-shimmer-text` / `...-light` | 渐变横扫文字 | 进度面板"生成中"文字、沙箱标题生成中状态 |
|
||||
| `.sixstep-spin-soft` | 360° 匀速旋转 | loading 图标 |
|
||||
| `.sixstep-fade-up` | 从下方淡入 | 子阶段卡片逐步出现 |
|
||||
| `.sixstep-node-pop` | scale 弹出 + 高光 | 流程图新增节点的弹出动画(持续 600ms) |
|
||||
| `.sixstep-ring-burst` | SVG circle `r` 从 0 扩散到 60,同时 opacity→0 | 节点出现时的扩散光环效果(持续 700ms) |
|
||||
| `.sixstep-canvas-grab` / `.sixstep-canvas-grabbing` | `cursor: grab/grabbing` | 流程图画布拖拽手型 |
|
||||
| `.sixstep-toolbar-btn` | 基础尺寸 + hover 过渡 | 工具栏图标按钮 |
|
||||
| `.sixstep-minimap-rect` | 静态样式 | 缩略图视口矩形指示框 |
|
||||
| `.sixstep-resize-handle` | 竖线拖拽手柄 | 沙箱左边缘的宽度调节拖拽条 |
|
||||
|
||||
---
|
||||
|
||||
## 七、主页面(`SixStepChatPage.tsx`)
|
||||
|
||||
### 7.1 职责
|
||||
|
||||
主页面是整个功能的**状态中枢**,负责:
|
||||
|
||||
- 多会话列表与 **`/api/conversations`** 持久化(`filter_key` / `filter_value` 标识 6bf 页)。
|
||||
- 创建并管理 **`runIntentOrchestrateStream`**(编排 + 意图)与 **`runExpertSearch` / `runExpertCreate`**(仍可用于「换一批专家」「创建专家」等直连 invoke)的控制器生命周期。
|
||||
- 将状态与专家相关回调分发给 `SixStepConversation`、`McpSelectModal`、`SixStepFlowSandbox` 等。
|
||||
- 管理沙箱宽度拖拽。
|
||||
|
||||
### 7.2 状态一览(节选)
|
||||
|
||||
| 状态 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `conversations` / `activeConvId` | 多会话 | 当前选中会话及其 `messages` |
|
||||
| `input` | `string` | 输入框文本 |
|
||||
| `isGenerating` | `boolean` | 是否正在请求流式接口 |
|
||||
| `outputFormat` | `'structured' \| 'markdown'` | 传给后端的 `output_format` |
|
||||
| `sandboxOpen` / `sandboxWidth` / `fullscreenOpen` | UI | 流程图沙箱与全屏 |
|
||||
| `historyOpen` / `loading` | UI / 加载 | 聊天记录侧栏与首屏拉取 |
|
||||
| `activeAssistantMsgId` | `string \| null` | 当前高亮/流式更新的助手消息 |
|
||||
| `mcpModal` | `{ open, messageId, expert }` | MCP 多选弹窗状态 |
|
||||
|
||||
### 7.3 Ref 一览
|
||||
|
||||
| Ref | 类型 | 用途 |
|
||||
|-----|------|------|
|
||||
| `controllerRef` | `SixStepMockController \| null` | `runIntentOrchestrateStream` 的 `stop()` |
|
||||
| `expertControllerRef` | `ExpertRunController \| null` | 专家搜索/创建流的 `stop()` |
|
||||
| `conversationsRef` | 会话数组 | 持久化前读取最新消息 |
|
||||
| `containerRef` / `resizeRef` | DOM / 拖拽 | 沙箱宽度拖拽 |
|
||||
|
||||
### 7.4 核心逻辑
|
||||
|
||||
**`handleSend`**
|
||||
|
||||
1. 追加用户消息;创建初始助手消息(`buildInitialAssistantMessage`,`phase` 随流程推进)。
|
||||
2. 调用 **`runIntentOrchestrateStream`**,请求体为 **`IntentOrchestrateRequest`**:`user_input`、`output_format`、`intent_mode`(默认 `auto`)、`current_phase`(含上一条助手为 **`paused`** 时传 `paused`)、`selected_expert`、`selected_mcps`、`expert_candidates`、**`last_steps`(来自上一条助手 `rawSteps`)**、`conversation_history` 等。
|
||||
- 暂停继续时会继承上一条助手的 `rawSteps` 与 `selectedMcps`(`pausedSelectedMcps`),并显式回传 `selected_mcps` / `last_steps`,避免恢复时丢失步骤选择与历史上下文。
|
||||
3. `onUpdate` 内更新当前会话中对应 `id` 的助手消息;结束后 `persistConversation`。
|
||||
|
||||
**专家与 MCP**
|
||||
|
||||
- 用户确认专家后 **`handleMcpModalConfirm`**:写入 `selectedExpert` / `selectedMcps`,并可用 **`intent_mode: 'force_orchestrate'`** 立即启动分析(无需用户再次输入)。
|
||||
- **`handleRefetchExpert`**:可用 `force_find_expert` 并排除已有候选。
|
||||
|
||||
**`handleStop`**
|
||||
|
||||
- `AbortController` 中止流;将未完成步骤从 `running` 收回;助手 **`phase` 置为 `paused`**,便于下次发送走 **`continue_analysis`** / `rerun_from_step` / `revise_input_continue` / `change_steps_continue` / `edit_stage_continue`。
|
||||
|
||||
**流内关键事件补充**
|
||||
|
||||
- `resume`:恢复继续分析时,会先把已有 `stages` 中的 `purpose/actor/behavior` 卡片回填到转换缓存,确保后续 `step_end` 的父子映射准确。
|
||||
- `rerun`:前端会按 `affected_steps` 清空受影响阶段(`stages`、`subStepStatus`、`rawSteps` 与局部卡片缓存),再接收重跑后的 `step_*`。
|
||||
- `change_steps`:按 `removed_steps` 清理被移除阶段与 `rawSteps`,保留未移除阶段并继续执行 `steps_to_run`。
|
||||
- `edit_apply`:先更新被编辑卡片内容,再按 `affected_steps` 清理后续阶段并触发重跑。
|
||||
- `intent`:包含 `slots`(如 `step`、`new_input`),用于更细粒度前端状态更新。
|
||||
|
||||
**沙箱宽度拖拽**:逻辑同前(`mousemove` / `mouseup`,保证对话区最小宽度)。
|
||||
|
||||
**活跃助手消息派生**:
|
||||
|
||||
```typescript
|
||||
const activeAssistantId = /* activeAssistantMsgId 或最后一条 assistant */;
|
||||
const activeAssistant = messages.find((m) => m.id === activeAssistantId);
|
||||
const liveMainStatus = activeAssistant?.mainStepStatus ?? DEFAULT_MAIN_STATUS;
|
||||
const liveSubStatus = activeAssistant?.subStepStatus ?? DEFAULT_SUB_STATUS;
|
||||
```
|
||||
|
||||
`liveMainStatus` / `liveSubStatus` 传给 **`SixStepProgressHorizontal`**;`activeAssistant` 传给沙箱与全屏。
|
||||
|
||||
### 7.5 Props 传递图
|
||||
|
||||
```
|
||||
SixStepChatPage
|
||||
│
|
||||
├─► SixStepChatHistory # 会话列表(与顶栏「聊天记录」联动)
|
||||
├─► SixStepProgressHorizontal # mainStatus / subStatus / isDark
|
||||
├─► SixStepConversation
|
||||
│ messages, isGenerating, activeMessageId,
|
||||
│ expertHandlers(选专家 / 换一批 / 创建 / 确认)
|
||||
├─► ChatInputComposer # onSend → handleSend, onStop → handleStop
|
||||
├─► SixStepFlowSandbox # message={activeAssistant}
|
||||
├─► SixStepFlowFullscreen
|
||||
└─► McpSelectModal # 专家确认后选 MCP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、左侧进度面板(`SixStepProgress.tsx`)
|
||||
|
||||
> **主页面顶栏**使用的是 **`SixStepProgressHorizontal.tsx`**(横向主/子步骤);本节描述纵向 `SixStepProgress` 的 Props 与交互,组件仍保留供其他布局复用。
|
||||
|
||||
### 8.1 Props
|
||||
|
||||
| Prop | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `mainStatus` | `Record<MainStepKey, StepStatus>` | ✓ | 三主步骤状态 |
|
||||
| `subStatus` | `Record<SubStepKey, StepStatus>` | ✓ | 四子阶段状态 |
|
||||
| `collapsed` | `boolean` | 可选 | 是否折叠,默认 `false` |
|
||||
| `onToggleCollapse` | `() => void` | 可选 | 展开/收起回调 |
|
||||
|
||||
### 8.2 展开视图(`collapsed = false`)
|
||||
|
||||
从上到下展示三个主步骤卡片,每个卡片包含:
|
||||
- 彩色图标(每个步骤有独立配色,running 时呼吸脉冲)
|
||||
- 标题 + 进度徽标(`已完成 / 生成中 / 待开始`)
|
||||
- 副文本描述(running 时展示 shimmer 动画)
|
||||
- 步骤之间用竖线连接(完成后变绿色)
|
||||
|
||||
当 `sixstep` 阶段进入 running 或 done,在其卡片内部展开四个子阶段列表(`SUB_STEPS`),每个子阶段包含:
|
||||
- 彩色图标(与流程图对应色)
|
||||
- 标题 + 状态图标
|
||||
- 时间线左侧的节点圆点 + 连接竖线
|
||||
|
||||
右上角收起按钮(`ChevronLeft`)触发 `onToggleCollapse`。
|
||||
|
||||
### 8.3 折叠视图(`collapsed = true`)
|
||||
|
||||
渲染轻量的 `CollapsedView` 子组件:
|
||||
- 宽度由父容器通过 `w-14` 控制(56px),有 `transition-all duration-300` 过渡动画
|
||||
- 顶部 `ChevronRight` 按钮用于展开
|
||||
- 竖向排列三个主步骤彩色图标 + 状态圆点
|
||||
- 若 `sixstep` 已解锁,在其下方额外展示四个小尺寸子阶段图标(`w-7 h-7`)
|
||||
- 所有元素均有 `title` 属性,鼠标悬停显示步骤名称
|
||||
|
||||
### 8.4 配色方案
|
||||
|
||||
步骤颜色定义在 `MAIN_STEPS` 和 `SUB_STEPS` 数组的 `color` 字段中:
|
||||
|
||||
| 步骤 | 深色 | 浅色 |
|
||||
|------|------|------|
|
||||
| 信息准备 | sky-300 / sky-500 | sky-600 / sky-50 |
|
||||
| 6步法推演 | violet-300 / violet-500 | violet-600 / violet-50 |
|
||||
| 最终结果 | emerald-300 / emerald-500 | emerald-600 / emerald-50 |
|
||||
| 目的 | violet-300 | orange |
|
||||
| 行为体 | cyan-300 | red |
|
||||
| 预期行为/驱动 | emerald-300 | emerald |
|
||||
| 叙事 | amber-300 | amber |
|
||||
|
||||
---
|
||||
|
||||
## 九、对话列表(`SixStepConversation.tsx`)
|
||||
|
||||
### 9.1 Props
|
||||
|
||||
| Prop | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `messages` | `SixStepMessage[]` | 全部消息 |
|
||||
| `isGenerating` | `boolean` | 是否正在生成 |
|
||||
| `activeMessageId` | `string \| null` | 当前流式聚焦的助手消息 id |
|
||||
| `expertHandlers` | 可选对象 | `onSelectExpert` / `onRefetchExpert` / `onCreateExpert` / `onConfirmExpert` |
|
||||
|
||||
### 9.2 消息渲染策略
|
||||
|
||||
每条消息根据 `role` 字段分叉:
|
||||
|
||||
- `role === 'user'` → 渲染 `UserMessage`:右对齐气泡 + 用户头像
|
||||
- `role === 'assistant'` → 渲染 `AssistantMessage`:左对齐 + 机器人头像(生成中时呼吸脉冲 + 星光图标)
|
||||
|
||||
消息列表容器 `ref` 监听内容变化,每次 `messages` 或 `isGenerating` 变化后自动滚动到底部。
|
||||
|
||||
### 9.3 助手消息 `AssistantMessage`
|
||||
|
||||
内部读取 `message.stages` 和 `message.mainStepStatus`、`message.subStepStatus`,根据是否存在各阶段数据来条件渲染对应的阶段块。底部(及部分分支末尾)挂载 **`StreamErrorNotice`**,展示 **`message.errors`**(来自 SSE `error_log` 等)。
|
||||
|
||||
助手消息结构示意:
|
||||
|
||||
```
|
||||
AssistantMessage
|
||||
│
|
||||
├─ 信息准备中(仅当 info=running 且无任何 stage)→ shimmer 提示
|
||||
│
|
||||
├─ PurposeStage (当 stages.purpose 存在时)
|
||||
│ ├─ InlineCollapsible (以 StageHeader 为标题)
|
||||
│ │ ├─ ThinkingBlock (思考过程)
|
||||
│ │ └─ InlineCollapsible (目的卡片列表)
|
||||
│ │ └─ PurposeCardItem × N
|
||||
│
|
||||
├─ ActorStage (当 stages.actor 存在时)
|
||||
│ ├─ InlineCollapsible
|
||||
│ │ ├─ ThinkingBlock
|
||||
│ │ └─ InlineCollapsible (行为体卡片列表)
|
||||
│ │ └─ 按 purpose 分组
|
||||
│ │ ├─ ParentGroupHeader (所属目的)
|
||||
│ │ └─ ActorCardItem × N
|
||||
│
|
||||
├─ BehaviorStage (当 stages.behavior 存在时)
|
||||
│ ├─ InlineCollapsible
|
||||
│ │ ├─ ThinkingBlock
|
||||
│ │ └─ InlineCollapsible (预期行为/驱动因素卡片列表)
|
||||
│ │ └─ 按 actor 分组
|
||||
│ │ ├─ ParentGroupHeader (所属行为体)
|
||||
│ │ └─ BehaviorCardItem × N(2列网格)
|
||||
│
|
||||
├─ NarrativeStage (当 stages.narrative 存在时)
|
||||
│ ├─ InlineCollapsible
|
||||
│ │ ├─ ThinkingBlock
|
||||
│ │ └─ InlineCollapsible (叙事卡片列表)
|
||||
│ │ └─ 按 driver 分组
|
||||
│ │ ├─ ParentGroupHeader (所属驱动因素)
|
||||
│ │ └─ NarrativeCardItem × N
|
||||
│
|
||||
└─ FinalAnswer (当 finalAnswer 存在或 final=running 时)
|
||||
│
|
||||
└─ StreamErrorNotice (当 message.errors 非空)
|
||||
```
|
||||
|
||||
**分组计算**:`ActorStage`、`BehaviorStage`、`NarrativeStage` 都在内部用 `useMemo` 计算分组,将卡片按父 ID 聚合到 Map,再按父实体列表顺序输出。
|
||||
|
||||
---
|
||||
|
||||
## 十、卡片原子组件(`SixStepCards.tsx`)
|
||||
|
||||
此文件作为"组件库",为对话列表提供所有可复用的卡片和容器组件。
|
||||
|
||||
### 10.1 `InlineCollapsible`
|
||||
|
||||
通用折叠面板,思考过程区块、卡片列表区块均复用此组件。
|
||||
|
||||
| Prop | 说明 |
|
||||
|------|------|
|
||||
| `title` | 标题区 ReactNode |
|
||||
| `children` | 折叠内容 |
|
||||
| `defaultOpen` | 默认展开状态(默认 `true`) |
|
||||
| `variant` | `'default'`(标准边框背景)或 `'subtle'`(更淡)|
|
||||
|
||||
展开/收起使用 `grid-template-rows` 动画(`0fr → 1fr`),配合 `opacity` 过渡,无需 JS 计算高度。
|
||||
|
||||
### 10.2 各卡片组件
|
||||
|
||||
| 组件 | 卡片类型 | 布局特点 |
|
||||
|------|---------|---------|
|
||||
| `PurposeCardItem` | 目的 | 紫/橙渐变背景,索引徽标 + Target 图标 + 标题 + 理由 |
|
||||
| `ActorCardItem` | 行为体 | 青/红渐变背景,UserCheck 图标 + 标题 + 内容 |
|
||||
| `BehaviorCardItem` | 预期行为/驱动因素 | 翠绿渐变背景,上下两部分:TrendingUp 图标+预期行为 / Zap 图标+驱动因素 |
|
||||
| `NarrativeCardItem` | 叙事 | 琥珀渐变背景,ScrollText 图标 + 标题 + 理由 |
|
||||
|
||||
所有卡片均有 `.sixstep-fade-up` 进入动画,`hover` 时边框加深。
|
||||
|
||||
### 10.3 `ParentGroupHeader`
|
||||
|
||||
显示某类卡片所属父实体名称的标签行,用于行为体、预期行为/驱动、叙事列表的分组标题。
|
||||
|
||||
| Prop | 说明 |
|
||||
|------|------|
|
||||
| `label` | 关系说明(如"所属目的") |
|
||||
| `parentTitle` | 父实体名称 |
|
||||
| `count` | 该组卡片数量 |
|
||||
| `variant` | `'actor' \| 'behavior' \| 'narrative'`,决定图标和配色 |
|
||||
|
||||
### 10.4 `ThinkingBlock`
|
||||
|
||||
展示思考过程文本的区块,`loading` 为 `true` 时标题行展示 "生成中" shimmer 文字。
|
||||
|
||||
### 10.5 `StageHeader`
|
||||
|
||||
折叠面板标题,包含阶段图标、阶段标题、卡片数量徽标、状态徽标(已完成/生成中)。`loading = true` 时图标呼吸脉冲,副文本展示 shimmer 动画。
|
||||
|
||||
---
|
||||
|
||||
## 十一、流程图沙箱(`SixStepFlowSandbox.tsx`)
|
||||
|
||||
### 11.1 Props
|
||||
|
||||
| Prop | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `message` | `SixStepMessage \| undefined` | 活跃助手消息,用于提取各阶段卡片数据 |
|
||||
| `width` | `number` | 当前沙箱宽度(px),由主页面拖拽控制 |
|
||||
| `onResizeStart` | `(e: React.MouseEvent) => void` | 拖拽手柄 mousedown 回调 |
|
||||
| `onClose` | `() => void` | 关闭沙箱回调 |
|
||||
| `onOpenFullscreen` | `() => void` | 打开全屏回调 |
|
||||
|
||||
### 11.2 布局参数(`COMPACT_OPTIONS`)
|
||||
|
||||
沙箱使用缩小版布局,节点小于全屏版:
|
||||
|
||||
```typescript
|
||||
const COMPACT_OPTIONS = {
|
||||
nodeWidth: 120, nodeHeight: 60,
|
||||
levelGap: 80, nodeGap: 24,
|
||||
paddingX: 32, paddingY: 28,
|
||||
};
|
||||
```
|
||||
|
||||
### 11.3 简化节点(`CompactNode`)
|
||||
|
||||
每个节点只显示**彩色图标 + 阶段缩写**(`VARIANT_ABBREV`):
|
||||
|
||||
| 层级 | 缩写 |
|
||||
|------|------|
|
||||
| 智能体(或根标题改为已选专家名时仍为 variant `agent`) | AI |
|
||||
| 目的 | md |
|
||||
| 行为体 | xwt |
|
||||
| 预期行为 | yqxw |
|
||||
| 驱动因素 | qdys |
|
||||
| 叙事 | xs |
|
||||
|
||||
底部固定图例说明对应关系。
|
||||
|
||||
### 11.4 新增节点动画
|
||||
|
||||
通过 `seenIdsRef`(`Set<string>`)记录已渲染过的节点 ID。每次 `layout.nodes` 变化时,找出新增的 ID,将其加入 `appearingIds` 状态,700ms 后自动移除。
|
||||
|
||||
`CompactNode` 接收 `appearing` prop,为 `true` 时:
|
||||
1. 为 `<g>` 添加 `.sixstep-node-pop` CSS 类(scale 弹出)
|
||||
2. 在节点中心绘制一个 SVG `<circle>` 并添加 `.sixstep-ring-burst` CSS 类(扩散光环)
|
||||
|
||||
### 11.5 交互能力
|
||||
|
||||
**平移(Pan)**:
|
||||
- `mousedown` 记录起始坐标和初始 `tx/ty` 到 `panStart.current`
|
||||
- 通过 `useEffect` 监听 `isPanning` 状态,动态注册/注销 `window` 的 `mousemove` 和 `mouseup`
|
||||
- `mousemove` 计算偏移量 `(e.clientX - startX, e.clientY - startY)` 叠加到初始平移值
|
||||
|
||||
**缩放(Zoom)**:
|
||||
- 通过 `useEffect` 向 `containerRef` 注册原生 `wheel` 事件(`{ passive: false }`),确保 `e.preventDefault()` 生效,不触发浏览器页面缩放
|
||||
- 以鼠标位置为缩放中心:`nextTx = mx - (mx - prev.tx) * (nextScale / prev.scale)`
|
||||
- 缩放范围:0.3x ~ 2.5x
|
||||
|
||||
**工具栏**:
|
||||
| 按钮 | 功能 |
|
||||
|------|------|
|
||||
| `+` | 按 1.2 倍放大 |
|
||||
| `-` | 按 1.2 倍缩小 |
|
||||
| ↺ | 恢复 1:1(translate 0,0 scale 1) |
|
||||
| ⊕ | 定位画布中央(保持当前缩放比,调整平移使图形居中) |
|
||||
| ⤢ | 触发 `onOpenFullscreen` |
|
||||
|
||||
首次有内容时自动执行 `fitToView`(等比缩放 + 居中),由 `fitAppliedRef` 控制只执行一次。
|
||||
|
||||
### 11.6 连线说明
|
||||
|
||||
SVG 中的 `<defs>` 为每种边类型各定义一个箭头 marker(id 加 `sixstep-sandbox-` 前缀,与全屏版 marker 不冲突)。当 `activePair === edge.pair` 时,该边添加 `.sixstep-ant-line` CSS 类展示蚂蚁线动画。
|
||||
|
||||
### 11.7 调整宽度
|
||||
|
||||
左侧有一个 `position: absolute` 的 `.sixstep-resize-handle` 竖线拖拽条,`mousedown` 触发 `onResizeStart`(由主页面处理具体逻辑)。
|
||||
|
||||
---
|
||||
|
||||
## 十二、全屏流程图视图(`SixStepFlowFullscreen.tsx`)
|
||||
|
||||
### 12.1 Props
|
||||
|
||||
| Prop | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `message` | `SixStepMessage \| undefined` | 与沙箱相同的活跃助手消息 |
|
||||
| `onClose` | `() => void` | 关闭全屏回调 |
|
||||
|
||||
### 12.2 与沙箱的区别
|
||||
|
||||
| 特性 | 沙箱 | 全屏 |
|
||||
|------|------|------|
|
||||
| 布局参数 | 紧凑(节点 120×60) | 标准(节点 200×84) |
|
||||
| 节点内容 | 缩写 + 图标 | 完整标题 + 详情 + 图标 |
|
||||
| 节点出现动画 | 有(pop + ring-burst) | 无 |
|
||||
| 子树收起/展开 | 无 | 有(点击**有子节点**的父节点;右下角「-」收起 / 「+」展开;画布左下角有操作提示) |
|
||||
| 缩略图 | 无 | 有(右下角;仅绘制当前可见节点) |
|
||||
| 下载 | 无 | 有(导出 SVG) |
|
||||
| Esc 关闭 | 无 | 有 |
|
||||
| 层级 | flex 子元素 | `fixed inset-0 z-50`(全视口遮罩) |
|
||||
| 缩放范围 | 0.3x ~ 2.5x | 0.2x ~ 3x |
|
||||
|
||||
### 12.3 详细节点(`DetailNode`)
|
||||
|
||||
使用 `foreignObject` 嵌入 HTML,展示:
|
||||
- 彩色图标包裹 + 大写类型标签(`VARIANT_LABEL`)
|
||||
- 节点标题(`line-clamp-1`)
|
||||
- 详情内容(`line-clamp-2`)
|
||||
|
||||
深色/浅色主题的渐变背景、图标颜色均通过 `VARIANT_TOKEN` 查表获取。
|
||||
|
||||
**收起子树**:当 `flowLayout` 中节点带 **`children`** 时,卡片右上角显示 **`+` / `-`**,点击节点(或该角标)切换 **`collapsedNodeIds`**:自该节点向下的后代节点与相关边不再纳入 **`targetVisibleNodeSet`**,并以约 **240ms** 的透明度过渡(进入/离开集合合并渲染,避免闪烁)。重新生成布局时会剔除已不存在的节点 id,避免残留收起状态。根节点若被选为专家展示,仍可按树结构收起其下子链。
|
||||
|
||||
### 12.4 缩略图(`Minimap`)
|
||||
|
||||
固定 220×150px,渲染在右下角。
|
||||
|
||||
内部逻辑:
|
||||
1. 计算整体布局与 minimap 尺寸的缩放比 `ratio = Math.min((W-10)/layoutW, (H-10)/layoutH)`
|
||||
2. 按 `ratio` 缩小绘制每个节点矩形(使用 `MINIMAP_VARIANT_FILL` 颜色,无文字)
|
||||
3. 根据当前 `transform`(`tx, ty, scale`)和视口尺寸,计算可见区域在世界坐标的矩形,再乘 `ratio` 映射到 minimap 坐标系,绘制蓝色描边矩形(`.sixstep-minimap-rect`)
|
||||
4. 点击 minimap:将点击的 minimap 坐标逆映射到世界坐标,调用 `onJump` 将视口移动到以该点为中心
|
||||
|
||||
### 12.5 SVG 下载
|
||||
|
||||
点击下载按钮时:
|
||||
1. 克隆 `svgRef.current`(当前全屏 SVG 元素)
|
||||
2. 设置 `width/height` 为实际布局尺寸,移除 transform
|
||||
3. 用 `XMLSerializer` 序列化为字符串
|
||||
4. 创建 `Blob` 和临时 `<a>` 标签,触发下载
|
||||
5. 文件名格式:`6步法推演-YYYY-MM-DD-HH-mm-ss.svg`
|
||||
|
||||
> ⚠️ 注意:由于节点内容在 `foreignObject` 中使用 HTML + CSS 类名,下载的 SVG 打开时若外部查看器不支持 CSS,样式会丢失。可在未来改为纯 SVG 文本节点渲染以提升兼容性。
|
||||
|
||||
---
|
||||
|
||||
## 十三、整体数据流图
|
||||
|
||||
```
|
||||
用户输入(回车)
|
||||
│
|
||||
▼
|
||||
SixStepChatPage.handleSend()
|
||||
│
|
||||
├─► 当前会话追加 UserMessage + 初始 AssistantMessage
|
||||
│
|
||||
└─► runIntentOrchestrateStream(assistant, { onUpdate, onComplete }, IntentOrchestrateRequest)
|
||||
│
|
||||
│ readSSE('/api/orchestrate/stream', body)
|
||||
│
|
||||
│ intent / expert_* / clarify / resume / rerun / change_steps / edit_apply
|
||||
│ start / step_* / flow / done / delta(追问或 markdown)
|
||||
│ 每次 onUpdate → 更新会话中该 assistant 的 SixStepMessage
|
||||
│
|
||||
▼ React re-render
|
||||
│
|
||||
├─► SixStepProgressHorizontal(liveMainStatus / liveSubStatus)
|
||||
├─► SixStepConversation(messages、expertHandlers、intent/clarify 等)
|
||||
├─► SixStepFlowSandbox / Fullscreen(stages → buildLayout)
|
||||
└─► persistConversation(SQLite)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十四、路由注册
|
||||
|
||||
**`src/App.tsx`**:
|
||||
```tsx
|
||||
// 路由定义
|
||||
<Route path="sixstep-chat" element={<SixStepChatPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
|
||||
// 导航路径映射(handleNavigate 函数)
|
||||
else if ([..., 'sixstep-chat'].includes(page)) {
|
||||
path = `/qa-center/${page}`;
|
||||
}
|
||||
```
|
||||
|
||||
**可观测性页面**(调用耗时 / 错误日志筛选):全站路由 **`/observability`**,侧栏顶级「可观测性」,对接后端 **`GET /api/observability/calls`**、**`/errors`**、**`/options`** 等(详见 `backend/doc.md`)。与 6bf 页面独立,不在 `/qa-center` 前缀下。
|
||||
|
||||
**`src/components/Sidebar.tsx`**:
|
||||
```tsx
|
||||
// 菜单配置(问答中心 children)
|
||||
{ id: 'sixstep-chat', label: '6bf', icon: Workflow }
|
||||
|
||||
// qaPages 集合(控制问答中心父菜单高亮)
|
||||
const qaPages = new Set([..., 'sixstep-chat']);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十五、流式错误与 `error_log`(`api.ts`)
|
||||
|
||||
编排 SSE 除 **`error`** 外,可能下发 **`error_log`**(步骤失败摘要、skill 降级等)。`runIntentOrchestrateStream` 将其归一为 **`StreamErrorItem`** 并 **`push` 到 `message.errors`**;网络异常也会写入类似结构。后端在可执行 **skill 失败已跳过**时,**`message` 文案**可能附带 **异常原因前若干字符**(与 `backend/app/mcp/base.py` 截断策略一致),便于在气泡内区分超时与其它错误。
|
||||
|
||||
**`StreamErrorNotice`**:仅在对应助手气泡内展示,支持折叠展开、错误/警告计数摘要;不参与 `showAnalysisContent` 隐藏逻辑(专家阶段亦可显示)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 十六、扩展指南
|
||||
|
||||
### 接入编排与意图流
|
||||
|
||||
1. 主入口为 **`runIntentOrchestrateStream`**(`api.ts`),与后端 **`/api/orchestrate/stream`** 事件一一对应;扩展新意图时通常同时改 **`backend/app/intent/detector.py`** 与 **`runIntentOrchestrateStream`** 的分支解析。
|
||||
- 当前除 `continue_analysis` 外,还支持 `rerun_from_step`、`revise_input_continue`、`change_steps_continue`、`edit_stage_continue` 分支。
|
||||
2. 保持 **`onUpdate(SixStepMessage)`** 契约:`SixStepChatPage` 只需合并助手消息字段。
|
||||
3. 纯编排、无意图分支的调试可继续用 **`runSixStepDeduction`**(同文件内,仅 `start` / `step_*` / `flow` / `done`)。
|
||||
|
||||
详见 **`INTENT_INTEGRATION.md`** 与仓库 **`docs/xvqiu.md`**。
|
||||
|
||||
### 某子步(如行为体、叙事)体感很慢时
|
||||
|
||||
前端 **`step_end`** 事件里会带上该步的 **`duration_ms`**(整步总耗时),便于在 UI 或调试时对比各步。
|
||||
|
||||
更细拆分(skill 耗时 vs 拼 prompt 扩展 vs LLM)在**后端**完成:每次 `POST /api/agents/{key}/invoke` 及编排内对 `MCPAgent.invoke` 的调用,服务端会打 **`mcp_timing`** / **`mcp_skill_timing`** 日志(见 **`backend/doc.md`**「MCP 调用接口」与 **`backend/DEVELOPMENT.md`** §5.2)。排查时在运行 `python -m app.main` 的终端查看即可;无需改前端请求体。
|
||||
|
||||
### 替换流程图引擎(如 G6 / React Flow)
|
||||
|
||||
仅需替换 `SixStepFlowSandbox.tsx` 和 `SixStepFlowFullscreen.tsx` 中的 SVG 渲染部分:
|
||||
- `buildLayout()` 的 `LayoutNode` 数组可直接转换为 G6 节点
|
||||
- `LayoutEdge` 数组可直接转换为 G6 边
|
||||
- `CURRENT_PAIR_BY_STAGE` 和 `EDGE_COLOR` 仍适用
|
||||
|
||||
### 新增推演子阶段
|
||||
|
||||
1. 在 `types.ts` 的 `SubStepKey` 中新增键名
|
||||
2. 在 `SixStepStages` 中新增对应 `*StageData` 字段
|
||||
3. 在 `mockData.ts` 的 `runSixStepDeduction` 中新增生成逻辑(若仍需要离线演示)
|
||||
4. 在 `SixStepConversation.tsx` 的 `AssistantMessage` 中新增阶段渲染块
|
||||
5. 在 `SixStepProgress.tsx` / **`SixStepProgressHorizontal.tsx`** 的 `SUB_STEPS` 中新增步骤项
|
||||
6. 若需要流程图新层级,在 `flowLayout.ts` 的 `buildLayout` 中新增节点构建逻辑
|
||||
@@ -0,0 +1,266 @@
|
||||
import type {
|
||||
ActorCard,
|
||||
BehaviorDriverCard,
|
||||
NarrativeCard,
|
||||
PurposeCard,
|
||||
SubStepKey,
|
||||
} from './types';
|
||||
|
||||
export interface LayoutNode {
|
||||
id: string;
|
||||
level: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
variant: 'agent' | 'purpose' | 'actor' | 'behavior' | 'driver' | 'narrative';
|
||||
title: string;
|
||||
detail: string;
|
||||
children: string[];
|
||||
parent?: string;
|
||||
}
|
||||
|
||||
export interface LayoutEdge {
|
||||
id: string;
|
||||
fromId: string;
|
||||
toId: string;
|
||||
pair: 'agent-purpose' | 'purpose-actor' | 'actor-behavior' | 'behavior-driver' | 'driver-narrative';
|
||||
}
|
||||
|
||||
export interface FlowLayout {
|
||||
nodes: LayoutNode[];
|
||||
edges: LayoutEdge[];
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface BuildLayoutOptions {
|
||||
nodeWidth?: number;
|
||||
nodeHeight?: number;
|
||||
levelGap?: number;
|
||||
nodeGap?: number;
|
||||
paddingX?: number;
|
||||
paddingY?: number;
|
||||
rootTitle?: string;
|
||||
rootDetail?: string;
|
||||
}
|
||||
|
||||
export const CURRENT_PAIR_BY_STAGE: Record<SubStepKey, LayoutEdge['pair'] | null> = {
|
||||
purpose: 'agent-purpose',
|
||||
actor: 'purpose-actor',
|
||||
behavior: 'actor-behavior',
|
||||
narrative: 'driver-narrative',
|
||||
};
|
||||
|
||||
export const STATUS_TITLE: Record<SubStepKey, string> = {
|
||||
purpose: '正在生成目的中',
|
||||
actor: '正在生成行为体中',
|
||||
behavior: '正在生成预期行为和驱动因素中',
|
||||
narrative: '正在生成叙事中',
|
||||
};
|
||||
|
||||
export const EDGE_COLOR: Record<LayoutEdge['pair'], { dark: string; light: string }> = {
|
||||
'agent-purpose': { dark: '#a78bfa', light: '#fb923c' },
|
||||
'purpose-actor': { dark: '#22d3ee', light: '#ef4444' },
|
||||
'actor-behavior': { dark: '#34d399', light: '#10b981' },
|
||||
'behavior-driver': { dark: '#5eead4', light: '#0d9488' },
|
||||
'driver-narrative': { dark: '#fbbf24', light: '#f59e0b' },
|
||||
};
|
||||
|
||||
export const EDGE_LABEL: Record<LayoutEdge['pair'], string> = {
|
||||
'agent-purpose': '智能体→目的',
|
||||
'purpose-actor': '目的→行为体',
|
||||
'actor-behavior': '行为体→预期行为',
|
||||
'behavior-driver': '预期行为→驱动因素',
|
||||
'driver-narrative': '驱动因素→叙事',
|
||||
};
|
||||
|
||||
export function buildEdgePath(from: LayoutNode, to: LayoutNode): string {
|
||||
const x1 = from.x + from.width / 2;
|
||||
const y1 = from.y + from.height;
|
||||
const x2 = to.x + to.width / 2;
|
||||
const y2 = to.y;
|
||||
const my = (y1 + y2) / 2;
|
||||
return `M ${x1} ${y1} C ${x1} ${my}, ${x2} ${my}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
export function buildLayout(
|
||||
purposes: PurposeCard[],
|
||||
actors: ActorCard[],
|
||||
behaviors: BehaviorDriverCard[],
|
||||
narratives: NarrativeCard[],
|
||||
options: BuildLayoutOptions = {},
|
||||
): FlowLayout {
|
||||
const NODE_WIDTH = options.nodeWidth ?? 200;
|
||||
const NODE_HEIGHT = options.nodeHeight ?? 84;
|
||||
const LEVEL_GAP = options.levelGap ?? 130;
|
||||
const NODE_GAP = options.nodeGap ?? 28;
|
||||
const PADDING_X = options.paddingX ?? 48;
|
||||
const PADDING_Y = options.paddingY ?? 40;
|
||||
const rootTitle = options.rootTitle?.trim() || '智能体';
|
||||
const rootDetail = options.rootDetail?.trim() || '协同推演 6 步法生成内容';
|
||||
|
||||
const nodes: LayoutNode[] = [];
|
||||
const nodeMap = new Map<string, LayoutNode>();
|
||||
const push = (node: LayoutNode) => {
|
||||
nodes.push(node);
|
||||
nodeMap.set(node.id, node);
|
||||
};
|
||||
|
||||
const agent: LayoutNode = {
|
||||
id: 'agent-root',
|
||||
level: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
variant: 'agent',
|
||||
title: rootTitle,
|
||||
detail: rootDetail,
|
||||
children: purposes.map((p) => p.id),
|
||||
};
|
||||
push(agent);
|
||||
|
||||
purposes.forEach((p) => {
|
||||
push({
|
||||
id: p.id,
|
||||
level: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
variant: 'purpose',
|
||||
title: p.title,
|
||||
detail: p.reason,
|
||||
children: actors.filter((a) => a.purposeId === p.id).map((a) => a.id),
|
||||
parent: 'agent-root',
|
||||
});
|
||||
});
|
||||
|
||||
actors.forEach((a) => {
|
||||
push({
|
||||
id: a.id,
|
||||
level: 2,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
variant: 'actor',
|
||||
title: a.title,
|
||||
detail: a.content,
|
||||
children: behaviors.filter((b) => b.actorId === a.id).map((b) => `${b.id}-behavior`),
|
||||
parent: a.purposeId,
|
||||
});
|
||||
});
|
||||
|
||||
behaviors.forEach((b) => {
|
||||
const behaviorId = `${b.id}-behavior`;
|
||||
const driverId = `${b.id}-driver`;
|
||||
push({
|
||||
id: behaviorId,
|
||||
level: 3,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
variant: 'behavior',
|
||||
title: '预期行为',
|
||||
detail: b.expectedBehavior,
|
||||
children: [driverId],
|
||||
parent: b.actorId,
|
||||
});
|
||||
push({
|
||||
id: driverId,
|
||||
level: 4,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
variant: 'driver',
|
||||
title: '驱动因素',
|
||||
detail: b.driver,
|
||||
children: narratives.filter((n) => n.driverId === b.id).map((n) => n.id),
|
||||
parent: behaviorId,
|
||||
});
|
||||
});
|
||||
|
||||
narratives.forEach((n) => {
|
||||
push({
|
||||
id: n.id,
|
||||
level: 5,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
variant: 'narrative',
|
||||
title: n.title,
|
||||
detail: n.reason,
|
||||
children: [],
|
||||
parent: `${n.driverId}-driver`,
|
||||
});
|
||||
});
|
||||
|
||||
const leafCounter = { v: 0 };
|
||||
const place = (node: LayoutNode) => {
|
||||
if (node.children.length === 0) {
|
||||
node.x = PADDING_X + leafCounter.v * (NODE_WIDTH + NODE_GAP);
|
||||
leafCounter.v += 1;
|
||||
return;
|
||||
}
|
||||
node.children.forEach((cid) => {
|
||||
const child = nodeMap.get(cid);
|
||||
if (child) place(child);
|
||||
});
|
||||
const xs = node.children.map((cid) => nodeMap.get(cid)?.x ?? 0).filter((v) => v >= 0);
|
||||
if (xs.length > 0) {
|
||||
node.x = (Math.min(...xs) + Math.max(...xs)) / 2;
|
||||
} else {
|
||||
node.x = PADDING_X;
|
||||
}
|
||||
};
|
||||
place(agent);
|
||||
|
||||
nodes.forEach((node) => {
|
||||
node.y = PADDING_Y + node.level * (NODE_HEIGHT + LEVEL_GAP);
|
||||
});
|
||||
|
||||
const edges: LayoutEdge[] = [];
|
||||
nodes.forEach((node) => {
|
||||
node.children.forEach((cid) => {
|
||||
const child = nodeMap.get(cid);
|
||||
if (!child) return;
|
||||
let pair: LayoutEdge['pair'];
|
||||
if (node.variant === 'agent') pair = 'agent-purpose';
|
||||
else if (node.variant === 'purpose') pair = 'purpose-actor';
|
||||
else if (node.variant === 'actor') pair = 'actor-behavior';
|
||||
else if (node.variant === 'behavior') pair = 'behavior-driver';
|
||||
else pair = 'driver-narrative';
|
||||
edges.push({ id: `${node.id}->${cid}`, fromId: node.id, toId: cid, pair });
|
||||
});
|
||||
});
|
||||
|
||||
const allLevels = Math.max(...nodes.map((n) => n.level));
|
||||
const maxX = Math.max(...nodes.map((n) => n.x + n.width));
|
||||
const totalWidth = maxX + PADDING_X;
|
||||
const totalHeight = PADDING_Y * 2 + (allLevels + 1) * NODE_HEIGHT + allLevels * LEVEL_GAP;
|
||||
|
||||
return { nodes, edges, width: totalWidth, height: totalHeight };
|
||||
}
|
||||
|
||||
export const VARIANT_ABBREV: Record<LayoutNode['variant'], string> = {
|
||||
agent: 'AI',
|
||||
purpose: 'md',
|
||||
actor: 'xwt',
|
||||
behavior: 'yqxw',
|
||||
driver: 'qdys',
|
||||
narrative: 'xs',
|
||||
};
|
||||
|
||||
export const VARIANT_LABEL: Record<LayoutNode['variant'], string> = {
|
||||
agent: '智能体',
|
||||
purpose: '目的',
|
||||
actor: '行为体',
|
||||
behavior: '预期行为',
|
||||
driver: '驱动因素',
|
||||
narrative: '叙事',
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
import type {
|
||||
ActorCard,
|
||||
BehaviorDriverCard,
|
||||
MainStepKey,
|
||||
NarrativeCard,
|
||||
PurposeCard,
|
||||
SixStepMessage,
|
||||
StepStatus,
|
||||
SubStepKey,
|
||||
} from './types';
|
||||
|
||||
const PURPOSE_SEED: { title: string; reason: string }[] = [
|
||||
{ title: '建立品牌权威认知', reason: '通过持续的专业内容输出,塑造行业领导者形象,使核心受众在面对同类品牌时优先想起本品牌,并带来长期信任度' },
|
||||
{ title: '激活核心用户决策路径', reason: '梳理用户从兴趣触达到决策转化的关键触点,缩短决策链路、提升转化效率' },
|
||||
{ title: '强化情感价值连接', reason: '通过故事化叙事激发情绪共鸣,沉淀难以被替代的品牌情感资产' },
|
||||
];
|
||||
|
||||
const ACTOR_SEED: { title: string; content: string }[] = [
|
||||
{ title: '行业意见领袖(KOL)', content: '在专业垂直领域有持续影响力,可在第一时间撬动核心圈层并带动二次扩散' },
|
||||
{ title: '品牌官方账号矩阵', content: '统一对外传递品牌主张与最新动态,承担信息发布、用户互动与口碑响应' },
|
||||
{ title: '深度内容创作者', content: '产出长图文、视频解读等内容形态,承担引发讨论与口碑沉淀的角色' },
|
||||
{ title: '行业垂直媒体', content: '具备专业背书能力,承担议题设置与权威背书职责' },
|
||||
{ title: '核心用户共创社区', content: '由忠诚用户构成,通过 UGC 形式输出真实使用反馈与场景示范' },
|
||||
{ title: '行业分析机构', content: '通过研究报告与数据支撑品牌主张,加强观点的可信度' },
|
||||
{ title: '品牌代言关键人', content: '与品牌价值观高度契合的代言人或合作方,承担情感投射与信任放大' },
|
||||
];
|
||||
|
||||
const BEHAVIOR_SEED: { expectedBehavior: string; driver: string }[] = [
|
||||
{ expectedBehavior: '主动转发并附带专业点评', driver: '内容契合自身专业立场,带来圈层认同与社交资本' },
|
||||
{ expectedBehavior: '在专业社区发起延伸讨论', driver: '议题具备争议或洞察价值,能引发同行参与并扩散' },
|
||||
{ expectedBehavior: '将品牌内容沉淀为参考资料', driver: '内容具备结构化、可复用的方法论价值,可在工作中长期引用' },
|
||||
{ expectedBehavior: '主动在直播间或路演中引用品牌观点', driver: '观点具备可解释性,能帮助行为体强化自身专业可信度' },
|
||||
{ expectedBehavior: '产出二次内容(长图文 / 视频)', driver: '原始素材具备可改编空间,能匹配自身受众语境' },
|
||||
{ expectedBehavior: '组织线下闭门交流会', driver: '议题对核心受众具备稀缺性,符合社群运营节奏' },
|
||||
{ expectedBehavior: '在调研报告中将品牌列为典型案例', driver: '品牌案例具备数据支撑、典型且可被结构化引用' },
|
||||
{ expectedBehavior: '在公开演讲中引用品牌叙事框架', driver: '叙事框架具备方法论高度,可被借用以提升自身演讲含金量' },
|
||||
];
|
||||
|
||||
const NARRATIVE_SEED: { title: string; reason: string }[] = [
|
||||
{ title: '从噪音中识别真实信号', reason: '聚焦行业过度信息的痛点,主张以理性框架回归本质' },
|
||||
{ title: '让专业判断回到决策中心', reason: '强调专业判断在复杂环境下的不可替代性' },
|
||||
{ title: '以长期主义穿越周期', reason: '倡导关注长期价值积累,而非短期爆点' },
|
||||
{ title: '把复杂问题讲简单', reason: '通过结构化表达让专业内容更易被传播与理解' },
|
||||
{ title: '让每一次选择都有迹可循', reason: '强调决策可追溯、可复盘,建立专业信任' },
|
||||
{ title: '与时间做朋友', reason: '把品牌成长锚定在长期视角,淡化短期波动焦虑' },
|
||||
{ title: '相信专业的力量', reason: '通过行为体共同的专业身份认同强化品牌主张' },
|
||||
{ title: '把方法论变成可复用的工具', reason: '将抽象主张沉淀为工具化资产,方便受众反复引用' },
|
||||
];
|
||||
|
||||
const PURPOSE_THINKING = `根据当前问题语境,需要先厘清品牌战略推演的核心目的:
|
||||
|
||||
1. 综合输入素材,识别可能的目标方向;
|
||||
2. 评估不同目的之间的优先级与互补性;
|
||||
3. 选择最契合当前阶段的目的组合,作为后续推演基础。`;
|
||||
|
||||
const ACTOR_THINKING = `围绕已生成的目的,开始识别支撑该目的所需的关键行为体:
|
||||
|
||||
- 需要明确每个行为体的角色定位
|
||||
- 评估其对目的的贡献度与覆盖范围
|
||||
- 避免行为体之间能力重叠或缺位`;
|
||||
|
||||
const BEHAVIOR_THINKING = `针对每个行为体,推演其在场景中应当展现的预期行为:
|
||||
|
||||
- 行为需要可观察、可衡量
|
||||
- 同时识别推动该行为发生的关键驱动因素
|
||||
- 行为与驱动因素需保持因果一致性`;
|
||||
|
||||
const NARRATIVE_THINKING = `基于驱动因素,构建用于触发行为的叙事框架:
|
||||
|
||||
- 叙事需要紧扣驱动因素的情绪或理性触点
|
||||
- 兼顾品牌主张与受众心智匹配度
|
||||
- 形成可被复用的叙事模板`;
|
||||
|
||||
export interface SixStepMockController {
|
||||
message: SixStepMessage;
|
||||
stop: () => void;
|
||||
promise: Promise<void>;
|
||||
}
|
||||
|
||||
const TYPE_INTERVAL = 28;
|
||||
const STAGE_GAP = 380;
|
||||
|
||||
function buildPurposeCards(): PurposeCard[] {
|
||||
return PURPOSE_SEED.map((seed, idx) => ({
|
||||
id: `purpose-${idx + 1}`,
|
||||
index: idx + 1,
|
||||
title: seed.title,
|
||||
reason: seed.reason,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildActorCards(purposes: PurposeCard[]): ActorCard[] {
|
||||
const cards: ActorCard[] = [];
|
||||
let counter = 0;
|
||||
purposes.forEach((purpose, pIdx) => {
|
||||
const count = pIdx === 0 ? 2 : 1;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
counter += 1;
|
||||
const seed = ACTOR_SEED[(counter - 1) % ACTOR_SEED.length];
|
||||
cards.push({
|
||||
id: `actor-${counter}`,
|
||||
index: counter,
|
||||
purposeId: purpose.id,
|
||||
title: seed.title,
|
||||
content: seed.content,
|
||||
});
|
||||
}
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
|
||||
function buildBehaviorCards(actors: ActorCard[]): BehaviorDriverCard[] {
|
||||
const cards: BehaviorDriverCard[] = [];
|
||||
let counter = 0;
|
||||
actors.forEach((actor, aIdx) => {
|
||||
const count = aIdx % 2 === 0 ? 2 : 1;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
counter += 1;
|
||||
const seed = BEHAVIOR_SEED[(counter - 1) % BEHAVIOR_SEED.length];
|
||||
cards.push({
|
||||
id: `behavior-${counter}`,
|
||||
index: counter,
|
||||
actorId: actor.id,
|
||||
expectedBehavior: seed.expectedBehavior,
|
||||
driver: seed.driver,
|
||||
});
|
||||
}
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
|
||||
function buildNarrativeCards(behaviors: BehaviorDriverCard[]): NarrativeCard[] {
|
||||
const cards: NarrativeCard[] = [];
|
||||
let counter = 0;
|
||||
behaviors.forEach((behavior, bIdx) => {
|
||||
const count = bIdx % 2 === 0 ? 2 : 1;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
counter += 1;
|
||||
const seed = NARRATIVE_SEED[(counter - 1) % NARRATIVE_SEED.length];
|
||||
cards.push({
|
||||
id: `narrative-${counter}`,
|
||||
index: counter,
|
||||
driverId: behavior.id,
|
||||
title: seed.title,
|
||||
reason: seed.reason,
|
||||
});
|
||||
}
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
|
||||
function buildFinalAnswer(): string {
|
||||
return `综合本次 6 步法推演结果,可以得到如下战略要点:
|
||||
|
||||
1. **目的**:以品牌权威认知为锚点,在专业人群中建立可信表达。
|
||||
2. **行为体**:依靠 KOL、官方账号矩阵、深度创作者三类协同发声。
|
||||
3. **预期行为与驱动因素**:通过专业内容激发主动传播与延伸讨论,形成圈层共振。
|
||||
4. **叙事**:以"在噪音中识别真实信号"为核心叙事线,串联整个传播节奏。
|
||||
|
||||
后续可基于该推演继续生成传播节奏、渠道分工与内容资产清单。`;
|
||||
}
|
||||
|
||||
export function buildInitialAssistantMessage(): SixStepMessage {
|
||||
return {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
stages: {},
|
||||
finalAnswer: '',
|
||||
mainStepStatus: { info: 'pending', sixstep: 'pending', final: 'pending' },
|
||||
subStepStatus: { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' },
|
||||
};
|
||||
}
|
||||
|
||||
interface RunOptions {
|
||||
onUpdate: (message: SixStepMessage) => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟 6 步法推演的全过程:信息准备 → 6 步法(4 个阶段) → 最终结果。
|
||||
*
|
||||
* 通过分阶段写入 thinking 文本和卡片,模拟真实流式生成效果。
|
||||
*/
|
||||
export function runSixStepDeduction(message: SixStepMessage, options: RunOptions): SixStepMockController {
|
||||
let cancelled = false;
|
||||
const timers: number[] = [];
|
||||
|
||||
const wait = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const id = window.setTimeout(() => {
|
||||
resolve();
|
||||
}, ms);
|
||||
timers.push(id);
|
||||
});
|
||||
|
||||
const setMain = (key: MainStepKey, status: StepStatus) => {
|
||||
message.mainStepStatus = { ...(message.mainStepStatus ?? { info: 'pending', sixstep: 'pending', final: 'pending' }), [key]: status };
|
||||
if (status === 'running') {
|
||||
message.currentMainStep = key;
|
||||
}
|
||||
};
|
||||
|
||||
const setSub = (key: SubStepKey, status: StepStatus) => {
|
||||
message.subStepStatus = {
|
||||
...(message.subStepStatus ?? { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' }),
|
||||
[key]: status,
|
||||
};
|
||||
if (status === 'running') {
|
||||
message.currentSubStep = key;
|
||||
}
|
||||
};
|
||||
|
||||
const emit = () => {
|
||||
if (cancelled) return;
|
||||
options.onUpdate({ ...message, stages: { ...message.stages } });
|
||||
};
|
||||
|
||||
const typeInto = async (text: string, write: (chunk: string) => void) => {
|
||||
const step = 4;
|
||||
for (let i = 0; i <= text.length; i += step) {
|
||||
if (cancelled) return;
|
||||
write(text.slice(0, i));
|
||||
emit();
|
||||
await wait(TYPE_INTERVAL);
|
||||
}
|
||||
if (!cancelled) {
|
||||
write(text);
|
||||
emit();
|
||||
}
|
||||
};
|
||||
|
||||
const promise = (async () => {
|
||||
setMain('info', 'running');
|
||||
emit();
|
||||
await wait(800);
|
||||
if (cancelled) return;
|
||||
setMain('info', 'done');
|
||||
setMain('sixstep', 'running');
|
||||
emit();
|
||||
await wait(STAGE_GAP);
|
||||
|
||||
setSub('purpose', 'running');
|
||||
message.stages = { ...message.stages, purpose: { thinking: '', cards: [], complete: false } };
|
||||
emit();
|
||||
await typeInto(PURPOSE_THINKING, (chunk) => {
|
||||
message.stages = { ...message.stages, purpose: { ...(message.stages?.purpose as any), thinking: chunk } };
|
||||
});
|
||||
if (cancelled) return;
|
||||
const purposes = buildPurposeCards();
|
||||
for (const card of purposes) {
|
||||
if (cancelled) return;
|
||||
const existing = message.stages?.purpose?.cards ?? [];
|
||||
message.stages = { ...message.stages, purpose: { ...(message.stages?.purpose as any), cards: [...existing, card] } };
|
||||
emit();
|
||||
await wait(280);
|
||||
}
|
||||
message.stages = { ...message.stages, purpose: { ...(message.stages?.purpose as any), complete: true } };
|
||||
setSub('purpose', 'done');
|
||||
emit();
|
||||
await wait(STAGE_GAP);
|
||||
|
||||
setSub('actor', 'running');
|
||||
message.stages = { ...message.stages, actor: { thinking: '', cards: [], complete: false } };
|
||||
emit();
|
||||
await typeInto(ACTOR_THINKING, (chunk) => {
|
||||
message.stages = { ...message.stages, actor: { ...(message.stages?.actor as any), thinking: chunk } };
|
||||
});
|
||||
if (cancelled) return;
|
||||
const actors = buildActorCards(purposes);
|
||||
for (const card of actors) {
|
||||
if (cancelled) return;
|
||||
const existing = message.stages?.actor?.cards ?? [];
|
||||
message.stages = { ...message.stages, actor: { ...(message.stages?.actor as any), cards: [...existing, card] } };
|
||||
emit();
|
||||
await wait(220);
|
||||
}
|
||||
message.stages = { ...message.stages, actor: { ...(message.stages?.actor as any), complete: true } };
|
||||
setSub('actor', 'done');
|
||||
emit();
|
||||
await wait(STAGE_GAP);
|
||||
|
||||
setSub('behavior', 'running');
|
||||
message.stages = { ...message.stages, behavior: { thinking: '', cards: [], complete: false } };
|
||||
emit();
|
||||
await typeInto(BEHAVIOR_THINKING, (chunk) => {
|
||||
message.stages = { ...message.stages, behavior: { ...(message.stages?.behavior as any), thinking: chunk } };
|
||||
});
|
||||
if (cancelled) return;
|
||||
const behaviors = buildBehaviorCards(actors);
|
||||
for (const card of behaviors) {
|
||||
if (cancelled) return;
|
||||
const existing = message.stages?.behavior?.cards ?? [];
|
||||
message.stages = { ...message.stages, behavior: { ...(message.stages?.behavior as any), cards: [...existing, card] } };
|
||||
emit();
|
||||
await wait(200);
|
||||
}
|
||||
message.stages = { ...message.stages, behavior: { ...(message.stages?.behavior as any), complete: true } };
|
||||
setSub('behavior', 'done');
|
||||
emit();
|
||||
await wait(STAGE_GAP);
|
||||
|
||||
setSub('narrative', 'running');
|
||||
message.stages = { ...message.stages, narrative: { thinking: '', cards: [], complete: false } };
|
||||
emit();
|
||||
await typeInto(NARRATIVE_THINKING, (chunk) => {
|
||||
message.stages = { ...message.stages, narrative: { ...(message.stages?.narrative as any), thinking: chunk } };
|
||||
});
|
||||
if (cancelled) return;
|
||||
const narratives = buildNarrativeCards(behaviors);
|
||||
for (const card of narratives) {
|
||||
if (cancelled) return;
|
||||
const existing = message.stages?.narrative?.cards ?? [];
|
||||
message.stages = { ...message.stages, narrative: { ...(message.stages?.narrative as any), cards: [...existing, card] } };
|
||||
emit();
|
||||
await wait(200);
|
||||
}
|
||||
message.stages = { ...message.stages, narrative: { ...(message.stages?.narrative as any), complete: true } };
|
||||
setSub('narrative', 'done');
|
||||
setMain('sixstep', 'done');
|
||||
setMain('final', 'running');
|
||||
emit();
|
||||
await wait(STAGE_GAP);
|
||||
|
||||
if (cancelled) return;
|
||||
await typeInto(buildFinalAnswer(), (chunk) => {
|
||||
message.finalAnswer = chunk;
|
||||
});
|
||||
if (cancelled) return;
|
||||
setMain('final', 'done');
|
||||
message.currentMainStep = undefined;
|
||||
message.currentSubStep = undefined;
|
||||
emit();
|
||||
options.onComplete?.();
|
||||
})();
|
||||
|
||||
return {
|
||||
message,
|
||||
stop: () => {
|
||||
cancelled = true;
|
||||
timers.forEach((t) => window.clearTimeout(t));
|
||||
},
|
||||
promise,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
@keyframes sixstep-ant-march {
|
||||
to {
|
||||
stroke-dashoffset: -24;
|
||||
}
|
||||
}
|
||||
|
||||
.sixstep-ant-line {
|
||||
stroke-dasharray: 6 6;
|
||||
animation: sixstep-ant-march 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sixstep-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.55);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(56, 189, 248, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.sixstep-pulse {
|
||||
animation: sixstep-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes sixstep-shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sixstep-shimmer-text {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(148, 163, 184, 0.55) 0%,
|
||||
rgba(248, 250, 252, 1) 45%,
|
||||
rgba(148, 163, 184, 0.55) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: sixstep-shimmer 2s linear infinite;
|
||||
}
|
||||
|
||||
.sixstep-shimmer-text-light {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(220, 38, 38, 0.45) 0%,
|
||||
rgba(190, 18, 60, 0.95) 45%,
|
||||
rgba(220, 38, 38, 0.45) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: sixstep-shimmer 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sixstep-spin-soft {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sixstep-spin-soft {
|
||||
animation: sixstep-spin-soft 1.4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sixstep-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sixstep-fade-up {
|
||||
animation: sixstep-fade-up 0.32s ease-out both;
|
||||
}
|
||||
|
||||
.sixstep-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sixstep-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 2px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.4);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.sixstep-resize-handle:hover::after {
|
||||
background: rgba(56, 189, 248, 0.8);
|
||||
}
|
||||
|
||||
@keyframes sixstep-node-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.4);
|
||||
filter: drop-shadow(0 0 0 rgba(56, 189, 248, 0));
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: scale(1.18);
|
||||
filter: drop-shadow(0 0 12px rgba(56, 189, 248, 0.8));
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: drop-shadow(0 0 0 rgba(56, 189, 248, 0));
|
||||
}
|
||||
}
|
||||
|
||||
.sixstep-node-pop {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: sixstep-node-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
@keyframes sixstep-ring-burst {
|
||||
0% {
|
||||
opacity: 0.85;
|
||||
r: 0;
|
||||
stroke-width: 4;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
r: 60;
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.sixstep-ring-burst {
|
||||
animation: sixstep-ring-burst 0.7s ease-out forwards;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.sixstep-canvas-grab {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.sixstep-canvas-grabbing {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sixstep-toolbar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.sixstep-minimap-rect {
|
||||
fill: rgba(56, 189, 248, 0.18);
|
||||
stroke: rgba(56, 189, 248, 0.9);
|
||||
stroke-width: 1;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
export type MainStepKey = 'info' | 'sixstep' | 'final';
|
||||
export type SubStepKey = 'purpose' | 'actor' | 'behavior' | 'narrative';
|
||||
|
||||
export type StepStatus = 'pending' | 'running' | 'done';
|
||||
|
||||
export interface PurposeCard {
|
||||
id: string;
|
||||
index: number;
|
||||
title: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ActorCard {
|
||||
id: string;
|
||||
index: number;
|
||||
purposeId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface BehaviorDriverCard {
|
||||
id: string;
|
||||
index: number;
|
||||
actorId: string;
|
||||
expectedBehavior: string;
|
||||
driver: string;
|
||||
}
|
||||
|
||||
export interface NarrativeCard {
|
||||
id: string;
|
||||
index: number;
|
||||
driverId: string;
|
||||
title: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PurposeStageData {
|
||||
thinking: string;
|
||||
cards: PurposeCard[];
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface ActorStageData {
|
||||
thinking: string;
|
||||
cards: ActorCard[];
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface BehaviorStageData {
|
||||
thinking: string;
|
||||
cards: BehaviorDriverCard[];
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface NarrativeStageData {
|
||||
thinking: string;
|
||||
cards: NarrativeCard[];
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface InfoStageData {
|
||||
content: string;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface SixStepStages {
|
||||
info?: InfoStageData;
|
||||
purpose?: PurposeStageData;
|
||||
actor?: ActorStageData;
|
||||
behavior?: BehaviorStageData;
|
||||
narrative?: NarrativeStageData;
|
||||
}
|
||||
|
||||
export type SelectableMcpKey = 'purpose' | 'actor' | 'behavior_factor' | 'narrative';
|
||||
|
||||
export interface ExpertCard {
|
||||
id: string;
|
||||
backendId?: string;
|
||||
name: string;
|
||||
detail: string;
|
||||
avatarUrl?: string;
|
||||
title?: string;
|
||||
tags?: string[];
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
export interface ExpertMatchData {
|
||||
status: 'idle' | 'searching' | 'creating' | 'ready' | 'confirmed' | 'error';
|
||||
thinking: string;
|
||||
experts: ExpertCard[];
|
||||
selectedExpertId?: string;
|
||||
selectedMcps?: SelectableMcpKey[];
|
||||
errorMessage?: string;
|
||||
searchedAt?: number;
|
||||
}
|
||||
|
||||
export type SixStepPhase =
|
||||
| 'idle'
|
||||
| 'intent-detecting'
|
||||
| 'expert-match'
|
||||
| 'expert-confirm'
|
||||
| 'generate'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
| 'clarifying'
|
||||
| 'follow-up';
|
||||
|
||||
/** SSE error_log / error 事件归一后的错误记录,积累在 SixStepMessage.errors 中。 */
|
||||
export interface StreamErrorItem {
|
||||
errorId: string;
|
||||
taskId?: string;
|
||||
level: 'warning' | 'error';
|
||||
source: 'orchestrator' | 'mcp' | 'skill' | 'llm' | 'network';
|
||||
mcpKey?: string;
|
||||
stepName?: string;
|
||||
skillKey?: string;
|
||||
skillName?: string;
|
||||
message: string;
|
||||
recoverable?: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** 后端 step_end 的原始字段,用于 continue_analysis 时回传 last_steps */
|
||||
export interface BackendStepResult {
|
||||
key: string;
|
||||
name?: string;
|
||||
status: string;
|
||||
thinking?: string;
|
||||
structured_outputs?: Record<string, unknown>[];
|
||||
markdown?: string;
|
||||
references?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface SixStepMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content?: string;
|
||||
timestamp: Date;
|
||||
outputFormat?: 'structured' | 'markdown';
|
||||
markdownContent?: string;
|
||||
stages?: SixStepStages;
|
||||
finalAnswer?: string;
|
||||
mainStepStatus?: Record<MainStepKey, StepStatus>;
|
||||
subStepStatus?: Record<SubStepKey, StepStatus>;
|
||||
currentSubStep?: SubStepKey;
|
||||
currentMainStep?: MainStepKey;
|
||||
phase?: SixStepPhase;
|
||||
expertMatch?: ExpertMatchData;
|
||||
selectedExpert?: ExpertCard;
|
||||
selectedMcps?: SelectableMcpKey[];
|
||||
intent?: { name: string; confidence?: number; reason?: string };
|
||||
clarifyQuestion?: string;
|
||||
/** 后端原始步骤数据,paused 后继续时作为 last_steps 传回后端 */
|
||||
rawSteps?: BackendStepResult[];
|
||||
/** 本轮是从哪个步骤继续的(resume/rerun 事件携带),供 UI 显示"继续自…" */
|
||||
resumedFrom?: string;
|
||||
/** 实际用于分析的问题(revise_input_continue 后更新为新问题) */
|
||||
effectiveUserInput?: string;
|
||||
/** 本轮累积的错误/警告列表(error_log 与 error 事件均追加于此) */
|
||||
errors?: StreamErrorItem[];
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,578 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Search,
|
||||
History,
|
||||
Bot,
|
||||
Sparkles,
|
||||
Database,
|
||||
Layers,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Cpu,
|
||||
Target,
|
||||
Zap,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { PageType } from '../../components/Sidebar';
|
||||
import { ChatMessage, EmptyChatState, QAComposer, TypingIndicator } from './QACommon';
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
description: string;
|
||||
skills: string[];
|
||||
dataSources: string[];
|
||||
functions: string[];
|
||||
outputFields: string[];
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
agentName?: string;
|
||||
skills?: string[];
|
||||
dataSources?: string[];
|
||||
functions?: string[];
|
||||
structuredOutput?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能体问答组件
|
||||
* 基于特定智能体的专业能力进行问答,支持选择不同领域的智能体
|
||||
* @param onNavigate - 页面导航函数
|
||||
*/
|
||||
const AgentQA: React.FC<{ onNavigate: (page: PageType) => void }> = ({ onNavigate }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// 模拟智能体列表
|
||||
const agents: Agent[] = [
|
||||
{
|
||||
id: 'audience-agent',
|
||||
name: 'AudienceAgent',
|
||||
category: '受众分析',
|
||||
tags: ['受众分层', '画像抽取', '行为预测'],
|
||||
description: '专业的受众分析智能体,能够识别目标受众特征并生成画像',
|
||||
skills: ['受众分层', '核心受众识别', '画像抽取', '行为预测'],
|
||||
dataSources: ['知识库', 'Y系统', 'A系统'],
|
||||
functions: ['信息抽取器', '标签聚类器', '结构化输出器'],
|
||||
outputFields: ['core_audience', 'key_traits', 'barriers', 'opportunities'],
|
||||
},
|
||||
{
|
||||
id: 'brand-agent',
|
||||
name: 'BrandAgent',
|
||||
category: '品牌分析',
|
||||
tags: ['品牌定位', '传播策略', '调性分析'],
|
||||
description: '专注品牌建设与传播策略的智能体',
|
||||
skills: ['品牌定位分析', '传播路径规划', '品牌调性设计'],
|
||||
dataSources: ['知识库', '行业数据库'],
|
||||
functions: ['品牌诊断器', '策略生成器'],
|
||||
outputFields: ['brand_position', 'communication_path', 'tone_guide'],
|
||||
},
|
||||
{
|
||||
id: 'content-agent',
|
||||
name: 'ContentAgent',
|
||||
category: '内容创作',
|
||||
tags: ['文案撰写', '创意生成', '多形式内容'],
|
||||
description: '智能内容创作助手,支持多种形式的内容生成',
|
||||
skills: ['文案撰写', '创意生成', '多形式内容', 'A/B测试文案'],
|
||||
dataSources: ['知识库', '优秀案例库'],
|
||||
functions: ['文案生成器', '风格适配器', '质量评估器'],
|
||||
outputFields: ['headlines', 'body_copy', 'call_to_action', 'variants'],
|
||||
},
|
||||
{
|
||||
id: 'channel-agent',
|
||||
name: 'ChannelAgent',
|
||||
category: '渠道规划',
|
||||
tags: ['渠道选择', '投放策略', '效果评估'],
|
||||
description: '专业的渠道分析与规划智能体',
|
||||
skills: ['渠道选择', '投放策略规划', '效果评估', '预算分配'],
|
||||
dataSources: ['Y系统', 'A系统', '行业数据'],
|
||||
functions: ['渠道分析器', '投放优化器', 'ROI预测器'],
|
||||
outputFields: ['recommended_channels', 'budget_allocation', 'expected_roi'],
|
||||
},
|
||||
];
|
||||
|
||||
// 状态管理
|
||||
// 当前选中的智能体
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent>(agents[0]);
|
||||
// 智能体搜索关键词
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
// 聊天消息列表
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
// 输入框内容
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
// 加载状态
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// 左侧侧边栏折叠状态
|
||||
const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
|
||||
|
||||
// 过滤智能体
|
||||
const filteredAgents = agents.filter(agent =>
|
||||
agent.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
agent.category.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
agent.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
/**
|
||||
* 处理发送消息
|
||||
* 发送用户消息并模拟智能体响应
|
||||
*/
|
||||
const handleSend = () => {
|
||||
if (!inputValue.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: inputValue,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages([...messages, userMessage]);
|
||||
setInputValue('');
|
||||
|
||||
// 模拟AI响应
|
||||
setTimeout(() => {
|
||||
const response: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: `基于${selectedAgent.name}的专业能力,我来为您分析:\n\n根据您的问题,我识别到核心受众特征如下:\n\n**核心受众识别**:\n- 主要年龄段:25-35岁\n- 职业特征:都市白领\n- 消费能力:中高端\n- 关注点:品质生活、个人成长\n\n**关键特质**:\n1. 追求个性化表达\n2. 注重社交认同\n3. 愿意为品质付费\n\n**可能存在的阻力**:\n- 价格敏感度较高\n- 对品牌忠诚度有限\n\n以上分析是否满足您的需求?如需调整维度,请继续补充信息。`,
|
||||
timestamp: new Date(),
|
||||
agentName: selectedAgent.name,
|
||||
skills: selectedAgent.skills,
|
||||
dataSources: selectedAgent.dataSources,
|
||||
functions: selectedAgent.functions,
|
||||
structuredOutput: {
|
||||
core_audience: '25-35岁都市白领',
|
||||
key_traits: ['追求个性化', '注重社交认同', '品质导向'],
|
||||
barriers: ['价格敏感', '品牌忠诚度有限'],
|
||||
opportunities: ['社交媒体影响大', '口碑传播效果好'],
|
||||
},
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, response]);
|
||||
setIsLoading(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理停止操作
|
||||
* 停止当前正在进行的智能体响应
|
||||
*/
|
||||
const handleStop = () => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理键盘事件
|
||||
* 按Enter键发送消息(不按Shift)
|
||||
*/
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存问答记录
|
||||
*/
|
||||
const handleSaveRecord = () => {
|
||||
alert('问答记录已保存');
|
||||
};
|
||||
|
||||
/**
|
||||
* 收藏当前对话
|
||||
*/
|
||||
const handleCollect = () => {
|
||||
alert('已添加到收藏');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`h-[calc(100vh-120px)] flex flex-col theme-bg rounded-xl ${isDark ? 'bg-slate-800/50' : 'bg-white'} border ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
|
||||
{/* 顶部工具栏 */}
|
||||
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
智能体问答
|
||||
</h1>
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${isDark ? 'bg-slate-700/50' : 'bg-red-50'}`}>
|
||||
<Bot className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />
|
||||
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
|
||||
当前智能体:
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-blue-400' : 'text-red-600'}`}>
|
||||
{selectedAgent.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onNavigate('qa-records')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-slate-700/50 text-slate-300 hover:bg-slate-600/50'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
历史记录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主体内容区 */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 左侧智能体区 */}
|
||||
<div className={`${isLeftSidebarCollapsed ? 'w-14 p-2' : 'w-72'} border-r flex-shrink-0 overflow-y-auto transition-all duration-300 ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'}`}>
|
||||
<div className={`flex items-center ${isLeftSidebarCollapsed ? 'justify-center' : 'justify-between'} p-2 ${isLeftSidebarCollapsed ? '' : 'px-4 pt-4 pb-0'}`}>
|
||||
{!isLeftSidebarCollapsed && (
|
||||
<span className={`text-xs font-medium uppercase tracking-wider ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
智能体配置
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLeftSidebarCollapsed(!isLeftSidebarCollapsed)}
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:text-white hover:bg-slate-700/50'
|
||||
: 'text-gray-500 hover:text-red-600 hover:bg-red-50'
|
||||
}`}
|
||||
title={isLeftSidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||
>
|
||||
{isLeftSidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isLeftSidebarCollapsed && (<>
|
||||
{/* 智能体搜索 */}
|
||||
<div className="p-4">
|
||||
<div className={`relative ${isDark ? 'bg-slate-700/50' : 'bg-white'} rounded-lg`}>
|
||||
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${isDark ? 'text-slate-400' : 'text-gray-400'}`} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索智能体..."
|
||||
className={`w-full pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none ${
|
||||
isDark
|
||||
? 'bg-transparent text-white placeholder-slate-500'
|
||||
: 'bg-transparent text-gray-800 placeholder-gray-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 智能体列表 */}
|
||||
<div className="px-4 pb-4">
|
||||
<h3 className={`text-xs font-medium uppercase tracking-wider mb-3 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
智能体列表
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{filteredAgents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
onClick={() => setSelectedAgent(agent)}
|
||||
className={`w-full text-left p-3 rounded-lg transition-all ${
|
||||
selectedAgent.id === agent.id
|
||||
? isDark
|
||||
? 'bg-blue-600/20 border border-blue-500/50'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
: isDark
|
||||
? 'bg-slate-700/30 border border-transparent hover:bg-slate-700/50'
|
||||
: 'bg-white border border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
selectedAgent.id === agent.id
|
||||
? isDark
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-red-500 text-white'
|
||||
: isDark
|
||||
? 'bg-slate-600 text-slate-300'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
<Bot className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{agent.name}
|
||||
</div>
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{agent.category}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 当前智能体能力摘要 */}
|
||||
<div className={`p-4 border-t ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
|
||||
<h3 className={`text-xs font-medium uppercase tracking-wider mb-3 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
能力摘要
|
||||
</h3>
|
||||
|
||||
{/* 核心职责 */}
|
||||
<div className="mb-4">
|
||||
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Target className="w-4 h-4" />
|
||||
<span className="text-xs">核心职责</span>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
|
||||
{selectedAgent.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 已启用技能 */}
|
||||
<div className="mb-4">
|
||||
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span className="text-xs">已启用技能</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedAgent.skills.map((skill, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
isDark ? 'bg-blue-600/30 text-blue-400' : 'bg-blue-100 text-blue-600'
|
||||
}`}
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 可访问数据源 */}
|
||||
<div className="mb-4">
|
||||
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Database className="w-4 h-4" />
|
||||
<span className="text-xs">可访问数据源</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedAgent.dataSources.map((source, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
isDark ? 'bg-green-600/30 text-green-400' : 'bg-green-100 text-green-600'
|
||||
}`}
|
||||
>
|
||||
{source}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 可调用功能 */}
|
||||
<div>
|
||||
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Layers className="w-4 h-4" />
|
||||
<span className="text-xs">可调用功能</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedAgent.functions.map((func, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
isDark ? 'bg-purple-600/30 text-purple-400' : 'bg-purple-100 text-purple-600'
|
||||
}`}
|
||||
>
|
||||
{func}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{/* 中间对话区 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 消息列表 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 ? (
|
||||
<EmptyChatState
|
||||
icon={<Bot className="w-11 h-11 opacity-60" />}
|
||||
title={`开始与${selectedAgent.name}对话`}
|
||||
description="基于智能体专业能力获取精准回答"
|
||||
/>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<ChatMessage
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
assistantLabel={msg.agentName || selectedAgent.name}
|
||||
renderAssistantExtra={(assistantMessage) => (
|
||||
<div className={`ml-12 p-3 rounded-xl ${isDark ? 'bg-slate-800/50' : 'bg-gray-50'}`}>
|
||||
<div className="mb-3">
|
||||
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>本轮启用技能</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{assistantMessage.skills?.map((skill, idx) => (
|
||||
<span key={idx} className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-blue-600/30 text-blue-400' : 'bg-blue-100 text-blue-600'}`}>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>本轮访问数据源</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{assistantMessage.dataSources?.map((source, idx) => (
|
||||
<span key={idx} className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-green-600/30 text-green-400' : 'bg-green-100 text-green-600'}`}>
|
||||
{source}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>本轮调用功能</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{assistantMessage.functions?.map((func, idx) => (
|
||||
<span key={idx} className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-purple-600/30 text-purple-400' : 'bg-purple-100 text-purple-600'}`}>
|
||||
{func}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{assistantMessage.structuredOutput && (
|
||||
<div>
|
||||
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>结构化输出</h4>
|
||||
<div className={`p-3 rounded-lg ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
|
||||
{Object.entries(assistantMessage.structuredOutput).map(([key, value]) => (
|
||||
<div key={key} className="flex items-start gap-2 mb-1 last:mb-0">
|
||||
<span className={`text-xs font-mono ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>{key}:</span>
|
||||
<span className={`text-xs ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
|
||||
{Array.isArray(value) ? value.join(', ') : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{isLoading && <TypingIndicator label={`${selectedAgent.name} 正在调用能力生成回复`} assistantLabel={selectedAgent.name} />}
|
||||
</div>
|
||||
|
||||
{/* 底部输入区 */}
|
||||
<QAComposer
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSubmit={handleSend}
|
||||
onStop={handleStop}
|
||||
onSave={handleSaveRecord}
|
||||
onCollect={handleCollect}
|
||||
isLoading={isLoading}
|
||||
placeholder="输入问题或任务要求..."
|
||||
showUpload={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧执行详情区 */}
|
||||
<div className={`w-72 border-l flex-shrink-0 overflow-y-auto p-4 ${
|
||||
isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'
|
||||
}`}>
|
||||
<h3 className={`text-sm font-medium mb-4 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
|
||||
执行详情
|
||||
</h3>
|
||||
|
||||
{/* 本轮启用技能 */}
|
||||
<div className="mb-6">
|
||||
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">本轮启用技能</span>
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
|
||||
<div className="space-y-2">
|
||||
{selectedAgent.skills.map((skill, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<CheckCircle className={`w-3 h-3 ${isDark ? 'text-blue-400' : 'text-green-500'}`} />
|
||||
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>{skill}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 本轮访问数据源 */}
|
||||
<div className="mb-6">
|
||||
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Database className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">本轮访问数据源</span>
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
|
||||
<div className="space-y-2">
|
||||
{selectedAgent.dataSources.map((source, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<Database className={`w-3 h-3 ${isDark ? 'text-green-400' : 'text-green-500'}`} />
|
||||
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>{source}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 本轮调用功能 */}
|
||||
<div className="mb-6">
|
||||
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Layers className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">本轮调用功能</span>
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
|
||||
<div className="space-y-2">
|
||||
{selectedAgent.functions.map((func, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<Cpu className={`w-3 h-3 ${isDark ? 'text-purple-400' : 'text-purple-500'}`} />
|
||||
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>{func}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结构化输出字段 */}
|
||||
<div>
|
||||
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">输出字段</span>
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
|
||||
<div className="space-y-2">
|
||||
{selectedAgent.outputFields.map((field, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<Zap className={`w-3 h-3 ${isDark ? 'text-yellow-400' : 'text-yellow-500'}`} />
|
||||
<span className={`text-xs font-mono ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>{field}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentQA;
|
||||
@@ -0,0 +1,189 @@
|
||||
import React, { useState } from 'react';
|
||||
import { CheckCircle, ChevronLeft, ChevronRight, History, MessageCircle, Users } from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { PageType } from '../../components/Sidebar';
|
||||
import { ChatMessage, EmptyChatState, QAComposer, TypingIndicator } from './QACommon';
|
||||
|
||||
interface CollaborativeQAProps {
|
||||
onNavigate: (page: PageType) => void;
|
||||
}
|
||||
|
||||
interface CollaborativeMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
const collaborativeAgents = [
|
||||
{ id: 'goal-agent', name: 'GoalAgent', category: '目标规划', color: 'blue' },
|
||||
{ id: 'audience-agent', name: 'AudienceAgent', category: '受众分析', color: 'green' },
|
||||
{ id: 'risk-agent', name: 'RiskAgent', category: '风险识别', color: 'purple' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 多智能体协同问答组件
|
||||
* 多个智能体从不同视角联合分析问题,生成综合建议
|
||||
* @param onNavigate - 页面导航函数
|
||||
*/
|
||||
const CollaborativeQA: React.FC<CollaborativeQAProps> = ({ onNavigate }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [messages, setMessages] = useState<CollaborativeMessage[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedAgents, setSelectedAgents] = useState(collaborativeAgents.map((agent) => agent.id));
|
||||
const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
|
||||
|
||||
const activeAgents = collaborativeAgents.filter((agent) => selectedAgents.includes(agent.id));
|
||||
|
||||
const handleSend = () => {
|
||||
if (!inputValue.trim()) return;
|
||||
|
||||
const userMessage: CollaborativeMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: inputValue,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInputValue('');
|
||||
setIsLoading(true);
|
||||
|
||||
setTimeout(() => {
|
||||
const agentNames = activeAgents.map((agent) => agent.name).join('、');
|
||||
const assistantMessage: CollaborativeMessage = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: `已组织 ${agentNames || '默认协同组'} 完成一轮联合研判:\n\n1. GoalAgent 建议先明确任务目标与阶段性 KPI,避免讨论发散。\n2. AudienceAgent 认为应优先锁定核心受众画像,并补充行为触点数据。\n3. RiskAgent 提醒需要同步评估内容合规、舆情扩散和资源约束。\n\n综合建议:先形成“目标-受众-风险”三列表,再进入方案细化与执行拆解。`,
|
||||
timestamp: new Date(),
|
||||
agentName: '协同编排助手',
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
setIsLoading(false);
|
||||
}, 1800);
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAgent = (agentId: string) => {
|
||||
setSelectedAgents((prev) =>
|
||||
prev.includes(agentId) ? prev.filter((id) => id !== agentId) : [...prev, agentId]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`h-[calc(100vh-120px)] flex flex-col theme-bg rounded-xl ${isDark ? 'bg-slate-800/50' : 'bg-white'} border ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
|
||||
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>多智能体协同问答</h1>
|
||||
<span className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>多角色联合研判、共识汇总与风险补充</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('qa-records')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
isDark ? 'bg-slate-700/50 text-slate-300 hover:bg-slate-600/50' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
历史记录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<aside className={`${isLeftSidebarCollapsed ? 'w-14 p-2' : 'w-72 p-4'} border-r flex-shrink-0 overflow-y-auto transition-all duration-300 ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'}`}>
|
||||
<div className={`flex items-center ${isLeftSidebarCollapsed ? 'justify-center' : 'justify-between'} mb-4`}>
|
||||
{!isLeftSidebarCollapsed && (
|
||||
<h3 className={`text-sm font-medium ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>参与智能体</h3>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLeftSidebarCollapsed(!isLeftSidebarCollapsed)}
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:text-white hover:bg-slate-700/50'
|
||||
: 'text-gray-500 hover:text-red-600 hover:bg-red-50'
|
||||
}`}
|
||||
title={isLeftSidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||
>
|
||||
{isLeftSidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isLeftSidebarCollapsed && (<>
|
||||
<div className="space-y-2">
|
||||
{collaborativeAgents.map((agent) => {
|
||||
const active = selectedAgents.includes(agent.id);
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
onClick={() => toggleAgent(agent.id)}
|
||||
className={`w-full p-3 rounded-xl text-left border transition-all ${
|
||||
active
|
||||
? isDark ? 'bg-blue-600/20 border-blue-500/50' : 'bg-red-50 border-red-200'
|
||||
: isDark ? 'bg-slate-700/30 border-transparent hover:bg-slate-700/50' : 'bg-white border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-2xl flex items-center justify-center ${active ? 'bg-gradient-to-br from-red-500 to-orange-400 text-white' : isDark ? 'bg-slate-600 text-slate-300' : 'bg-gray-100 text-gray-600'}`}>
|
||||
<Users className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-gray-900'}`}>{agent.name}</div>
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{agent.category}</div>
|
||||
</div>
|
||||
{active && <CheckCircle className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>)}
|
||||
</aside>
|
||||
|
||||
<section className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 ? (
|
||||
<EmptyChatState
|
||||
icon={<MessageCircle className="w-11 h-11 opacity-60" />}
|
||||
title="发起一次协同会商"
|
||||
description="输入任务背景,多个智能体会从不同视角生成联合建议"
|
||||
/>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} assistantLabel={message.agentName || '协同编排助手'} />
|
||||
))
|
||||
)}
|
||||
{isLoading && <TypingIndicator label="多智能体正在协同分析并汇总观点" assistantLabel="协同编排助手" />}
|
||||
</div>
|
||||
|
||||
<QAComposer
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSubmit={handleSend}
|
||||
onStop={handleStop}
|
||||
onSave={() => alert('问答记录已保存')}
|
||||
onCollect={() => alert('已添加到收藏')}
|
||||
isLoading={isLoading}
|
||||
placeholder="输入协同研判主题或任务背景..."
|
||||
showUpload={false}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollaborativeQA;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
const zncmUrl = import.meta.env.VITE_AIPI_CONSUMER_URL || '';
|
||||
import { requestCrudService } from '@/service/request-axios-service';
|
||||
const service = requestCrudService;
|
||||
const userInfo = localStorage.getItem('userInfo');
|
||||
const deerFlowUrl = 'https://ch1.b.uat.4.cn/mopeweb/#/login/';
|
||||
interface TaskPgFromProps {
|
||||
gopath: string
|
||||
}
|
||||
const GAFromQA: React.FC<TaskPgFromProps> = ({gopath}) => {
|
||||
const [deerFlow, setDeerFlow] = useState('https://ch1.b.uat.4.cn/mopeweb/#/login/'); // http://localhost:5174/#/
|
||||
useEffect(() => {
|
||||
if(userInfo) {
|
||||
const userName = JSON.parse(userInfo).userName;
|
||||
const gaUrl = deerFlowUrl + userName + `?goPath=${gopath}`
|
||||
console.log('gaUrl', gaUrl)
|
||||
setDeerFlow(gaUrl);
|
||||
}
|
||||
}, [gopath]);
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<iframe className="h-[calc(100vh-92px)]" src={deerFlow}></iframe>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default GAFromQA
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
const zncmUrl = import.meta.env.VITE_AIPI_CONSUMER_URL || '';
|
||||
import { requestCrudService } from '@/service/request-axios-service';
|
||||
const service = requestCrudService;
|
||||
const userInfo = localStorage.getItem('userInfo');
|
||||
const deerFlowUrl = 'https://ch1.b.uat.4.cn/xwtweb/#/login/';
|
||||
interface TaskPgFromProps {
|
||||
gopath: string
|
||||
}
|
||||
const GAFromNewQA: React.FC<TaskPgFromProps> = ({gopath}) => {
|
||||
const [deerFlow, setDeerFlow] = useState('https://ch1.b.uat.4.cn/xwtweb/#/login/'); // http://localhost:5174/#/
|
||||
useEffect(() => {
|
||||
if(userInfo) {
|
||||
const userName = JSON.parse(userInfo).userName;
|
||||
const gaUrl = deerFlowUrl + userName + `?pathTo=${gopath}`
|
||||
console.log('gaUrl', gaUrl)
|
||||
setDeerFlow(gaUrl);
|
||||
}
|
||||
}, [gopath]);
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<iframe className="h-[calc(100vh-92px)]" src={deerFlow}></iframe>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default GAFromNewQA
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,450 @@
|
||||
import type { QASingleRagTrace, QAThinkingTrace } from './QACommon';
|
||||
import type { ComparePane, KnowledgeBaseChunk, Message, QAMode, SourceItem } from './GeneralQA.types';
|
||||
|
||||
/**
|
||||
* 解析单轮 RAG 接口的 `<think>...</think>` 流式文本。
|
||||
*
|
||||
* 后端会把“思考过程”和“最终回答”混在同一个 answer 字段里返回,
|
||||
* UI 需要分别展示在 `SingleRagAnswer` 的思考区和答案区。这个函数只做纯文本拆分,
|
||||
* 不依赖 React 状态,因此既可用于模拟数据,也可作为对比模式里的兜底空 trace。
|
||||
*/
|
||||
export const parseSingleRagStream = (chunks: { answer: string }[]): QASingleRagTrace => {
|
||||
let thinkingMarkdown = '';
|
||||
let answerMarkdown = '';
|
||||
let inThink = false;
|
||||
let thinkingComplete = false;
|
||||
|
||||
const appendToken = (token: string) => {
|
||||
let remaining = token;
|
||||
while (remaining) {
|
||||
if (inThink) {
|
||||
const endIdx = remaining.indexOf('</think>');
|
||||
if (endIdx === -1) {
|
||||
thinkingMarkdown += remaining;
|
||||
break;
|
||||
}
|
||||
thinkingMarkdown += remaining.slice(0, endIdx);
|
||||
inThink = false;
|
||||
thinkingComplete = true;
|
||||
remaining = remaining.slice(endIdx + '</think>'.length);
|
||||
} else {
|
||||
const startIdx = remaining.indexOf('<think>');
|
||||
if (startIdx === -1) {
|
||||
answerMarkdown += remaining;
|
||||
break;
|
||||
}
|
||||
answerMarkdown += remaining.slice(0, startIdx);
|
||||
inThink = true;
|
||||
remaining = remaining.slice(startIdx + '<think>'.length);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chunks.forEach((chunk) => appendToken(chunk.answer));
|
||||
|
||||
return {
|
||||
title: '单轮 RAG 思考过程',
|
||||
thinkingMarkdown,
|
||||
answerMarkdown,
|
||||
thinkingComplete,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 构造单轮 RAG 的演示 trace。
|
||||
*
|
||||
* 首屏默认需要一条完整的示例问答,帮助用户理解“思考过程 + 答案 + 引用”如何展示。
|
||||
* 这里保留原组件中的演示内容,避免拆分后默认展示行为发生变化。
|
||||
*/
|
||||
export const createSingleRagTrace = (): QASingleRagTrace => parseSingleRagStream([
|
||||
{ answer: '<think>' },
|
||||
{ answer: '需要回答用户关于 **RAG 工作原理** 的问题。\n\n' },
|
||||
{ answer: '先根据知识库召回内容组织核心流程:\n\n' },
|
||||
{ answer: '- 检索阶段:查找相关知识片段\n' },
|
||||
{ answer: '- 增强阶段:将证据注入提示词\n' },
|
||||
{ answer: '- 生成阶段:模型基于上下文生成答案\n\n' },
|
||||
{ answer: '回答需要保持结构清晰,并说明它为什么能降低幻觉。' },
|
||||
{ answer: '</think>' },
|
||||
{ answer: 'RAG(Retrieval-Augmented Generation,检索增强生成)是一种把 **知识库检索** 和 **大模型生成** 结合起来的问答方式。\n\n' },
|
||||
{ answer: '### 工作流程\n\n' },
|
||||
{ answer: '1. **问题理解**:系统接收用户问题并生成检索查询。\n' },
|
||||
{ answer: '2. **知识检索**:从知识库中召回相关文档片段。\n' },
|
||||
{ answer: '3. **上下文增强**:把检索到的内容和用户问题一起放入 Prompt。\n' },
|
||||
{ answer: '4. **模型生成**:大模型基于增强后的上下文生成回答。\n\n' },
|
||||
{ answer: '这种方式可以让回答更贴近已有资料,同时提升可追溯性。' },
|
||||
]);
|
||||
|
||||
/**
|
||||
* 构造多轮/深度检索的演示思考链。
|
||||
*
|
||||
* 真实多轮请求会通过 SSE 持续补齐 rounds;深度检索当前仍走本地模拟,
|
||||
* 因此这个函数保留不同模式下的轮次差异,用于未接入真实接口时的展示。
|
||||
*/
|
||||
export const createThinkingTrace = (mode: QAMode): QAThinkingTrace => {
|
||||
// 初始化轮次数组,第一轮总是基础检索
|
||||
const rounds = [
|
||||
{
|
||||
id: 'round-1', // 第一轮唯一标识
|
||||
title: '理解问题并拆解检索方向', // 本轮主题:理解问题并确定检索方向
|
||||
thoughtMarkdown: '用户询问 **RAG 技术工作原理**。需要先明确核心链路:\n\n- 问题理解与向量化\n- 知识库召回\n- 上下文增强\n- 大模型生成\n\n第一轮优先检索基础定义和主流程。', // 模型思考过程,展示分析步骤
|
||||
query: 'RAG 检索增强生成 工作原理 基础流程', // 发送给检索系统的查询词
|
||||
queryShow: true,
|
||||
retrievals: [ // 第一轮检索返回的资料列表
|
||||
{
|
||||
id: 'r1-1',
|
||||
title: 'RAG技术白皮书',
|
||||
content: 'RAG 将外部知识检索结果作为上下文注入生成模型,能够降低幻觉并提升答案的时效性和可追溯性。',
|
||||
source: '知识库 / RAG技术白皮书.pdf',
|
||||
},
|
||||
{
|
||||
id: 'r1-2',
|
||||
title: 'LLM应用实践:检索增强生成',
|
||||
content: '典型 RAG 流程包括查询改写、向量检索、片段重排、Prompt 组装和答案生成。',
|
||||
source: '知识库 / LLM应用实践.docx',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 多轮模式和深度检索模式需要第二轮检索,补充更多细节
|
||||
if (mode !== 'single-round') {
|
||||
// 添加第二轮检索:补充多轮检索与重排机制的差异
|
||||
rounds.push({
|
||||
id: 'round-2', // 第二轮的唯一标识
|
||||
title: '补充多轮检索与重排机制', // 本轮检索的主题
|
||||
thoughtMarkdown: '基础链路已经明确,继续检索 **多轮 RAG** 和 **深度检索** 的差异:\n\n1. 是否需要 Query 改写\n2. 是否需要对子问题分别召回\n3. 是否需要结果重排与证据融合',
|
||||
query: '多轮 RAG Query 改写 子问题检索 结果重排', // 本轮发送给检索系统的查询词
|
||||
// 本轮检索返回的资料列表
|
||||
queryShow: true,
|
||||
retrievals: [
|
||||
{
|
||||
id: 'r2-1',
|
||||
title: '高级RAG技术指南',
|
||||
content: '复杂问题通常需要分解为多个子问题,并对每个子问题分别检索,再通过重排模型筛选高相关证据。',
|
||||
source: '资料库 / 高级RAG技术指南.md',
|
||||
},
|
||||
{
|
||||
id: 'r2-2',
|
||||
title: 'RAG召回与重排最佳实践',
|
||||
content: '重排阶段可使用 Cross Encoder 或 LLM 评分,按相关性、覆盖度和可信度筛选最终上下文。',
|
||||
source: '行业资料 / RAG最佳实践.html',
|
||||
},
|
||||
{
|
||||
id: 'r2-3',
|
||||
title: '检索增强系统评测报告',
|
||||
content: '多阶段检索可提升复杂问题的召回率,但会增加延迟和计算成本,需要在效果与响应速度之间平衡。',
|
||||
source: '评测库 / 检索增强系统评测报告.xlsx',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 深度检索模式需要第三轮,综合所有证据并形成最终回答结构
|
||||
if (mode === 'deep-search') {
|
||||
rounds.push({
|
||||
id: 'round-3', // 第三轮的唯一标识
|
||||
title: '综合证据并形成回答结构', // 本轮的主题:整合前面检索到的所有证据,组织成完整的回答
|
||||
thoughtMarkdown: '当前证据已覆盖定义、流程和优化机制。最后需要组织成清晰结构:\n\n- 一句话定义\n- 分阶段流程\n- 技术价值\n- 适用场景与注意事项',
|
||||
query: 'RAG 技术价值 幻觉 时效性 可追溯性 应用场景', // 本轮查询:关于 RAG 价值和应用场景的信息
|
||||
queryShow: true,
|
||||
retrievals: [
|
||||
{
|
||||
id: 'r3-1',
|
||||
title: '企业知识问答系统建设方案',
|
||||
content: 'RAG 在企业知识问答中常用于连接内部文档、制度库和业务数据库,使回答具备来源依据。',
|
||||
source: '方案库 / 企业知识问答系统建设方案.pptx',
|
||||
},
|
||||
{
|
||||
id: 'r3-2',
|
||||
title: '生成式AI落地风险控制',
|
||||
content: 'RAG 不能完全消除幻觉,仍需要上下文质量控制、引用展示、权限过滤和答案审核机制。',
|
||||
source: '风控库 / 生成式AI落地风险控制.pdf',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
title: mode === 'single-round' ? '单轮检索思考过程' : mode === 'multi-round' ? '多轮检索思考过程' : '深度检索思考过程',
|
||||
rounds,
|
||||
finalAnswerMarkdown: 'RAG(Retrieval-Augmented Generation,检索增强生成)是一种把 **外部知识检索** 和 **大模型生成** 结合起来的技术架构。\n\n### 核心流程\n\n1. **理解问题**:系统解析用户问题,并可能进行 Query 改写或问题拆解。\n2. **检索证据**:从知识库、文档库或业务数据源中召回相关片段。\n3. **筛选重排**:对召回片段按相关性和可信度排序,保留高质量上下文。\n4. **增强生成**:将问题和检索证据一起发送给大模型,生成回答。\n5. **引用溯源**:展示答案依据,便于用户核验。\n\n### 主要价值\n\n- 降低模型幻觉\n- 引入最新或私有知识\n- 提升回答可解释性和可追溯性\n- 适合企业知识问答、政策解读、资料分析等场景',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 构造流式多轮检索的初始空 trace。
|
||||
*
|
||||
* 请求刚建立时还没有真正的工具调用结果,先渲染一个“正在准备检索”的轮次,
|
||||
* 可以让用户立即看到助手进入工作状态,后续 SSE 事件会继续更新这个 trace。
|
||||
*/
|
||||
export const createMultiRoundStreamTrace = (): QAThinkingTrace => ({
|
||||
title: '多轮检索思考过程',
|
||||
rounds: [{
|
||||
id: `round-${Date.now()}`,
|
||||
title: '正在准备检索',
|
||||
thoughtMarkdown: '',
|
||||
query: '',
|
||||
queryShow: true,
|
||||
queryLoading: true,
|
||||
retrievals: [],
|
||||
}],
|
||||
thinkingComplete: false,
|
||||
finalAnswerMarkdown: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* 为对比模式创建左右面板的初始状态。
|
||||
*
|
||||
* 对比模式会并发运行两个不同的问答模式;每个面板需要独立维护 loading、trace 和 sources。
|
||||
* 该工厂函数把不同模式的初始 trace 统一封装,避免页面组件重复判断。
|
||||
*/
|
||||
export const createComparePane = (mode: QAMode): ComparePane => ({
|
||||
mode,
|
||||
loading: true,
|
||||
singleRagTrace: (mode === 'single-round' || mode === 'single-chat')
|
||||
? {
|
||||
title: '单轮 RAG 思考过程',
|
||||
thinkingMarkdown: '',
|
||||
answerMarkdown: '',
|
||||
thinkingComplete: false,
|
||||
}
|
||||
: undefined,
|
||||
thinkingTrace: (mode === 'single-round' || mode === 'single-chat') ? undefined : createMultiRoundStreamTrace(),
|
||||
sources: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* 首屏演示消息。
|
||||
*
|
||||
* 页面首次进入时,如果没有用户消息,会展示两轮预置问答,说明引用、思考过程和追问的展示形态。
|
||||
* 拆分时完整保留原始 mock 数据,确保首屏体验不变。
|
||||
*/
|
||||
export const createMockSingleRagMessages = (): Message[] => ([
|
||||
{
|
||||
id: 'mock-user-1',
|
||||
role: 'user',
|
||||
content: 'RAG 在企业知识问答里具体怎么落地?',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'mock-assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
singleRagTrace: {
|
||||
title: '单轮 RAG 思考过程',
|
||||
thinkingMarkdown: '先明确用户要的是“可落地方案”,所以需要回答:\n\n1. 数据准备与切片策略\n2. 检索与重排链路\n3. 答案引用与可追溯性\n\n优先给出一套可直接上线的最小闭环。',
|
||||
answerMarkdown: '可以按“**数据层 → 检索层 → 生成层 → 评估层**”四步落地:\n\n1. **数据层**:把制度、FAQ、SOP 文档切片并向量化,按业务域建索引。[[1]]\n2. **检索层**:先向量召回,再用重排模型做精排,提升命中质量。[[2]]\n3. **生成层**:将 TopK 证据注入 Prompt,要求回答必须带引用编号。[[3]]\n4. **评估层**:持续监控命中率、引用准确率和用户满意度。\n\n如果你要,我可以下一步给你一份“可直接给研发执行”的字段级配置清单。',
|
||||
thinkingComplete: true,
|
||||
},
|
||||
sources: [
|
||||
{
|
||||
title: '企业知识问答平台建设指南',
|
||||
type: '知识库',
|
||||
snippet: '建议按业务域拆分索引,并保持文档版本可追溯。',
|
||||
source: '内部架构组',
|
||||
time: '2026-03-18',
|
||||
url: 'https://example.com/rag-enterprise-guide',
|
||||
content: '企业落地 RAG 时,建议先从高频问答域切入,采用“文档切片 + 向量索引 + 重排”三层方案,确保上线初期可控。',
|
||||
},
|
||||
{
|
||||
title: 'RAG 重排实践报告',
|
||||
type: '技术报告',
|
||||
snippet: '仅向量召回会引入噪声,重排可显著提升答案相关性。',
|
||||
source: 'NLP 团队',
|
||||
time: '2026-01-09',
|
||||
url: 'https://example.com/rag-rerank-report',
|
||||
content: '在 20 万条文档规模下,向量 Top50 + CrossEncoder 重排到 Top6,可使人工评测正确率提升约 12%。',
|
||||
},
|
||||
{
|
||||
title: '可追溯生成规范',
|
||||
type: '规范',
|
||||
snippet: '回答输出应绑定证据编号,便于用户核验。',
|
||||
source: 'AI 治理委员会',
|
||||
time: '2025-12-22',
|
||||
url: 'https://example.com/traceable-generation-spec',
|
||||
content: '推荐在答案中输出 [[n]] 形式的引用标识,并支持点击查看证据明细、来源与时间信息。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-user-2',
|
||||
role: 'user',
|
||||
content: '那单轮和多轮检索的差异是什么?',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'mock-assistant-2',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
singleRagTrace: {
|
||||
title: '单轮 RAG 思考过程',
|
||||
thinkingMarkdown: '用户在做方案选型,回答要突出“复杂问题覆盖率”和“时延成本”两个维度。',
|
||||
answerMarkdown: '**单轮检索**更快,适合事实性问题;**多轮检索**覆盖更全,适合复杂推理问题。通常实践里会先单轮,召回不足再升级多轮。[[1]][[2]]',
|
||||
thinkingComplete: true,
|
||||
},
|
||||
sources: [
|
||||
{
|
||||
title: 'RAG 模式选型对比',
|
||||
type: '白皮书',
|
||||
snippet: '单轮偏性能,多轮偏完整性。',
|
||||
source: '智能搜索实验室',
|
||||
time: '2026-02-11',
|
||||
url: 'https://example.com/rag-mode-comparison',
|
||||
content: '复杂查询在多轮检索中召回覆盖更高,但会增加 20%-60% 延迟,需按业务 SLA 选择。',
|
||||
},
|
||||
{
|
||||
title: '检索系统时延评测',
|
||||
type: '评测',
|
||||
snippet: '多轮策略会增加查询与重排调用次数。',
|
||||
source: '平台性能组',
|
||||
time: '2026-02-27',
|
||||
url: 'https://example.com/rag-latency-benchmark',
|
||||
content: '在同等资源下,多轮检索平均时延高于单轮检索,但对于复杂问题的回答完整度更高。',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
/**
|
||||
* 将多轮检索接口返回的 references 项映射为通用引用来源。
|
||||
*/
|
||||
export const mapReferenceToSource = (item: { title?: string; content?: string; snippet?: string }): SourceItem => ({
|
||||
title: item.title || '未命名来源',
|
||||
type: '参考资料',
|
||||
snippet: item.snippet || item.content || '',
|
||||
content: item.content || item.snippet || '',
|
||||
});
|
||||
|
||||
/**
|
||||
* 将知识库单轮接口返回的 docs 映射为页面引用来源。
|
||||
*/
|
||||
export const mapDocsToSources = (docs: KnowledgeBaseChunk['docs']): SourceItem[] => {
|
||||
const normalizedDocs = Array.isArray(docs) ? docs : docs ? [docs] : [];
|
||||
|
||||
return normalizedDocs.map((item, idx) => {
|
||||
console.log(item,'检索资料')
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
title: '',
|
||||
type: '知识库',
|
||||
snippet: item,
|
||||
source: '',
|
||||
time: '',
|
||||
content: item,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: item.title || item.source || `参考资料 ${idx + 1}`,
|
||||
type: '知识库',
|
||||
snippet: item.snippet || item.content || item.url || '',
|
||||
source: item.source || item.title || '',
|
||||
time: '',
|
||||
url: item.url,
|
||||
content: item.content || item.snippet || '',
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const chatDataJSON = (chatJSON: string) => {
|
||||
let chatJSONText = chatJSON.replace(/}{"/g, '},{"');
|
||||
chatJSONText = '[' + chatJSONText + ']';
|
||||
// console.log('解析chat',chatJSONText);
|
||||
let chatData = [];
|
||||
let chatTitle: any = '';
|
||||
const docsList: any[] = [];
|
||||
try {
|
||||
chatData = JSON.parse(chatJSONText);
|
||||
chatData.forEach((item: any) => {
|
||||
if (item['answer']) {
|
||||
chatTitle += item['answer'];
|
||||
} else if (item['docs']) {
|
||||
docsList.push(item);
|
||||
}
|
||||
});
|
||||
return {
|
||||
chatTitle,
|
||||
docsList,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
chatTitle,
|
||||
docsList,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从流式文本中提取完整 JSON 对象。
|
||||
*
|
||||
* SSE 响应可能把一个 JSON 切成多段返回;该函数通过花括号深度和字符串状态,
|
||||
* 找到已经完整闭合的对象,并把未闭合尾巴放回 `rest` 等待下一批数据拼接。
|
||||
*/
|
||||
export const extractJsonObjects = (raw: string): { objects: string[]; rest: string } => {
|
||||
const objects: string[] = [];
|
||||
let depth = 0;
|
||||
let stringQuote: '"' | "'" | null = null;
|
||||
let escaped = false;
|
||||
let start = -1;
|
||||
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
const ch = raw[i];
|
||||
|
||||
if (stringQuote) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (ch === '\\') {
|
||||
escaped = true;
|
||||
} else if (ch === stringQuote) {
|
||||
stringQuote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
stringQuote = ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '{') {
|
||||
if (depth === 0) start = i;
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0 && start !== -1) {
|
||||
objects.push(raw.slice(start, i + 1));
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (depth > 0 && start !== -1) {
|
||||
return { objects, rest: raw.slice(start) };
|
||||
}
|
||||
|
||||
return { objects, rest: '' };
|
||||
};
|
||||
|
||||
// 处理平台数据
|
||||
export const platformTypeChange = (platform: string) => {
|
||||
let platformType: string = '';
|
||||
if (platform.includes('fb')) {
|
||||
platformType = 'facebook';
|
||||
} else if (platform.includes('x')) {
|
||||
platformType = 'x';
|
||||
} else if (platform.includes('in')) {
|
||||
platformType = 'instagram';
|
||||
} else if (platform.includes('re')) {
|
||||
platformType = 'reddit';
|
||||
} else if (platform.includes('th')) {
|
||||
platformType = 'threads';
|
||||
} else {
|
||||
platformType = 'facebook';
|
||||
}
|
||||
return platformType;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { QASingleRagTrace, QAThinkingTrace } from './QACommon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type QAMode = 'single-chat' | 'single-round' | 'multi-round' | 'deep-search' | 'hx-search' | 'news-report';
|
||||
export type ViewMode = 'single' | 'compare';
|
||||
export type KnowledgeScope = 'all' | 'specific-kb' | 'specific-domain';
|
||||
export type AnswerStyle = 'concise' | 'detailed';
|
||||
export type CompareLayout = 'horizontal' | 'vertical';
|
||||
|
||||
export interface SourceItem {
|
||||
title: string;
|
||||
type: string;
|
||||
snippet: string;
|
||||
source?: string;
|
||||
time?: string;
|
||||
url?: string;
|
||||
keywords?: string;
|
||||
taskDirection?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
singleRagTrace?: QASingleRagTrace;
|
||||
thinkingTrace?: QAThinkingTrace;
|
||||
sources?: SourceItem[];
|
||||
}
|
||||
|
||||
export interface ToolCallData {
|
||||
tool_name: string;
|
||||
tool_input: {
|
||||
text?: string;
|
||||
query?: string;
|
||||
q?: string;
|
||||
keyword?: string;
|
||||
platform?: string;
|
||||
user_fig?: string;
|
||||
result?: string;
|
||||
s?: string;
|
||||
e?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseDoc {
|
||||
title?: string;
|
||||
content?: string;
|
||||
source?: string;
|
||||
url?: string;
|
||||
snippet?: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseChunk {
|
||||
answer?: string;
|
||||
docs?: KnowledgeBaseDoc[] | KnowledgeBaseDoc | string;
|
||||
}
|
||||
|
||||
export interface ComparePane {
|
||||
mode: QAMode;
|
||||
loading: boolean;
|
||||
singleRagTrace?: QASingleRagTrace;
|
||||
thinkingTrace?: QAThinkingTrace;
|
||||
sources: SourceItem[];
|
||||
}
|
||||
|
||||
export interface CompareResult {
|
||||
id: string | number;
|
||||
compareUserInput: string;
|
||||
left: ComparePane;
|
||||
right: ComparePane;
|
||||
differences?: {
|
||||
consensus: string;
|
||||
differences: string;
|
||||
leftAdvantage: string;
|
||||
rightAdvantage: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SearchProcessState {
|
||||
currentMode: string;
|
||||
rounds: number;
|
||||
hits: number;
|
||||
queryRewrite: string;
|
||||
}
|
||||
|
||||
export interface stateItem {
|
||||
compareIndex: number;
|
||||
compareId: string;
|
||||
messageList: Message[],
|
||||
compareResult: CompareResult[];
|
||||
}
|
||||
|
||||
export interface qaModeItem {
|
||||
value: QAMode;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
desc: string;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { BookOpen, Bot, Brain, ChevronLeft, ChevronRight, Sparkles, User2Icon } from 'lucide-react';
|
||||
import type { AnswerStyle, CompareLayout, KnowledgeScope, QAMode, qaModeItem, ViewMode } from './GeneralQA.types';
|
||||
|
||||
/**
|
||||
* 左侧配置栏 Props。
|
||||
*
|
||||
* 组件内不保存业务状态,所有选中值和 setter 都来自父组件。
|
||||
* 这样父组件可以统一处理“生成中切换需要确认停止”的逻辑,配置栏只负责触发用户意图。
|
||||
*/
|
||||
type Props = {
|
||||
isDark: boolean; // 当前是否为暗黑主题,用于样式适配
|
||||
viewMode: ViewMode; // 当前视图模式:'single' 单一模式 或 'compare' 对比模式
|
||||
qaMode: QAMode; // 单一模式下的问答模式:'single-round' 单轮RAG / 'multi-round' 多轮RAG / 'deep-search' 深度检索
|
||||
compareLeftMode: QAMode; // 对比模式下左侧的问答模式
|
||||
compareRightMode: QAMode; // 对比模式下右侧的问答模式
|
||||
compareLayout: CompareLayout; // 对比模式的布局方式:'horizontal' 水平 或 'vertical' 垂直
|
||||
knowledgeScope: KnowledgeScope; // 知识范围设置:'all' 全部 / 'specific-kb' 指定知识库 / 'specific-domain' 指定领域
|
||||
answerStyle: AnswerStyle; // 回答风格偏好:'concise' 简洁 或 'detailed' 详细
|
||||
showSources: boolean; // 是否在回答中展示引用来源
|
||||
showProcess: boolean; // 是否展示模型的思考过程
|
||||
autoSummarizeDiff: boolean; // 对比模式下是否自动总结左右差异
|
||||
recommendBetter: boolean; // 对比模式下是否推荐更好的回答方案
|
||||
isCollapsed: boolean; // 左侧配置面板是否折叠
|
||||
qaModeOptions?: qaModeItem[];
|
||||
setQaMode: (mode: QAMode) => void; // 设置单一模式的问答模式
|
||||
setCompareLeftMode: (mode: QAMode) => void; // 设置对比模式左侧的模式
|
||||
setCompareRightMode: (mode: QAMode) => void; // 设置对比模式右侧的模式
|
||||
setCompareLayout: (layout: CompareLayout) => void; // 设置对比布局方式
|
||||
setKnowledgeScope: (scope: KnowledgeScope) => void; // 设置知识范围
|
||||
setAnswerStyle: (style: AnswerStyle) => void; // 设置回答风格
|
||||
setShowSources: (show: boolean) => void; // 设置是否展示引用来源
|
||||
setShowProcess: (show: boolean) => void; // 设置是否展示思考过程
|
||||
setAutoSummarizeDiff: (enabled: boolean) => void; // 设置是否自动总结差异
|
||||
setRecommendBetter: (enabled: boolean) => void; // 设置是否推荐更好的方案
|
||||
setIsCollapsed: (collapsed: boolean) => void; // 设置配置面板折叠状态
|
||||
};
|
||||
|
||||
/**
|
||||
* 单一模式下可选的问答模式。
|
||||
*
|
||||
* 每个选项同时提供展示文案、图标和简短说明,便于配置栏用统一结构渲染卡片。
|
||||
*/
|
||||
const qaModeList: qaModeItem[] = [
|
||||
{ value: 'single-chat' as const, label: '快速', icon: Bot, desc: '快速直接' },
|
||||
{ value: 'single-round' as const, label: '单轮RAG', icon: Sparkles, desc: '快速直接' },
|
||||
{ value: 'multi-round' as const, label: '多轮RAG', icon: Brain, desc: '深入理解' },
|
||||
{ value: 'deep-search' as const, label: '任务检索', icon: BookOpen, desc: '检索相关任务' },
|
||||
{ value: 'hx-search' as const, label: '画像检索', icon: User2Icon, desc: '检索相关账号' },
|
||||
{ value: 'news-report' as const, label: '新闻报告生成', icon: User2Icon, desc: '检索相关新闻' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 知识范围选项。
|
||||
*
|
||||
* 当前只影响 UI 配置状态,后续接入真实知识库筛选时可以直接把该值映射到请求参数。
|
||||
*/
|
||||
const knowledgeScopeOptions = [
|
||||
{ value: 'all' as const, label: '全部知识源' },
|
||||
{ value: 'specific-kb' as const, label: '指定知识库' },
|
||||
{ value: 'specific-domain' as const, label: '指定资源域' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 左侧问答配置栏。
|
||||
*
|
||||
* 该组件集中承载页面的“模式选择、知识范围、回答偏好、对比设置”等表单控件。
|
||||
* 父组件仍是唯一状态源,配置栏通过 setter 把用户选择同步回父组件;这样不会改变原有请求逻辑,
|
||||
* 同时把 300 多行配置 JSX 从 `GeneralQA.tsx` 中移出,便于后续维护表单项和样式。
|
||||
*/
|
||||
export default function GeneralQAConfigSidebar({
|
||||
isDark,
|
||||
viewMode,
|
||||
qaMode,
|
||||
compareLeftMode,
|
||||
compareRightMode,
|
||||
compareLayout,
|
||||
knowledgeScope,
|
||||
answerStyle,
|
||||
showSources,
|
||||
showProcess,
|
||||
autoSummarizeDiff,
|
||||
recommendBetter,
|
||||
isCollapsed,
|
||||
qaModeOptions = qaModeList,
|
||||
setQaMode,
|
||||
setCompareLeftMode,
|
||||
setCompareRightMode,
|
||||
setCompareLayout,
|
||||
setKnowledgeScope,
|
||||
setAnswerStyle,
|
||||
setShowSources,
|
||||
setShowProcess,
|
||||
setAutoSummarizeDiff,
|
||||
setRecommendBetter,
|
||||
setIsCollapsed,
|
||||
}: Props): JSX.Element {
|
||||
/**
|
||||
* 自定义勾选图标。
|
||||
*
|
||||
* 原生 checkbox 被隐藏后,所有勾选项都通过这个 SVG 保持一致的视觉风格。
|
||||
*/
|
||||
const renderCheckIcon = () => (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${isCollapsed ? 'w-14 p-2' : 'w-72 p-4'} border-r flex-shrink-0 overflow-y-auto transition-all duration-300 ${
|
||||
isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'
|
||||
}`}>
|
||||
<div className={`flex items-center ${isCollapsed ? 'justify-center' : 'justify-between'} mb-4`}>
|
||||
{!isCollapsed && (
|
||||
<span className={`text-xs font-medium uppercase tracking-wider ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
问答配置
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:text-white hover:bg-slate-700/50'
|
||||
: 'text-gray-500 hover:text-red-600 hover:bg-red-50'
|
||||
}`}
|
||||
title={isCollapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{viewMode === 'single' ? (
|
||||
<div className="mb-6">
|
||||
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>问答模式</h3>
|
||||
<div className="space-y-2">
|
||||
{qaModeOptions.map((mode) => {
|
||||
const Icon = mode.icon;
|
||||
return (
|
||||
<button
|
||||
key={mode.value}
|
||||
onClick={() => setQaMode(mode.value)}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left ${
|
||||
qaMode === mode.value
|
||||
? isDark
|
||||
? 'bg-blue-600/20 border border-blue-500/50 text-blue-400'
|
||||
: 'bg-red-50 border border-red-200 text-red-600'
|
||||
: isDark
|
||||
? 'bg-slate-700/30 border border-transparent text-slate-400 hover:bg-slate-700/50'
|
||||
: 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{mode.label}</div>
|
||||
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{mode.desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比模式设置</h3>
|
||||
<div className="mb-4">
|
||||
<label className={`text-xs mb-2 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>模式A</label>
|
||||
<select value={compareLeftMode} onChange={(e) => setCompareLeftMode(e.target.value as QAMode)} className={`w-full px-3 py-2 rounded-lg text-sm ${isDark ? 'bg-slate-700/50 border border-slate-600 text-white' : 'bg-white border border-gray-200 text-gray-700'}`}>
|
||||
{
|
||||
qaModeOptions.filter(item => item.value !== compareRightMode).map(item => (
|
||||
<option value={item.value} key={item.value}>{item.label}</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div className={`text-center my-2 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}><span className="text-lg">VS</span></div>
|
||||
<div className="mb-4">
|
||||
<label className={`text-xs mb-2 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>模式B</label>
|
||||
<select value={compareRightMode} onChange={(e) => setCompareRightMode(e.target.value as QAMode)} className={`w-full px-3 py-2 rounded-lg text-sm ${isDark ? 'bg-slate-700/50 border border-slate-600 text-white' : 'bg-white border border-gray-200 text-gray-700'}`}>
|
||||
{
|
||||
qaModeOptions.filter(item => item.value !== compareLeftMode).map(item => (
|
||||
<option value={item.value} key={item.value}>{item.label}</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比布局</h3>
|
||||
<div className="flex gap-2">
|
||||
{(['horizontal', 'vertical'] as const).map((layout) => (
|
||||
<button key={layout} onClick={() => setCompareLayout(layout)} className={`flex-1 px-3 py-2 rounded-lg text-sm transition-all ${compareLayout === layout ? isDark ? 'bg-blue-600 text-white' : 'bg-red-500 text-white' : isDark ? 'bg-slate-700/50 text-slate-300' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{layout === 'horizontal' ? '左右对比' : '上下对比'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* <div className="mb-6">
|
||||
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>知识范围</h3>
|
||||
<div className="space-y-2">
|
||||
{knowledgeScopeOptions.map((option) => (
|
||||
<label key={option.value} className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${knowledgeScope === option.value ? isDark ? 'bg-blue-600/20 text-blue-400' : 'bg-red-50 text-red-600' : isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||||
<input type="radio" name="knowledge-scope" checked={knowledgeScope === option.value} onChange={() => setKnowledgeScope(option.value)} className="sr-only" />
|
||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${knowledgeScope === option.value ? isDark ? 'border-blue-500 bg-blue-500' : 'border-red-500 bg-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>
|
||||
{knowledgeScope === option.value && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
|
||||
</div>
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* <div className="mb-6">
|
||||
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>回答偏好</h3>
|
||||
<div className="space-y-3">
|
||||
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||||
<input type="checkbox" checked={answerStyle === 'detailed'} onChange={() => setAnswerStyle(answerStyle === 'detailed' ? 'concise' : 'detailed')} className="sr-only" />
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${answerStyle === 'detailed' ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{answerStyle === 'detailed' && renderCheckIcon()}</div>
|
||||
<span className="text-sm">详细回答</span>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||||
<input type="checkbox" checked={showSources} onChange={() => setShowSources(!showSources)} className="sr-only" />
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${showSources ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{showSources && renderCheckIcon()}</div>
|
||||
<span className="text-sm">展示引用来源</span>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||||
<input type="checkbox" checked={showProcess} onChange={() => setShowProcess(!showProcess)} className="sr-only" />
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${showProcess ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{showProcess && renderCheckIcon()}</div>
|
||||
<span className="text-sm">展示检索过程</span>
|
||||
</label>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* {viewMode === 'compare' && (
|
||||
<div className="mb-6">
|
||||
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比设置</h3>
|
||||
<div className="space-y-3">
|
||||
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||||
<input type="checkbox" checked={autoSummarizeDiff} onChange={() => setAutoSummarizeDiff(!autoSummarizeDiff)} className="sr-only" />
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${autoSummarizeDiff ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{autoSummarizeDiff && renderCheckIcon()}</div>
|
||||
<span className="text-sm">自动总结差异</span>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||||
<input type="checkbox" checked={recommendBetter} onChange={() => setRecommendBetter(!recommendBetter)} className="sr-only" />
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${recommendBetter ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{recommendBetter && renderCheckIcon()}</div>
|
||||
<span className="text-sm">推荐更优答案</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { History } from 'lucide-react';
|
||||
import type { PageType } from '../../components/Sidebar';
|
||||
import type { ViewMode } from './GeneralQA.types';
|
||||
|
||||
/**
|
||||
* 顶部栏 Props。
|
||||
*
|
||||
* `setViewMode` 和 `onNavigate` 由父组件传入,父组件会在生成中切换时做确认拦截;
|
||||
* Header 本身不直接感知 loading,保持纯展示和事件转发。
|
||||
*/
|
||||
type Props = {
|
||||
isDark: boolean; // 当前是否为暗黑主题,用于样式适配
|
||||
viewMode: ViewMode; // 当前视图模式:'single' 单一模式 或 'compare' 对比模式
|
||||
chatHeadTitle?: string;
|
||||
chatDesign?: string;
|
||||
compareShow?: boolean;
|
||||
setViewMode: (mode: ViewMode) => void; // 设置视图模式回调函数
|
||||
onNavigate: (page: PageType) => void; // 页面导航回调函数,用于跳转到其他页面(如历史记录)
|
||||
};
|
||||
|
||||
/**
|
||||
* 通用问答页面顶部栏。
|
||||
*
|
||||
* 该组件只负责页面标题、模式切换和历史记录入口,所有业务状态仍由父组件维护。
|
||||
* 这样拆分后,顶部导航的样式和交互不会和中间问答流处理逻辑混在一起,后续如果要新增
|
||||
* “保存模板”“导出记录”等入口,也可以只改这个文件。
|
||||
*/
|
||||
export default function GeneralQAHeader({ isDark, viewMode, chatHeadTitle = '通用问答', chatDesign = '支持单轮RAG、多轮RAG、深度检索及结果对比', compareShow = true, setViewMode, onNavigate }: Props): JSX.Element {
|
||||
return (
|
||||
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{chatHeadTitle}
|
||||
</h1>
|
||||
<span className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{chatDesign}
|
||||
</span>
|
||||
</div>
|
||||
{
|
||||
compareShow && <div className="flex items-center gap-2">
|
||||
<div className={`flex rounded-lg p-1 ${isDark ? 'bg-slate-700/50' : 'bg-red-50'}`}>
|
||||
<button
|
||||
onClick={() => setViewMode('single')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${viewMode === 'single'
|
||||
? isDark
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-red-500 text-white'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-white'
|
||||
: 'text-gray-600 hover:text-red-600'
|
||||
}`}
|
||||
>
|
||||
单一模式
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('compare')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${viewMode === 'compare'
|
||||
? isDark
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-red-500 text-white'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-white'
|
||||
: 'text-gray-600 hover:text-red-600'
|
||||
}`}
|
||||
>
|
||||
对比模式
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* <button
|
||||
onClick={() => onNavigate('qa-records')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-slate-700/50 text-slate-300 hover:bg-slate-600/50'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
历史记录
|
||||
</button> */}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,410 @@
|
||||
.mope-chat {
|
||||
--bg: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #ffffff;
|
||||
--card: #ffffff;
|
||||
--border: #fee2e2;
|
||||
--border-strong: #fecaca;
|
||||
--text: #111827;
|
||||
--text-muted: #6b7280;
|
||||
--accent: #dc2626;
|
||||
--accent-hover: #ef4444;
|
||||
--green: #16a34a;
|
||||
--yellow: #f59e0b;
|
||||
--red: #dc2626;
|
||||
--purple: #7c3aed;
|
||||
--button-text: #ffffff;
|
||||
--surface-hover: rgba(220, 38, 38, 0.04);
|
||||
--surface-hover-strong: rgba(220, 38, 38, 0.08);
|
||||
--accent-soft: rgba(220, 38, 38, 0.08);
|
||||
--accent-soft-strong: rgba(220, 38, 38, 0.16);
|
||||
--danger-soft: rgba(220, 38, 38, 0.10);
|
||||
--shadow-soft: 0 14px 40px rgba(15, 23, 42, 0.07);
|
||||
--shadow-card: 0 8px 24px rgba(15, 23, 42, 0.05);
|
||||
|
||||
--text-primary: var(--text);
|
||||
--text-secondary: var(--text-muted);
|
||||
--border-color: var(--border);
|
||||
--bg-secondary: var(--surface);
|
||||
--accent-blue: var(--accent);
|
||||
|
||||
--phase-starting: var(--accent);
|
||||
--phase-diverging: var(--yellow);
|
||||
--phase-closing: var(--green);
|
||||
--phase-done: var(--purple);
|
||||
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.mope-chat.mope-chat-dark {
|
||||
--bg: #0f172a;
|
||||
--surface: #1e293b;
|
||||
--surface-raised: #111827;
|
||||
--card: #0f172a;
|
||||
--border: rgba(30, 64, 175, 0.45);
|
||||
--border-strong: rgba(59, 130, 246, 0.45);
|
||||
--text: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #60a5fa;
|
||||
--green: #10b981;
|
||||
--yellow: #f59e0b;
|
||||
--red: #f43f5e;
|
||||
--purple: #8b5cf6;
|
||||
--button-text: #ffffff;
|
||||
--surface-hover: rgba(255, 255, 255, 0.03);
|
||||
--surface-hover-strong: rgba(255, 255, 255, 0.06);
|
||||
--accent-soft: rgba(59, 130, 246, 0.14);
|
||||
--accent-soft-strong: rgba(59, 130, 246, 0.22);
|
||||
--danger-soft: rgba(244, 63, 94, 0.12);
|
||||
--shadow-soft: 0 18px 45px rgba(0, 0, 0, 0.28);
|
||||
--shadow-card: 0 12px 28px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.mope-chat,
|
||||
.mope-chat * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mope-chat {
|
||||
color: var(--text);
|
||||
min-height: 100%;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.mope-chat input,
|
||||
.mope-chat textarea,
|
||||
.mope-chat select,
|
||||
.mope-chat button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.mope-chat p,
|
||||
.mope-chat h1,
|
||||
.mope-chat h2,
|
||||
.mope-chat h3,
|
||||
.mope-chat h4,
|
||||
.mope-chat pre {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.mope-chat ::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.mope-chat ::-webkit-scrollbar-track {
|
||||
background: var(--surface);
|
||||
}
|
||||
.mope-chat ::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mope-chat ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Idle: centered form */
|
||||
.mope-chat.app-idle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 96px);
|
||||
padding: 36px 40px 42px;
|
||||
background:
|
||||
radial-gradient(circle at top center, var(--accent-soft) 0, transparent 34%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.mope-chat.app-idle-scroll {
|
||||
align-items: flex-start;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Running / done: two-column */
|
||||
.mope-chat.app-running {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 96px);
|
||||
overflow: hidden;
|
||||
background: var(--surface-raised);
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.mope-chat .app-header {
|
||||
background: color-mix(in srgb, var(--surface) 92%, var(--accent) 8%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mope-chat .app-header-logo {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.mope-chat .app-header-task {
|
||||
flex: 1;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mope-chat .app-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mope-chat .app-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mope-chat .app-sidebar {
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--surface) 96%, var(--accent) 4%);
|
||||
}
|
||||
|
||||
.mope-chat .app-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.mope-chat .app-progress-sidebar {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--surface) 96%, var(--accent) 4%);
|
||||
}
|
||||
|
||||
.mope-chat .app-side-panel {
|
||||
position: relative;
|
||||
transition: width 0.22s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.mope-chat .app-side-panel.collapsed {
|
||||
width: 44px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mope-chat .app-side-toggle {
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
z-index: 2;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-muted);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.mope-chat .app-side-toggle:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-hover-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mope-chat .app-side-toggle-left {
|
||||
float: right;
|
||||
margin: 10px 10px 0 0;
|
||||
}
|
||||
|
||||
.mope-chat .app-side-toggle-right {
|
||||
float: left;
|
||||
margin: 10px 0 0 10px;
|
||||
}
|
||||
|
||||
.mope-chat .app-side-panel.collapsed .app-side-toggle-left,
|
||||
.mope-chat .app-side-panel.collapsed .app-side-toggle-right {
|
||||
float: none;
|
||||
margin: 10px auto 0;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.mope-chat button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s, background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.mope-chat button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mope-chat button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mope-chat .btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
||||
color: var(--button-text);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 10px 22px var(--accent-soft-strong);
|
||||
}
|
||||
|
||||
.mope-chat .btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.mope-chat .btn-danger {
|
||||
background: transparent;
|
||||
color: var(--red);
|
||||
border: 1px solid var(--red);
|
||||
}
|
||||
|
||||
.mope-chat .btn-danger:hover:not(:disabled) {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.mope-chat .btn-secondary {
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.mope-chat .btn-secondary:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.mope-chat .btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mope-chat .btn-ghost:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.mope-chat .badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.mope-chat .badge-starting { background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); }
|
||||
.mope-chat .badge-diverging { background: color-mix(in srgb, var(--yellow) 15%, transparent); color: var(--yellow); }
|
||||
.mope-chat .badge-closing { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); }
|
||||
.mope-chat .badge-done { background: color-mix(in srgb, var(--purple) 15%, transparent); color: var(--purple); }
|
||||
.mope-chat .badge-default { background: color-mix(in srgb, var(--text-muted) 15%, transparent); color: var(--text-muted); }
|
||||
|
||||
/* Spinner */
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.mope-chat .spinner {
|
||||
display: inline-block;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Reference cite */
|
||||
.mope-chat .ref-cite {
|
||||
display: inline-block;
|
||||
background: var(--accent-soft-strong);
|
||||
color: var(--accent);
|
||||
border-radius: 4px;
|
||||
padding: 0 3px;
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mope-chat .ref-cite:hover::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
z-index: 100;
|
||||
font-family: inherit;
|
||||
pointer-events: none;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Error banner */
|
||||
.mope-chat .error-banner {
|
||||
background: var(--danger-soft);
|
||||
border: 1px solid var(--red);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
color: var(--red);
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.mope-chat .app-progress-sidebar {
|
||||
width: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.mope-chat .app-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mope-chat .app-sidebar,
|
||||
.mope-chat .app-progress-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
.mope-chat .app-main {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useSession } from '@/hooks/useSession';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import ConfigPanel from './components/ConfigPanel';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Timeline from './components/Timeline';
|
||||
import ProgressSidebar from './components/ProgressSidebar';
|
||||
import ScenarioConfigPage from './components/ScenarioConfigPage';
|
||||
import './MopeChat.css';
|
||||
import { useChatContext } from '@/contexts/ChatContext';
|
||||
import { requestCrudService } from '@/service/request-axios-service';
|
||||
|
||||
type StartConfig = {
|
||||
task_context?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type SessionError = {
|
||||
agent_id?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type SessionHistoryPayload = {
|
||||
typeName: string;
|
||||
templateName: string;
|
||||
resultJson: Record<string, any>;
|
||||
};
|
||||
|
||||
const MOPE_HISTORY_API_BASE = '/api/mope/history';
|
||||
const backendUrls = import.meta.env.VITE_AIPI_MOPECHAT_URL || '';
|
||||
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const service = requestCrudService;
|
||||
/**
|
||||
* 保存模型完整生成结果。
|
||||
*
|
||||
* 接口暂未提供,先把调用方法和请求体结构固定下来:
|
||||
* - typeName:类型名字,目前使用用户输入的任务背景。
|
||||
* - templateName:用户选择的生成模板名称。
|
||||
* - resultJson:完整结果 JSON,包含 timeline、agentStates、reportSchema、finalReport 等恢复页面所需数据。
|
||||
*/
|
||||
async function saveMopeHistory(payload: SessionHistoryPayload) {
|
||||
console.log('保存了吗',payload )
|
||||
const response = await fetch(MOPE_HISTORY_API_BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`保存 MOPE 历史失败:HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json().catch(() => null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户最近一次 MOPE 生成历史。
|
||||
*
|
||||
* 接口暂未提供,先约定返回值就是保存时的 resultJson。
|
||||
* 如果接口没有历史数据,应返回 null / 空对象 / 404,页面会保持当前未生成状态。
|
||||
*/
|
||||
async function fetchMopeHistory(): Promise<Record<string, any> | null> {
|
||||
|
||||
const response = await fetch(MOPE_HISTORY_API_BASE, { method: 'GET' });
|
||||
|
||||
if (response.status === 404 || response.status === 204) return null;
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取 MOPE 历史失败:HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data?.resultJson || data || null;
|
||||
}
|
||||
|
||||
export default function MopeChat() {
|
||||
const session = useSession() as any;
|
||||
const { theme } = useTheme() as { theme?: string };
|
||||
const { startConfig } = useChatContext();
|
||||
const isDark = theme === 'dark';
|
||||
const [taskContext, setTaskContext] = useState<string>('');
|
||||
const [idlePage, setIdlePage] = useState<string>('home');
|
||||
const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
|
||||
const [isRightSidebarCollapsed, setIsRightSidebarCollapsed] = useState(false);
|
||||
|
||||
const {
|
||||
status,
|
||||
phase,
|
||||
coordinatorMode,
|
||||
currentRound,
|
||||
divergeRounds,
|
||||
reportSchema,
|
||||
agents,
|
||||
agentStates,
|
||||
timeline,
|
||||
sharedMemory,
|
||||
finalReportPartial,
|
||||
currentFinalField,
|
||||
pendingReview,
|
||||
reviewSubmitting,
|
||||
reviewRevising,
|
||||
errors,
|
||||
startSession,
|
||||
stopSession,
|
||||
approveCoordinator,
|
||||
reviseCoordinator,
|
||||
hydrateFromSnapshot,
|
||||
reset,
|
||||
} = session;
|
||||
|
||||
const historyLoadedRef = useRef(false);
|
||||
const savedHistoryKeyRef = useRef<string | null>(null);
|
||||
const currentConfigRef = useRef<StartConfig | null>(null);
|
||||
|
||||
const buildHistorySnapshot = useCallback(() => ({
|
||||
status,
|
||||
phase,
|
||||
coordinatorMode,
|
||||
currentRound,
|
||||
divergeRounds,
|
||||
reportSchema,
|
||||
agents,
|
||||
agentStates,
|
||||
timeline,
|
||||
sharedMemory,
|
||||
finalReportPartial,
|
||||
currentFinalField,
|
||||
config: currentConfigRef.current,
|
||||
}), [
|
||||
status,
|
||||
phase,
|
||||
coordinatorMode,
|
||||
currentRound,
|
||||
divergeRounds,
|
||||
reportSchema,
|
||||
agents,
|
||||
agentStates,
|
||||
timeline,
|
||||
sharedMemory,
|
||||
finalReportPartial,
|
||||
currentFinalField,
|
||||
]);
|
||||
|
||||
const handleStart = (config: StartConfig, backendUrl: string) => {
|
||||
currentConfigRef.current = config;
|
||||
savedHistoryKeyRef.current = null;
|
||||
setTaskContext(config.task_context || '');
|
||||
console.log('config', config)
|
||||
startSession(config, backendUrl);
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
console.log('state', )
|
||||
stopSession();
|
||||
const snapshot = buildHistorySnapshot();
|
||||
const templateName = currentConfigRef.current?.template_name || '未命名模板';
|
||||
const typeName = taskContext || currentConfigRef.current?.task_context || '未命名任务';
|
||||
const saveKey = `${typeName}::${templateName}::${timeline.length}`;
|
||||
if (savedHistoryKeyRef.current === saveKey) return;
|
||||
savedHistoryKeyRef.current = saveKey;
|
||||
|
||||
void saveMopeHistory({
|
||||
typeName,
|
||||
templateName,
|
||||
resultJson: {
|
||||
...snapshot,
|
||||
typeName,
|
||||
templateName,
|
||||
savedAt: new Date().toISOString(),
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.warn('MOPE 历史记录保存失败:', error);
|
||||
savedHistoryKeyRef.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
currentConfigRef.current = null;
|
||||
savedHistoryKeyRef.current = null;
|
||||
setTaskContext('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (startConfig) {
|
||||
handleStart({
|
||||
...startConfig,
|
||||
coordinator_mode: 'human_review',
|
||||
}, backendUrls);
|
||||
}
|
||||
console.log('startConfig', startConfig)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (historyLoadedRef.current) return;
|
||||
historyLoadedRef.current = true;
|
||||
|
||||
void fetchMopeHistory()
|
||||
.then((snapshot) => {
|
||||
if (!snapshot || !Array.isArray(snapshot.timeline) || snapshot.timeline.length === 0) return;
|
||||
currentConfigRef.current = snapshot.config || null;
|
||||
setTaskContext(snapshot.config?.task_context || snapshot.typeName || '');
|
||||
hydrateFromSnapshot(snapshot);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('MOPE 历史记录获取失败,保持默认空状态:', error);
|
||||
});
|
||||
}, [hydrateFromSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('status', status)
|
||||
if (status !== 'done' || !timeline?.length) return;
|
||||
const snapshot = buildHistorySnapshot();
|
||||
const templateName = currentConfigRef.current?.template_name || '未命名模板';
|
||||
const typeName = taskContext || currentConfigRef.current?.task_context || '未命名任务';
|
||||
const saveKey = `${typeName}::${templateName}::${timeline.length}`;
|
||||
if (savedHistoryKeyRef.current === saveKey) return;
|
||||
savedHistoryKeyRef.current = saveKey;
|
||||
|
||||
void saveMopeHistory({
|
||||
typeName,
|
||||
templateName,
|
||||
resultJson: {
|
||||
...snapshot,
|
||||
typeName,
|
||||
templateName,
|
||||
savedAt: new Date().toISOString(),
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.warn('MOPE 历史记录保存失败:', error);
|
||||
savedHistoryKeyRef.current = null;
|
||||
});
|
||||
}, [status, timeline, taskContext, buildHistorySnapshot]);
|
||||
|
||||
if (status === 'idle') {
|
||||
if (idlePage === 'scenario_config') {
|
||||
return (
|
||||
<div className={`mope-chat app-idle app-idle-scroll ${isDark ? 'mope-chat-dark' : 'mope-chat-light'}`}>
|
||||
<ScenarioConfigPage onBack={() => setIdlePage('home')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mope-chat app-idle ${isDark ? 'mope-chat-dark' : 'mope-chat-light'}`}>
|
||||
<ConfigPanel
|
||||
onStart={handleStart}
|
||||
isRunning={false}
|
||||
onStop={handleStop}
|
||||
onOpenScenarioConfig={() => setIdlePage('scenario_config')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mope-chat app-running ${isDark ? 'mope-chat-dark' : 'mope-chat-light'}`}>
|
||||
<header className="app-header">
|
||||
<span className="app-header-logo">MOPE</span>
|
||||
<span className="app-header-task">
|
||||
{taskContext ? `"${taskContext.slice(0, 80)}${taskContext.length > 80 ? '…' : ''}"` : '分析中…'}
|
||||
</span>
|
||||
<span className="badge badge-default">
|
||||
{coordinatorMode === 'human_review' ? '人工确认' : '全自动'}
|
||||
</span>
|
||||
{status === 'waiting_human' && (
|
||||
<span className="badge badge-diverging">等待人工确认</span>
|
||||
)}
|
||||
<div className="app-header-actions">
|
||||
{(status === 'running' || status === 'waiting_human') && (
|
||||
<button className="btn-danger" onClick={handleStop}>
|
||||
停止
|
||||
</button>
|
||||
)}
|
||||
{(status === 'done' || status === 'error') && (
|
||||
<button className="btn-secondary" onClick={handleReset}>
|
||||
重新开始
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="app-body">
|
||||
<aside className={`app-sidebar app-side-panel${isLeftSidebarCollapsed ? ' collapsed' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="app-side-toggle app-side-toggle-left"
|
||||
onClick={() => setIsLeftSidebarCollapsed(v => !v)}
|
||||
aria-label={isLeftSidebarCollapsed ? '展开左侧栏' : '收起左侧栏'}
|
||||
title={isLeftSidebarCollapsed ? '展开左侧栏' : '收起左侧栏'}
|
||||
>
|
||||
{isLeftSidebarCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||
</button>
|
||||
{!isLeftSidebarCollapsed && (
|
||||
<Sidebar
|
||||
agents={agents}
|
||||
agentStates={agentStates}
|
||||
phase={phase}
|
||||
currentRound={currentRound}
|
||||
divergeRounds={divergeRounds}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="app-main">
|
||||
{errors.length > 0 && errors.map((e: SessionError, i: number) => (
|
||||
<div key={i} className="error-banner">
|
||||
{e.agent_id ? `[${e.agent_id}] ` : ''}{e.error}
|
||||
</div>
|
||||
))}
|
||||
<Timeline
|
||||
timeline={timeline}
|
||||
agentStates={agentStates}
|
||||
reportSchema={reportSchema}
|
||||
pendingReview={pendingReview}
|
||||
onApproveReview={approveCoordinator}
|
||||
onReviseReview={reviseCoordinator}
|
||||
reviewSubmitting={reviewSubmitting}
|
||||
reviewRevising={reviewRevising}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<aside className={`app-progress-sidebar app-side-panel${isRightSidebarCollapsed ? ' collapsed' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="app-side-toggle app-side-toggle-right"
|
||||
onClick={() => setIsRightSidebarCollapsed(v => !v)}
|
||||
aria-label={isRightSidebarCollapsed ? '展开右侧栏' : '收起右侧栏'}
|
||||
title={isRightSidebarCollapsed ? '展开右侧栏' : '收起右侧栏'}
|
||||
>
|
||||
{isRightSidebarCollapsed ? <ChevronLeft size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
{!isRightSidebarCollapsed && (
|
||||
<ProgressSidebar
|
||||
phase={phase}
|
||||
status={status}
|
||||
currentRound={currentRound}
|
||||
divergeRounds={divergeRounds}
|
||||
reportSchema={reportSchema}
|
||||
sharedMemory={sharedMemory}
|
||||
finalReportPartial={finalReportPartial}
|
||||
currentFinalField={currentFinalField}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,9 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export default function QACenterPage() {
|
||||
return (
|
||||
<div>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,935 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Dialog, Textarea } from 'tdesign-react';
|
||||
import {
|
||||
Bookmark,
|
||||
BrainCircuit,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
FileSearch,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Send,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Square,
|
||||
Copy,
|
||||
Trash2,
|
||||
Edit3,
|
||||
Upload,
|
||||
UserRound,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import MarkdownRenderer from '../MarkdownRenderer';
|
||||
import { SourceItem } from './GeneralQA.types';
|
||||
|
||||
/**
|
||||
* 单条检索结果。
|
||||
*
|
||||
* 多轮检索过程中每一轮可能返回多条资料,这里只保留 UI 展示所需的标题、摘要和来源。
|
||||
*/
|
||||
export interface QARetrievalItem {
|
||||
id?: string;
|
||||
title: string;
|
||||
content: string;
|
||||
keywords?: string;
|
||||
taskDirection?: string;
|
||||
type?: string;
|
||||
time?: string;
|
||||
source?: string;
|
||||
recUuid?: string;
|
||||
platform?: string;
|
||||
fans?: number;
|
||||
comment_count?: number;
|
||||
message_id?: string;
|
||||
hot_num?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 多轮检索中的单轮思考节点。
|
||||
*
|
||||
* `queryLoading` 控制该轮检索结果是否仍显示 loading;retrievals 到达后再渲染资料列表。
|
||||
*/
|
||||
export interface QAThinkingRound {
|
||||
id: string; // 唯一标识符,用于列表渲染时的 key
|
||||
title: string; // 该轮检索的标题,如"第1轮检索"或"背景资料收集"
|
||||
thoughtMarkdown: string; // 模型思考过程的 Markdown 内容,展示思考步骤和推理逻辑
|
||||
queryTitle?: string; // 检索内容
|
||||
query: string; // 该轮发送给检索系统的查询语句
|
||||
queryLoading?: boolean; // 该轮检索是否仍在进行中,true 时显示加载动画
|
||||
textidContent?: string; // 新闻详细内容
|
||||
retrievals: QARetrievalItem[]; // 该轮返回的检索结果列表
|
||||
queryShow: boolean; // 检索列表为空时是否显示
|
||||
}
|
||||
|
||||
/**
|
||||
* 多轮/深度检索的完整思考链。
|
||||
*
|
||||
* rounds 展示检索过程,finalAnswerMarkdown 展示最终回答;两者分开便于流式过程中逐步更新。
|
||||
*/
|
||||
export interface QAThinkingTrace {
|
||||
title?: string; // 整体思考链的标题
|
||||
rounds: QAThinkingRound[]; // 多轮检索过程中的所有轮次,每轮包含思考和检索结果
|
||||
finalAnswerMarkdown: string; // 基于多轮检索结果生成的最终回答内容
|
||||
thinkingComplete?: boolean; // 思考过程是否已完成,true 时思考区域可折叠
|
||||
}
|
||||
|
||||
/**
|
||||
* 单轮 RAG 的回答结构。
|
||||
*
|
||||
* 后端可能把 `<think>...</think>` 和答案混在同一个 answer 流中,解析后分别放到 thinkingMarkdown 与 answerMarkdown。
|
||||
*/
|
||||
export interface QASingleRagTrace {
|
||||
title?: string; // 思考过程的标题,如"单轮 RAG 思考过程"
|
||||
thinkingMarkdown: string; // 模型在生成回答前的思考过程内容,使用 Markdown 格式
|
||||
answerMarkdown: string; // 最终生成的回答内容,使用 Markdown 格式
|
||||
thinkingComplete?: boolean; // 思考过程是否已完成,true 时思考区域可折叠
|
||||
}
|
||||
|
||||
export interface QACommonMessage {
|
||||
id: string; // 消息唯一标识符
|
||||
role: 'user' | 'assistant'; // 消息发送者角色:user 为用户消息,assistant 为 AI 回复
|
||||
content: string; // 消息的纯文本内容(当使用 thinkingTrace 或 singleRagTrace 时为空)
|
||||
timestamp?: Date; // 消息发送时间
|
||||
thinkingTrace?: QAThinkingTrace; // 多轮/深度检索模式的思考链,包含多轮检索过程和最终回答
|
||||
sources?: { // 消息引用的参考资料列表
|
||||
title: string; // 参考资料标题
|
||||
type: string; // 资料类型,如"文档"、"网页"等
|
||||
snippet: string; // 资料摘要内容
|
||||
source?: string; // 来源名称或 URL
|
||||
time?: string; // 发布时间
|
||||
url?: string; // 资料链接
|
||||
content?: string; // 完整内容(可能用于详情弹窗)
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface QACommonMessage {
|
||||
singleRagTrace?: QASingleRagTrace; // 单轮 RAG 模式的思考过程和回答结构
|
||||
}
|
||||
|
||||
interface QAAvatarProps {
|
||||
role: 'user' | 'assistant';
|
||||
isActive?: boolean;
|
||||
label?: string;
|
||||
variant?: 'default' | 'blue' | 'purple';
|
||||
}
|
||||
|
||||
const getAssistantAvatarClass = (variant: QAAvatarProps['variant'], isDark: boolean) => {
|
||||
switch (variant) {
|
||||
case 'blue':
|
||||
return isDark
|
||||
? 'bg-gradient-to-br from-blue-400 via-blue-600 to-cyan-700 text-white'
|
||||
: 'bg-gradient-to-br from-blue-300 via-blue-500 to-cyan-500 text-white';
|
||||
case 'purple':
|
||||
return isDark
|
||||
? 'bg-gradient-to-br from-purple-400 via-violet-600 to-fuchsia-700 text-white'
|
||||
: 'bg-gradient-to-br from-purple-300 via-violet-500 to-fuchsia-500 text-white';
|
||||
default:
|
||||
return isDark
|
||||
? 'bg-gradient-to-br from-cyan-500 via-blue-600 to-violet-600 text-white'
|
||||
: 'bg-gradient-to-br from-red-400 via-rose-500 to-orange-400 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一头像组件。
|
||||
*
|
||||
* 单一模式使用默认助手色;对比模式可通过 variant 指定蓝/紫色系,让头像与左右面板标签保持一致。
|
||||
* `isActive` 会触发呼吸和星光动画,用来提示当前助手正在生成。
|
||||
*/
|
||||
export const QAAvatar: React.FC<QAAvatarProps> = ({ role, isActive = false, label, variant = 'default' }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const Icon = role === 'assistant' ? Bot : UserRound;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-9 h-9 rounded-2xl flex items-center justify-center flex-shrink-0 overflow-hidden shadow-sm ${role === 'assistant'
|
||||
? getAssistantAvatarClass(variant, isDark)
|
||||
: isDark
|
||||
? 'bg-gradient-to-br from-slate-500 via-slate-700 to-slate-900 text-white'
|
||||
: 'bg-gradient-to-br from-sky-400 via-indigo-500 to-violet-500 text-white'
|
||||
} ${isActive ? 'qa-avatar-active' : ''}`}
|
||||
title={label}
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/20 opacity-0 qa-avatar-shine" />
|
||||
<Icon className={`w-5 h-5 relative z-10 ${isActive ? 'qa-avatar-icon-active' : ''}`} />
|
||||
{role === 'assistant' && isActive && (
|
||||
<Sparkles className="absolute -right-0.5 -top-0.5 w-3.5 h-3.5 text-yellow-200 qa-avatar-spark" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TypingIndicatorProps {
|
||||
label?: string;
|
||||
assistantLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 助手预连接/生成中的占位提示。
|
||||
*
|
||||
* 用于请求已经发出但还没有生成可展示消息时,给用户明确的等待反馈。
|
||||
*/
|
||||
export const TypingIndicator: React.FC<TypingIndicatorProps> = ({ label = '正在思考并生成回复', assistantLabel = 'AI 助手' }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<QAAvatar role="assistant" isActive label={assistantLabel} />
|
||||
<div className={`max-w-[70%] rounded-2xl px-4 py-3 shadow-sm border ${isDark ? 'bg-slate-700/60 border-slate-600/50 text-slate-200' : 'bg-white border-red-100 text-gray-700'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className={`w-4 h-4 animate-spin ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
|
||||
<span
|
||||
className={`text-sm font-medium qa-generating-marquee ${isDark ? 'qa-generating-marquee-dark' : 'qa-generating-marquee-light'}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/* <div className="flex gap-1">
|
||||
{[0, 1, 2].map((idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={`w-1.5 h-1.5 rounded-full qa-typing-dot ${isDark ? 'bg-blue-300' : 'bg-red-400'}`}
|
||||
style={{
|
||||
animationDelay: `${idx * 160}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChatMessageProps<T extends QACommonMessage> {
|
||||
message: T;
|
||||
showSources?: boolean;
|
||||
assistantLabel?: string;
|
||||
assistantActive?: boolean;
|
||||
onCitationClick?: (message: T, citationIndex: number) => void;
|
||||
onCopyAssistant?: (message: T) => void;
|
||||
onDeleteAssistant?: (message: T) => void;
|
||||
onEditAssistant?: (message: T) => void;
|
||||
onResendAssistant?: (message: T) => void;
|
||||
renderAssistantExtra?: (message: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息渲染器。
|
||||
*
|
||||
* 根据消息类型自动选择普通文本、单轮 RAG 回答或多轮检索思考链。
|
||||
* sources 区域独立折叠展示,避免引用资料过长时挤占主回答阅读空间。
|
||||
*/
|
||||
export function ChatMessage<T extends QACommonMessage>({
|
||||
message,
|
||||
showSources = true,
|
||||
assistantLabel = 'AI 助手',
|
||||
assistantActive = false,
|
||||
onCitationClick,
|
||||
onCopyAssistant,
|
||||
onDeleteAssistant,
|
||||
onEditAssistant,
|
||||
onResendAssistant,
|
||||
renderAssistantExtra,
|
||||
}: ChatMessageProps<T>) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const isUser = message.role === 'user';
|
||||
const hasTrace = !isUser && !!(message.singleRagTrace || message.thinkingTrace);
|
||||
const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL;
|
||||
const [visible, setVisible] = useState(false); // 打开详情对话框
|
||||
const [listData, setListData] = useState<SourceItem>({
|
||||
title: '',
|
||||
type: '',
|
||||
time: '',
|
||||
snippet: '',
|
||||
source: '',
|
||||
content: '',
|
||||
});
|
||||
const showDialog = (item: SourceItem) => {
|
||||
console.log('item', item)
|
||||
if (item.url) {
|
||||
window.open(`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${item.url}`);
|
||||
} else {
|
||||
setListData(item);
|
||||
setVisible(true);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
|
||||
{!isUser && <QAAvatar role="assistant" isActive={assistantActive} label={assistantLabel} />}
|
||||
<div className={`${hasTrace ? 'max-w-[85%] flex-1' : 'max-w-[72%]'} rounded-2xl px-4 py-3 shadow-sm ${isUser
|
||||
? isDark
|
||||
? 'bg-blue-600/70 text-white'
|
||||
: 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
|
||||
: message.singleRagTrace || message.thinkingTrace
|
||||
? isDark
|
||||
? 'bg-slate-800/40 text-slate-100 border border-slate-600/40'
|
||||
: 'bg-white text-gray-800 border border-transparent shadow-[0px_3px_12px_rgba(0, 0, 0, 0.12)] px-0 py-0'
|
||||
: isDark
|
||||
? 'bg-slate-700/60 text-slate-100 border border-slate-600/40'
|
||||
: 'bg-white text-gray-800 border border-gray-100'
|
||||
}`}>
|
||||
{message.singleRagTrace && !isUser ? (
|
||||
<SingleRagAnswer
|
||||
trace={message.singleRagTrace}
|
||||
onCitationClick={(citationIndex) => onCitationClick?.(message, citationIndex)}
|
||||
/>
|
||||
) : message.thinkingTrace && !isUser ? (
|
||||
<RetrievalThinkingTrace
|
||||
trace={message.thinkingTrace}
|
||||
onCitationClick={(citationIndex) => onCitationClick?.(message, citationIndex)}
|
||||
/>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap leading-relaxed">{message.content}</p>
|
||||
)}
|
||||
{!isUser && (onCopyAssistant || onDeleteAssistant || onEditAssistant || onResendAssistant) && (
|
||||
<div className={`mt-2 flex items-center justify-end gap-1.5 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{onCopyAssistant && (
|
||||
<button type="button" onClick={() => onCopyAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-slate-100' : 'hover:bg-gray-100 hover:text-gray-700'}`} title="复制回答">
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onDeleteAssistant && (
|
||||
<button type="button" onClick={() => onDeleteAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-rose-300' : 'hover:bg-red-50 hover:text-red-600'}`} title="删除问答">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onEditAssistant && (
|
||||
<button type="button" onClick={() => onEditAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-blue-300' : 'hover:bg-blue-50 hover:text-blue-600'}`} title="编辑回答和思考过程">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onResendAssistant && (
|
||||
<button type="button" onClick={() => onResendAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-emerald-300' : 'hover:bg-emerald-50 hover:text-emerald-600'}`} title="重新发送">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isUser && <QAAvatar role="user" label="用户" />}
|
||||
</div>
|
||||
|
||||
|
||||
{!isUser && message.sources && message.sources.length > 0 && showSources && (
|
||||
<CollapsibleSection
|
||||
defaultOpen={false}
|
||||
className={`ml-12 ${hasTrace ? 'max-w-[85%]' : 'max-w-[72%]'} ${isDark ? 'bg-slate-800/50 border-slate-700/50' : 'bg-gray-50 border-gray-200'}`}
|
||||
title={(
|
||||
<div className="flex items-center gap-2">
|
||||
<BrainCircuit className={`w-3.5 h-3.5 ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
|
||||
<span className={`text-xs font-medium ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>引用来源</span>
|
||||
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>({message.sources.length})</span>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2 pt-[10px]">
|
||||
{message.sources.map((source, idx) => (
|
||||
<div key={idx} className={`text-xs p-2 rounded-[4px] border shadow-sm transition-all duration-200 ease-out cursor-pointer hover:-translate-y-0.5 hover:scale-[1.01] ${isDark ? 'bg-slate-700/50 border-slate-600/50 shadow-black/10 hover:bg-slate-700/80 hover:border-blue-400/40 hover:shadow-lg hover:shadow-blue-950/20' : 'bg-white border-gray-100 shadow-gray-200/60 hover:border-red-200 hover:shadow-md hover:shadow-red-100/70'}`} onClick={() => showDialog(source)}>
|
||||
<div className="flex items-center gap-2 mb-1 min-w-0">
|
||||
<BrainCircuit className={`w-3 h-3 shrink-0 ${isDark ? 'text-blue-400' : 'text-red-400'}`} />
|
||||
<span className={`font-medium truncate min-w-0 flex-1 ${isDark ? 'text-slate-300' : 'text-gray-700'}`} title={source.title}>{source.title}</span>
|
||||
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs ${isDark ? 'bg-slate-600 text-slate-300' : 'bg-gray-200 text-gray-500'}`}>
|
||||
{source.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className={`line-clamp-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{source.snippet}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
<Dialog
|
||||
visible={visible}
|
||||
onClose={() => setVisible(false)}
|
||||
header="提示"
|
||||
footer={null}
|
||||
>
|
||||
<div>
|
||||
<div className="pr-9">
|
||||
<h3 className="text-base font-semibold">{listData.title || '引用内容'}</h3>
|
||||
<div className={`mt-2 text-xs space-y-1 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<div>来源:{listData.source || '未知来源'}</div>
|
||||
<div>时间:{listData.time || '未知'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{listData.url && (
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href={`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${listData.url}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm transition-all ${isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
title="打开原始链接"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
查看原文链接
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className={`mt-4 rounded-xl p-3 text-sm leading-relaxed whitespace-pre-wrap ${isDark ? 'bg-slate-800/70 text-slate-200' : 'bg-gray-50 text-gray-700'
|
||||
}`}>
|
||||
{listData.content || '暂无引用内容'}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
{!isUser && renderAssistantExtra?.(message)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用折叠面板。
|
||||
*
|
||||
* 思考过程、检索结果、引用来源都复用该组件,保证展开/收起动画和深浅色主题一致。
|
||||
*/
|
||||
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
className = '',
|
||||
headerClassName = '',
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
// 当 defaultOpen 改变时,更新 open 状态
|
||||
useEffect(() => {
|
||||
setOpen(defaultOpen);
|
||||
}, [defaultOpen]);
|
||||
return (
|
||||
<div className={`rounded-2xl border overflow-hidden ${isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'} ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={`w-full flex items-center justify-between gap-3 px-4 py-3 text-left transition-colors ${isDark ? 'hover:bg-slate-800/70' : 'hover:bg-red-50/60'
|
||||
} ${headerClassName}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">{title}</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 flex-shrink-0 transition-transform duration-300 ease-out ${open ? 'rotate-0' : '-rotate-90'
|
||||
} ${isDark ? 'text-slate-400' : 'text-gray-500'}`}
|
||||
/>
|
||||
</button>
|
||||
<div className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}>
|
||||
<div className="overflow-hidden">
|
||||
<div className={`px-4 pb-4 transition-transform duration-300 ease-out ${open ? 'translate-y-0' : '-translate-y-2'} ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SingleRagAnswerProps {
|
||||
trace: QASingleRagTrace;
|
||||
onCitationClick?: (citationIndex: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单轮 RAG 回答展示。
|
||||
*
|
||||
* 思考内容和最终回答分成两个区域:思考区可折叠,回答区始终展示。
|
||||
* 这样既保留模型推理过程,也不会影响用户优先阅读最终答案。
|
||||
*/
|
||||
export const SingleRagAnswer: React.FC<SingleRagAnswerProps> = ({ trace, onCitationClick }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
|
||||
<CollapsibleSection
|
||||
defaultOpen={!trace.thinkingComplete}
|
||||
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${isDark ? 'bg-violet-500/15 text-violet-300' : 'bg-orange-50 text-orange-500'
|
||||
}`}>
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>{trace.title || '思考过程'}</div>
|
||||
{trace.thinkingComplete ? (
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
思考已完成,可展开查看
|
||||
</div>
|
||||
) : (
|
||||
<div className={`text-xs qa-generating-marquee ${isDark ? 'qa-generating-marquee-dark text-slate-300' : 'qa-generating-marquee-light text-gray-600'}`}>
|
||||
正在接收 <think> 流式思考内容
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{trace.thinkingMarkdown ? (
|
||||
<MarkdownRenderer
|
||||
content={trace.thinkingMarkdown}
|
||||
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
|
||||
onCitationClick={onCitationClick}
|
||||
/>
|
||||
) : (
|
||||
<div className={`text-sm flex items-center gap-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
等待模型输出思考内容...
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
<div className={`rounded-2xl border p-4 ${isDark ? 'bg-slate-700/60 border-slate-600/40' : 'bg-white border-gray-100'}`}>
|
||||
{trace.answerMarkdown ? (
|
||||
<MarkdownRenderer
|
||||
content={trace.answerMarkdown}
|
||||
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
|
||||
onCitationClick={onCitationClick}
|
||||
/>
|
||||
) : (
|
||||
<div className={`text-sm flex items-center gap-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{trace.thinkingComplete ? '正在流式生成回答...' : '思考结束后开始生成回答...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RetrievalListProps {
|
||||
items: QARetrievalItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索结果列表。
|
||||
*
|
||||
* 每条资料默认折叠,首条默认展开,便于用户快速看到最相关资料,同时保留完整资料明细。
|
||||
*/
|
||||
const RetrievalList: React.FC<RetrievalListProps> = ({ items }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [visible, setVisible] = useState(false); // 打开详情对话框
|
||||
const [listData, setListData] = useState<QARetrievalItem>({
|
||||
title: '',
|
||||
content: '',
|
||||
});
|
||||
const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL;
|
||||
const b1SearchUrl = import.meta.env.VITE_AIPI_B1COH_URL;
|
||||
const b2SearchUrl = import.meta.env.VITE_AIPI_B2ACTOR_URL;
|
||||
const showDialog = (item: QARetrievalItem) => {
|
||||
console.log('item', item)
|
||||
if (item.type === 'more') {
|
||||
if (item.recUuid) {
|
||||
window.open(`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${item.recUuid}`);
|
||||
} else {
|
||||
setListData(item);
|
||||
setVisible(true);
|
||||
}
|
||||
} else if (item.type === 'task') {
|
||||
window.open(`${b1SearchUrl}/#/task/item-aicoh/step-info-search?id=${item.id}`)
|
||||
} else if (item.type === 'hx') {
|
||||
window.open(`${b2SearchUrl}/fromB/toDetail?user_id=${item.id}&platform=${item.platform}`);
|
||||
} else {
|
||||
setListData(item);
|
||||
setVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div key={index}>
|
||||
{
|
||||
item.type !== 'user_behavior' ? <CollapsibleSection
|
||||
key={index}
|
||||
defaultOpen={index === 0}
|
||||
className={isDark ? 'bg-slate-800/40' : 'bg-gray-50/70'}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-6 h-6 rounded-lg flex items-center justify-center text-xs font-semibold ${isDark ? 'bg-blue-500/20 text-blue-300' : 'bg-red-100 text-red-600'
|
||||
}`}>{index + 1}</span>
|
||||
<div className="min-w-0">
|
||||
<div className={`text-sm font-medium truncate ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{item.title}</div>
|
||||
{
|
||||
item.type === 'more' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>时间:{item.time || '-'}</div>
|
||||
}
|
||||
{
|
||||
item.type === 'task' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>关键词:{item.keywords || '-'}</div>
|
||||
}
|
||||
{
|
||||
item.type === 'hx' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}><span>平台:{item.platform || '-'}</span><span className="ml-[10px]">粉丝数:{item.fans || '-'}</span></div>
|
||||
}
|
||||
{
|
||||
(item.type === 'taiwan_news' || item.type === 'world_news' || item.type === 'messaage_comment' || item.type === 'america_news') && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}><span>平台:{item.platform || '-'}</span>{item.comment_count && <span className="ml-[10px]">评论数:{item.comment_count || '-'}</span>}</div>
|
||||
}
|
||||
{
|
||||
item.type === 'get_time_data' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}><span>时间:{item.time || '-'}</span><span className="ml-[10px]">热度:{item.hot_num || '-'}</span></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className={`text-sm leading-relaxed cursor-progress line-clamp-3 hover:underline ${isDark ? 'text-slate-300' : 'text-gray-600'}`} onClick={() => showDialog(item)}>{item.content}</p>
|
||||
</CollapsibleSection>
|
||||
: <div className="rounded-[5px] border border-[#f5f5f5] p-[10px]" key={index + 10}>
|
||||
<span className="font-bold mr-[10px]">{index + 1}.</span>
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
<Dialog
|
||||
visible={visible}
|
||||
onClose={() => setVisible(false)}
|
||||
header="提示"
|
||||
footer={null}
|
||||
>
|
||||
<div>
|
||||
<div className="pr-9">
|
||||
<h3 className="text-base font-semibold">{listData.title}</h3>
|
||||
<div className={`mt-2 text-xs space-y-1 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{listData.time && <span>时间:{listData.time || '未知'}</span>}
|
||||
{listData.platform && <span className="ml-[10px]">平台:{listData.platform || '未知'}</span>}
|
||||
{listData.comment_count && <span className="ml-[10px]">评论数:{listData.comment_count || '未知'}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-4 rounded-xl p-3 text-sm leading-relaxed whitespace-pre-wrap ${isDark ? 'bg-slate-800/70 text-slate-200' : 'bg-gray-50 text-gray-700'
|
||||
}`}>
|
||||
{listData.content || '暂无引用内容'}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SearchQueryBadgeProps {
|
||||
query: string;
|
||||
loading?: boolean;
|
||||
queryTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索词徽标。
|
||||
*
|
||||
* 多轮检索每一轮都会产生一个 query;loading 状态下展示旋转图标,表示该 query 正在召回资料。
|
||||
*/
|
||||
const SearchQueryBadge: React.FC<SearchQueryBadgeProps> = ({ query, loading = false, queryTitle = '检索词' }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className={`inline-flex max-w-full items-center gap-2 rounded-full px-3 py-1.5 border ${isDark ? 'bg-blue-500/10 border-blue-400/20 text-blue-200' : 'bg-red-50 border-red-100 text-red-600'
|
||||
}`}>
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
|
||||
<span className="text-xs flex-shrink-0">{queryTitle}</span>
|
||||
<span className={`text-xs font-medium truncate ${loading && 'qa-generating-marquee'} ${loading && (isDark ? 'qa-generating-marquee-dark' : 'qa-generating-marquee-light')}`}>
|
||||
{query}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RetrievalThinkingTraceProps {
|
||||
trace: QAThinkingTrace;
|
||||
onCitationClick?: (citationIndex: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 多轮/深度检索思考链展示。
|
||||
*
|
||||
* 组件按轮次渲染思考内容、检索词和检索结果,底部展示最终回答。
|
||||
* 它只消费已经整理好的 trace 数据,不关心数据来自真实 SSE 还是本地模拟。
|
||||
*/
|
||||
export const RetrievalThinkingTrace: React.FC<RetrievalThinkingTraceProps> = ({ trace, onCitationClick }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
|
||||
<CollapsibleSection
|
||||
defaultOpen={!trace.thinkingComplete}
|
||||
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
|
||||
}`}>
|
||||
<BrainCircuit className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{trace.title || '思考过程'}
|
||||
</div>
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
共 {trace.rounds.length} 轮推理与检索,可展开查看细节
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 pt-1">
|
||||
{trace.rounds.map((round, index) => (
|
||||
<div key={index} className={`relative pl-5 ${index < trace.rounds.length - 1 ? 'pb-2' : ''}`}>
|
||||
{index < trace.rounds.length - 1 && (
|
||||
<div className={`absolute left-[7px] top-8 bottom-0 w-px ${isDark ? 'bg-slate-700' : 'bg-red-100'}`} />
|
||||
)}
|
||||
<div className={`absolute left-0 top-4 w-3.5 h-3.5 rounded-full border-2 ${isDark ? 'bg-slate-900 border-cyan-400' : 'bg-white border-red-400'
|
||||
}`} />
|
||||
<CollapsibleSection
|
||||
defaultOpen
|
||||
className={isDark ? 'bg-slate-900/40' : 'bg-red-50/30'}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-100 text-red-600'
|
||||
}`}>第 {index + 1} 轮</span>
|
||||
<span className={`text-sm font-medium ${round.queryLoading && 'qa-generating-marquee'} ${round.queryLoading && (isDark ? 'text-slate-100 qa-generating-marquee-dark' : 'text-gray-900 qa-generating-marquee-light')}`}>{round.title}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<CollapsibleSection
|
||||
defaultOpen
|
||||
className={isDark ? 'bg-slate-800/50' : 'bg-white/80'}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className={`w-4 h-4 ${isDark ? 'text-violet-300' : 'text-orange-500'}`} />
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-800'}`}>思考内容</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MarkdownRenderer
|
||||
content={round.thoughtMarkdown}
|
||||
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
|
||||
onCitationClick={onCitationClick}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
{
|
||||
round.queryShow &&
|
||||
<div className="flex items-center">
|
||||
<SearchQueryBadge query={round.query} queryTitle={round.queryTitle} loading={round.queryLoading} />
|
||||
</div>
|
||||
}
|
||||
{
|
||||
round.queryShow &&
|
||||
<CollapsibleSection
|
||||
defaultOpen={!round.queryLoading}
|
||||
className={isDark ? 'bg-slate-800/50' : 'bg-white/80'}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FileSearch className={`w-4 h-4 ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-800'}`}>检索内容</span>
|
||||
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{round.retrievals.length} 条</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{round.queryLoading ? (
|
||||
<div className={`rounded-xl px-3 py-4 text-sm flex items-center gap-2 ${isDark ? 'bg-slate-900/60 text-slate-400' : 'bg-gray-50 text-gray-500'}`}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
正在召回相关内容...
|
||||
</div>
|
||||
) : (
|
||||
<RetrievalList items={round.retrievals} />
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
}
|
||||
{
|
||||
round.textidContent &&
|
||||
<div className="mt-[10px]">
|
||||
{round.textidContent}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<div className={`rounded-2xl border p-4 ${isDark ? 'bg-slate-700/60 border-slate-600/40' : 'bg-white border-gray-100'}`}>
|
||||
<div className={`text-sm font-semibold mb-3 ${isDark ? 'text-white' : 'text-gray-900'}`}>最终回答</div>
|
||||
{
|
||||
!trace.finalAnswerMarkdown && <div className={`rounded-xl px-3 py-4 text-sm flex items-center gap-2 ${isDark ? 'bg-slate-900/60 text-slate-400' : 'bg-gray-50 text-gray-500'}`}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
正在生成思考过程与检索内容...
|
||||
</div>
|
||||
}
|
||||
<MarkdownRenderer
|
||||
content={trace.finalAnswerMarkdown}
|
||||
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
|
||||
onCitationClick={onCitationClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface EmptyChatStateProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 空状态展示。
|
||||
*
|
||||
* 当当前模式没有任何消息时使用,提示用户可以开始输入问题。
|
||||
*/
|
||||
export const EmptyChatState: React.FC<EmptyChatStateProps> = ({ icon, title, description }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className={`h-full flex flex-col items-center justify-center ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
<div className={`w-20 h-20 rounded-3xl mb-5 flex items-center justify-center ${isDark ? 'bg-slate-700/40' : 'bg-gradient-to-br from-red-50 to-orange-50'
|
||||
}`}>
|
||||
{icon ?? <Bot className="w-11 h-11 opacity-60" />}
|
||||
</div>
|
||||
<p className="text-lg font-medium">{title}</p>
|
||||
<p className="text-sm mt-2">{description}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface QAComposerProps {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
isLoading: boolean;
|
||||
canSubmit?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onStop: () => void;
|
||||
onKeyDown?: (e) => void;
|
||||
onUpload?: () => void;
|
||||
onReset?: () => void;
|
||||
onSave?: () => void;
|
||||
onCollect?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
showUpload?: boolean;
|
||||
showReset?: boolean;
|
||||
showSave?: boolean;
|
||||
showCollect?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 问答输入区。
|
||||
*
|
||||
* 负责输入、发送/停止、附件、重置、保存、收藏和模型参数设置入口。
|
||||
* 具体业务动作通过 props 回调交给父组件处理,保证输入区本身不依赖某个问答模式。
|
||||
*/
|
||||
export const QAComposer: React.FC<QAComposerProps> = ({
|
||||
value,
|
||||
placeholder,
|
||||
isLoading,
|
||||
canSubmit,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onKeyDown,
|
||||
onUpload,
|
||||
onReset,
|
||||
onSave,
|
||||
onCollect,
|
||||
onOpenSettings,
|
||||
showUpload = true,
|
||||
showReset = false,
|
||||
showSave = true,
|
||||
showCollect = true,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const submitEnabled = canSubmit ?? Boolean(value.trim());
|
||||
const iconButtonClass = `p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-600 text-slate-400' : 'hover:bg-gray-200 text-gray-500'}`;
|
||||
const handleKeyDown = (val, context) => {
|
||||
if (context?.e?.key === 'Enter') {
|
||||
// 阻止默认的换行行为(如果是 Textarea)
|
||||
context.e.preventDefault();
|
||||
|
||||
// 模拟发送请求
|
||||
onKeyDown(val)
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={`border-t p-4 ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
|
||||
<div className={`flex items-end gap-3 rounded-2xl p-3 shadow-inner ${isDark ? 'bg-slate-700/50' : 'bg-gray-100'}`}>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(nextValue) => onChange(String(nextValue))}
|
||||
onKeydown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
autosize={{ minRows: 1, maxRows: 15 }}
|
||||
disabled={isLoading}
|
||||
className={`flex-1 !bg-transparent !border-0 !shadow-none qa-composer-textarea [&]:!border-0 [&]:!shadow-none [&]:!outline-none [&]:focus:!border-0 [&]:focus:!shadow-none [&]:focus:!outline-none [&_.t-textarea]:!border-0 [&_.t-textarea]:!shadow-none [&_.t-textarea]:!outline-none [&_.t-textarea:hover]:!border-0 [&_.t-textarea:hover]:!shadow-none [&_.t-textarea--focused]:!border-0 [&_.t-textarea--focused]:!shadow-none [&_.t-is-focused]:!border-0 [&_.t-is-focused]:!shadow-none [&_.t-textarea__inner]:!bg-transparent [&_.t-textarea__inner]:!border-0 [&_.t-textarea__inner]:!shadow-none [&_.t-textarea__inner]:!outline-none [&_.t-textarea__inner]:!resize-none [&_.t-textarea__inner]:!min-h-[40px] [&_.t-textarea__inner]:!p-0 [&_.t-textarea__inner:hover]:!border-0 [&_.t-textarea__inner:hover]:!shadow-none [&_.t-textarea__inner:focus]:!border-0 [&_.t-textarea__inner:focus]:!shadow-none [&_.t-textarea__inner:focus]:!outline-none [&_.t-textarea__inner]:disabled:!cursor-not-allowed ${isDark
|
||||
? '[&_.t-textarea__inner]:!text-white [&_.t-textarea__inner]:placeholder:!text-slate-500'
|
||||
: '[&_.t-textarea__inner]:!text-gray-800 [&_.t-textarea__inner]:placeholder:!text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* {showUpload && (
|
||||
<button onClick={onUpload} className={iconButtonClass} title="上传附件" type="button">
|
||||
<Upload className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{showReset && (
|
||||
<button onClick={onReset} className={iconButtonClass} title="重新提问" type="button">
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{showSave && (
|
||||
<button onClick={onSave} className={iconButtonClass} title="保存记录" type="button">
|
||||
<Bookmark className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{showCollect && (
|
||||
<button onClick={onCollect} className={iconButtonClass} title="加入收藏" type="button">
|
||||
<Bookmark className="w-5 h-5" />
|
||||
</button>
|
||||
)} */}
|
||||
{onOpenSettings && (
|
||||
<button onClick={onOpenSettings} className={iconButtonClass} title="模型参数设置" type="button">
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={isLoading ? onStop : onSubmit}
|
||||
disabled={!isLoading && !submitEnabled}
|
||||
className={`px-4 py-2 rounded-xl font-medium transition-all flex items-center gap-2 shadow-sm ${isLoading
|
||||
? isDark
|
||||
? 'bg-rose-600 text-white qa-stop-breathing'
|
||||
: 'bg-red-600 text-white qa-stop-breathing'
|
||||
: submitEnabled
|
||||
? isDark
|
||||
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
: 'bg-red-500 hover:bg-red-600 text-white'
|
||||
: isDark
|
||||
? 'bg-slate-600 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? <Square className="w-4 h-4 fill-current" /> : <Send className="w-4 h-4" />}
|
||||
{isLoading ? '停止' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Outlet, Navigate } from 'react-router-dom';
|
||||
|
||||
export default function QALayout() {
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { CircleHelp, Bot, Layers } from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
type QAProvider = 'general' | 'agent' | 'multi-agent';
|
||||
type ViewMode = 'single' | 'compare';
|
||||
|
||||
interface QATypeSwitchProps {
|
||||
qaProvider: QAProvider; // 当前问答模式:'general' 通用问答 / 'agent' 智能体问答 / 'multi-agent' 协同分析
|
||||
setQaProvider: (provider: QAProvider) => void; // 设置问答模式的回调函数
|
||||
selectedAgentName?: string; // 当选择智能体问答时,当前选中的智能体名称
|
||||
viewMode?: ViewMode; // 当前视图模式(仅通用问答支持):'single' 单一模式 或 'compare' 对比模式
|
||||
setViewMode?: (mode: ViewMode) => void; // 设置视图模式的回调函数(仅通用问答可用)
|
||||
}
|
||||
|
||||
export default function QATypeSwitch({
|
||||
qaProvider,
|
||||
setQaProvider,
|
||||
selectedAgentName,
|
||||
viewMode = 'single',
|
||||
setViewMode,
|
||||
}: QATypeSwitchProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 问答模式切换 */}
|
||||
<div className={`flex rounded-xl p-1 ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
|
||||
<button
|
||||
onClick={() => setQaProvider('general')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
|
||||
qaProvider === 'general'
|
||||
? isDark
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-red-500 text-white'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-white'
|
||||
: 'text-gray-600 hover:text-red-600'
|
||||
}`}
|
||||
>
|
||||
<CircleHelp className="w-4 h-4" />
|
||||
通用问答
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setQaProvider('agent')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
|
||||
qaProvider === 'agent'
|
||||
? isDark
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-red-500 text-white'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-white'
|
||||
: 'text-gray-600 hover:text-red-600'
|
||||
}`}
|
||||
>
|
||||
<Bot className="w-4 h-4" />
|
||||
智能体问答
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setQaProvider('multi-agent')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
|
||||
qaProvider === 'multi-agent'
|
||||
? isDark
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-green-500 text-white'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-white'
|
||||
: 'text-gray-600 hover:text-green-600'
|
||||
}`}
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
协同分析
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 当前智能体指示 */}
|
||||
{qaProvider === 'agent' && selectedAgentName && (
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${isDark ? 'bg-blue-600/20' : 'bg-blue-50'}`}>
|
||||
<Bot className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>
|
||||
{selectedAgentName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 对比模式切换 - 仅通用问答 */}
|
||||
{qaProvider === 'general' && setViewMode && (
|
||||
<div className={`flex rounded-xl p-1 ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
|
||||
<button
|
||||
onClick={() => setViewMode('single')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
viewMode === 'single'
|
||||
? isDark
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-red-500 text-white'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-white'
|
||||
: 'text-gray-600 hover:text-red-600'
|
||||
}`}
|
||||
>
|
||||
单一模式
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('compare')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
viewMode === 'compare'
|
||||
? isDark
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-red-500 text-white'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-white'
|
||||
: 'text-gray-600 hover:text-red-600'
|
||||
}`}
|
||||
>
|
||||
对比模式
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,638 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import {
|
||||
Edit2, Plus, Trash2, Check, X, ChevronDown, ChevronRight,
|
||||
Target, Users, Activity, BookOpen, Zap, AlertTriangle,
|
||||
Settings, FileText, Info, RefreshCw, AlertCircle, Brain, Save,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface FieldRequirement {
|
||||
title: string;
|
||||
goal: string;
|
||||
must_cover: string[];
|
||||
done_criteria: string[];
|
||||
blockers: string[];
|
||||
dependencies: string[];
|
||||
evidence_needs: string[];
|
||||
preferred_agents: string[];
|
||||
style_notes: string;
|
||||
min_chars?: number;
|
||||
min_items?: number;
|
||||
}
|
||||
|
||||
interface AgentData {
|
||||
id: string;
|
||||
name: string;
|
||||
dimension: string;
|
||||
expertise: string;
|
||||
relevant_fields: string[];
|
||||
analysis_contract: string[];
|
||||
}
|
||||
|
||||
interface CoordinatorConfig {
|
||||
system_prompt_prefix?: string;
|
||||
round1_guidance?: string;
|
||||
middle_round_guidance?: string;
|
||||
final_round_guidance?: string;
|
||||
speaker_selection_rules?: string;
|
||||
focus_instruction_rules?: string;
|
||||
dependency_driving_rules?: string[];
|
||||
consistency_check_rules?: string[];
|
||||
revise_prompt_prefix?: string;
|
||||
}
|
||||
|
||||
interface TemplateData {
|
||||
template_id: string;
|
||||
template_name: string;
|
||||
template_brief: string;
|
||||
usage_notes: string[];
|
||||
report_schema: Record<string, string>;
|
||||
report_examples: Record<string, string>;
|
||||
field_requirements: Record<string, FieldRequirement>;
|
||||
agents: AgentData[];
|
||||
coordinator_config: CoordinatorConfig;
|
||||
}
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
template_id: '模板ID',
|
||||
template_name: '模板名称',
|
||||
template_brief: '模板简介',
|
||||
usage_notes: '使用说明',
|
||||
report_schema: '报告字段结构',
|
||||
report_examples: '报告字段示例',
|
||||
field_requirements: '字段要求配置',
|
||||
agents: '智能体配置',
|
||||
coordinator_config: '协调者配置',
|
||||
title: '字段标题',
|
||||
goal: '字段目标',
|
||||
must_cover: '必须覆盖内容',
|
||||
done_criteria: '完成标准',
|
||||
blockers: '阻塞条件',
|
||||
dependencies: '依赖字段',
|
||||
evidence_needs: '证据需求',
|
||||
preferred_agents: '推荐智能体',
|
||||
style_notes: '风格说明',
|
||||
min_chars: '最小字符数',
|
||||
min_items: '最小条目数',
|
||||
id: '智能体ID',
|
||||
name: '智能体名称',
|
||||
dimension: '分析维度',
|
||||
expertise: '专业能力',
|
||||
relevant_fields: '关联字段',
|
||||
analysis_contract: '分析契约',
|
||||
system_prompt_prefix: '系统提示词前缀',
|
||||
round1_guidance: '第一轮指导',
|
||||
middle_round_guidance: '中间轮指导',
|
||||
final_round_guidance: '最终轮指导',
|
||||
speaker_selection_rules: '发言者选择规则',
|
||||
focus_instruction_rules: '焦点指令规则',
|
||||
dependency_driving_rules: '依赖驱动规则',
|
||||
consistency_check_rules: '一致性检查规则',
|
||||
revise_prompt_prefix: '修改提示前缀',
|
||||
};
|
||||
|
||||
const REPORT_SCHEMA_LABELS: Record<string, string> = {
|
||||
objective_analysis: '一、目的分析',
|
||||
target_actor_analysis: '二、目标行为体分析',
|
||||
expected_behavior_drivers: '三、预期行为及行为驱动因素分析',
|
||||
narrative_theme_design: '四、叙事主题设计',
|
||||
action_plan_list: '五、认知行动方案列表',
|
||||
};
|
||||
|
||||
interface AgentVisual {
|
||||
Icon: React.ElementType;
|
||||
lightCard: string;
|
||||
darkCard: string;
|
||||
iconLight: string;
|
||||
iconDark: string;
|
||||
tagLight: string;
|
||||
tagDark: string;
|
||||
}
|
||||
|
||||
const AGENT_VISUALS: Record<string, AgentVisual> = {
|
||||
objective: {
|
||||
Icon: Target,
|
||||
lightCard: 'from-blue-50 to-sky-50 border-blue-200 hover:border-blue-400 hover:shadow-blue-200/60',
|
||||
darkCard: 'from-slate-900/80 to-blue-950/20 border-slate-700/70 hover:border-blue-500/40 hover:shadow-slate-950/50',
|
||||
iconLight: 'bg-blue-100 text-blue-600',
|
||||
iconDark: 'bg-slate-800/90 text-blue-300 ring-1 ring-blue-500/20',
|
||||
tagLight: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
tagDark: 'bg-slate-800/70 text-blue-200 border-blue-500/25',
|
||||
},
|
||||
actor: {
|
||||
Icon: Users,
|
||||
lightCard: 'from-emerald-50 to-teal-50 border-emerald-200 hover:border-emerald-400 hover:shadow-emerald-200/60',
|
||||
darkCard: 'from-slate-900/80 to-emerald-950/20 border-slate-700/70 hover:border-emerald-500/40 hover:shadow-slate-950/50',
|
||||
iconLight: 'bg-emerald-100 text-emerald-600',
|
||||
iconDark: 'bg-slate-800/90 text-emerald-300 ring-1 ring-emerald-500/20',
|
||||
tagLight: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
tagDark: 'bg-slate-800/70 text-emerald-200 border-emerald-500/25',
|
||||
},
|
||||
behavior: {
|
||||
Icon: Activity,
|
||||
lightCard: 'from-amber-50 to-orange-50 border-amber-200 hover:border-amber-400 hover:shadow-amber-200/60',
|
||||
darkCard: 'from-slate-900/80 to-amber-950/20 border-slate-700/70 hover:border-amber-500/40 hover:shadow-slate-950/50',
|
||||
iconLight: 'bg-amber-100 text-amber-600',
|
||||
iconDark: 'bg-slate-800/90 text-amber-300 ring-1 ring-amber-500/20',
|
||||
tagLight: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
tagDark: 'bg-slate-800/70 text-amber-200 border-amber-500/25',
|
||||
},
|
||||
narrative: {
|
||||
Icon: BookOpen,
|
||||
lightCard: 'from-purple-50 to-violet-50 border-purple-200 hover:border-purple-400 hover:shadow-purple-200/60',
|
||||
darkCard: 'from-slate-900/80 to-purple-950/20 border-slate-700/70 hover:border-purple-500/40 hover:shadow-slate-950/50',
|
||||
iconLight: 'bg-purple-100 text-purple-600',
|
||||
iconDark: 'bg-slate-800/90 text-purple-300 ring-1 ring-purple-500/20',
|
||||
tagLight: 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
tagDark: 'bg-slate-800/70 text-purple-200 border-purple-500/25',
|
||||
},
|
||||
action: {
|
||||
Icon: Zap,
|
||||
lightCard: 'from-indigo-50 to-slate-50 border-indigo-200 hover:border-indigo-400 hover:shadow-indigo-200/60',
|
||||
darkCard: 'from-slate-900/80 to-indigo-950/20 border-slate-700/70 hover:border-indigo-500/40 hover:shadow-slate-950/50',
|
||||
iconLight: 'bg-indigo-100 text-indigo-600',
|
||||
iconDark: 'bg-slate-800/90 text-indigo-300 ring-1 ring-indigo-500/20',
|
||||
tagLight: 'bg-indigo-100 text-indigo-700 border-indigo-200',
|
||||
tagDark: 'bg-slate-800/70 text-indigo-200 border-indigo-500/25',
|
||||
},
|
||||
risk: {
|
||||
Icon: AlertTriangle,
|
||||
lightCard: 'from-rose-50 to-pink-50 border-rose-200 hover:border-rose-400 hover:shadow-rose-200/60',
|
||||
darkCard: 'from-slate-900/80 to-rose-950/20 border-slate-700/70 hover:border-rose-500/40 hover:shadow-slate-950/50',
|
||||
iconLight: 'bg-rose-100 text-rose-600',
|
||||
iconDark: 'bg-slate-800/90 text-rose-300 ring-1 ring-rose-500/20',
|
||||
tagLight: 'bg-rose-100 text-rose-700 border-rose-200',
|
||||
tagDark: 'bg-slate-800/70 text-rose-200 border-rose-500/25',
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_VISUAL: AgentVisual = {
|
||||
Icon: Brain,
|
||||
lightCard: 'from-gray-50 to-slate-50 border-gray-200 hover:border-gray-400 hover:shadow-gray-200/60',
|
||||
darkCard: 'from-slate-900/80 to-slate-800/50 border-slate-700/70 hover:border-slate-500/50 hover:shadow-slate-950/50',
|
||||
iconLight: 'bg-gray-100 text-gray-600',
|
||||
iconDark: 'bg-slate-800/90 text-slate-300 ring-1 ring-slate-500/20',
|
||||
tagLight: 'bg-gray-100 text-gray-700 border-gray-200',
|
||||
tagDark: 'bg-slate-800/70 text-slate-200 border-slate-500/25',
|
||||
};
|
||||
|
||||
const mapFieldLabel = (v: string) => REPORT_SCHEMA_LABELS[v] || v;
|
||||
|
||||
function EditableText({
|
||||
value,
|
||||
onChange,
|
||||
isDark,
|
||||
multiline = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
isDark: boolean;
|
||||
multiline?: boolean;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
const confirm = () => { onChange(draft); setEditing(false); };
|
||||
const cancel = () => { setDraft(value); setEditing(false); };
|
||||
|
||||
const inputCls = isDark
|
||||
? 'bg-slate-800 border-blue-500/60 text-slate-100 focus:border-blue-400'
|
||||
: 'bg-white border-blue-300 text-gray-800 focus:border-blue-500';
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{multiline ? (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
rows={5}
|
||||
className={`w-full px-3 py-2 text-sm rounded-lg border outline-none resize-y font-mono leading-relaxed transition-colors ${inputCls}`}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') confirm(); if (e.key === 'Escape') cancel(); }}
|
||||
className={`w-full px-3 py-1.5 text-sm rounded-lg border outline-none transition-colors ${inputCls}`}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={confirm} className="flex items-center gap-1 px-3 py-1 text-xs rounded-lg bg-green-500 hover:bg-green-400 text-white transition-colors">
|
||||
<Check className="w-3 h-3" /> 确认
|
||||
</button>
|
||||
<button onClick={cancel} className={`flex items-center gap-1 px-3 py-1 text-xs rounded-lg border transition-colors ${isDark ? 'border-slate-600 text-slate-400 hover:text-slate-200' : 'border-gray-300 text-gray-500 hover:text-gray-700'}`}>
|
||||
<X className="w-3 h-3" /> 取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/editable flex items-start gap-2 rounded-lg px-2 py-1.5 -mx-2 -my-1.5 transition-all duration-200 hover:bg-black/5 dark:hover:bg-white/5 cursor-pointer" onClick={() => { setDraft(value); setEditing(true); }}>
|
||||
<span className={`flex-1 text-sm leading-relaxed break-words ${isDark ? 'text-slate-300' : 'text-gray-700'} ${multiline ? 'whitespace-pre-wrap font-mono text-xs' : ''}`}>
|
||||
{value || <span className={isDark ? 'text-slate-600 italic' : 'text-gray-300 italic'}>(空)</span>}
|
||||
</span>
|
||||
<Edit2 className={`flex-shrink-0 w-3.5 h-3.5 mt-0.5 opacity-0 group-hover/editable:opacity-100 transition-all duration-200 ${isDark ? 'text-blue-400' : 'text-blue-500'}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagList({
|
||||
tags,
|
||||
onChange,
|
||||
isDark,
|
||||
tagLight = 'bg-sky-50 text-sky-700 border-sky-200',
|
||||
tagDark = 'bg-slate-800/70 text-sky-200 border-sky-500/25',
|
||||
}: {
|
||||
tags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
isDark: boolean;
|
||||
tagLight?: string;
|
||||
tagDark?: string;
|
||||
}) {
|
||||
const [editIdx, setEditIdx] = useState<number | null>(null);
|
||||
const [editVal, setEditVal] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [addVal, setAddVal] = useState('');
|
||||
|
||||
const tagCls = isDark ? tagDark : tagLight;
|
||||
const inputCls = isDark
|
||||
? 'bg-slate-800 border-blue-500/60 text-slate-100 placeholder-slate-500 focus:border-blue-400'
|
||||
: 'bg-white border-blue-400 text-gray-800 placeholder-gray-400 focus:border-blue-500';
|
||||
|
||||
const confirmEdit = () => {
|
||||
if (editIdx === null) return;
|
||||
const next = [...tags];
|
||||
if (editVal.trim()) next[editIdx] = editVal.trim();
|
||||
else next.splice(editIdx, 1);
|
||||
onChange(next);
|
||||
setEditIdx(null);
|
||||
};
|
||||
|
||||
const confirmAdd = () => {
|
||||
if (addVal.trim()) onChange([...tags, addVal.trim()]);
|
||||
setAdding(false);
|
||||
setAddVal('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 items-center">
|
||||
{tags.map((tag, i) =>
|
||||
editIdx === i ? (
|
||||
<span key={i} className="inline-flex items-center gap-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={editVal}
|
||||
onChange={e => setEditVal(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') confirmEdit(); if (e.key === 'Escape') setEditIdx(null); }}
|
||||
className={`px-2 py-0.5 text-xs rounded border outline-none w-36 transition-colors ${inputCls}`}
|
||||
/>
|
||||
<button onClick={confirmEdit} className="text-green-500 hover:text-green-400 transition-colors"><Check className="w-3 h-3" /></button>
|
||||
<button onClick={() => setEditIdx(null)} className="text-red-400 hover:text-red-300 transition-colors"><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
) : (
|
||||
<span key={i} className="group/tag inline-flex items-center gap-1">
|
||||
<span
|
||||
onClick={() => { setEditIdx(i); setEditVal(tag); setAdding(false); }}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-xs rounded-full border cursor-pointer transition-all duration-200 hover:scale-105 hover:opacity-90 ${tagCls}`}
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onChange(tags.filter((_, j) => j !== i)); }}
|
||||
className="opacity-0 group-hover/tag:opacity-100 transition-opacity hover:text-red-500 ml-0.5"
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{adding ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={addVal}
|
||||
onChange={e => setAddVal(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') { setAdding(false); setAddVal(''); } }}
|
||||
placeholder="输入内容…"
|
||||
className={`px-2 py-0.5 text-xs rounded border outline-none w-36 transition-colors ${inputCls}`}
|
||||
/>
|
||||
<button onClick={confirmAdd} className="text-green-500 hover:text-green-400 transition-colors"><Check className="w-3 h-3" /></button>
|
||||
<button onClick={() => { setAdding(false); setAddVal(''); }} className="text-red-400 hover:text-red-300 transition-colors"><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setAdding(true); setEditIdx(null); }}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border border-dashed transition-all duration-200 hover:scale-105 ${isDark ? 'border-slate-600 text-slate-500 hover:border-blue-500 hover:text-blue-400' : 'border-gray-300 text-gray-400 hover:border-blue-400 hover:text-blue-500'}`}
|
||||
>
|
||||
<Plus className="w-3 h-3" /> 添加
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentCard({
|
||||
agent,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
isDark,
|
||||
}: {
|
||||
agent: AgentData;
|
||||
onUpdate: (a: AgentData) => void;
|
||||
onDelete: () => void;
|
||||
isDark: boolean;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const vis = AGENT_VISUALS[agent.id] ?? DEFAULT_VISUAL;
|
||||
const { Icon } = vis;
|
||||
const cardCls = isDark ? vis.darkCard : vis.lightCard;
|
||||
const iconCls = isDark ? vis.iconDark : vis.iconLight;
|
||||
const tagCls = isDark ? vis.tagDark : vis.tagLight;
|
||||
|
||||
const handleFieldChange = (field: keyof AgentData, value: unknown) => {
|
||||
onUpdate({ ...agent, [field]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
className={`relative rounded-xl border p-4 bg-gradient-to-br transition-all duration-300 ${cardCls} ${hovered ? 'scale-[1.02] shadow-lg -translate-y-0.5' : 'scale-100 shadow-sm'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-transform duration-300 ${iconCls} ${hovered ? 'scale-110 rotate-3' : ''}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-semibold text-sm ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{agent.name}</p>
|
||||
<p className={`text-xs mt-0.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{agent.dimension}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 transition-opacity duration-200 ${hovered ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700 text-slate-400 hover:text-blue-300' : 'hover:bg-white/70 text-gray-400 hover:text-blue-500'}`}
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700 text-slate-400 hover:text-red-400' : 'hover:bg-white/70 text-gray-400 hover:text-red-500'}`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<code className={`inline-block text-xs px-2 py-0.5 rounded-full border mb-2 font-mono ${tagCls}`}>{agent.id}</code>
|
||||
|
||||
<div className="mb-3">
|
||||
<p className={`text-xs leading-relaxed mb-1.5 line-clamp-2 ${isDark ? 'text-slate-400' : 'text-gray-600'}`}>{agent.expertise.slice(0, 80)}</p>
|
||||
</div>
|
||||
|
||||
{agent.relevant_fields.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<p className={`text-xs font-medium mb-1.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.relevant_fields}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{agent.relevant_fields.map(f => (
|
||||
<span key={f} className={`text-xs px-2 py-0.5 rounded-full border ${tagCls}`}>{f}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setEditing(false)}>
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||
<div
|
||||
className={`relative w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-2xl border shadow-2xl ${isDark ? 'bg-slate-900 border-slate-700' : 'bg-white border-gray-200'}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
||||
<h2 className={`font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>编辑智能体</h2>
|
||||
<button onClick={() => setEditing(false)} className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-800 text-slate-400' : 'hover:bg-gray-100 text-gray-500'}`}><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{(['id', 'name', 'dimension'] as const).map(k => (
|
||||
<div key={k} className="space-y-1">
|
||||
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS[k]}</label>
|
||||
<EditableText value={agent[k]} onChange={v => handleFieldChange(k, v)} isDark={isDark} />
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-1">
|
||||
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.expertise}</label>
|
||||
<EditableText value={agent.expertise} onChange={v => handleFieldChange('expertise', v)} isDark={isDark} multiline />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.relevant_fields}</label>
|
||||
<TagList tags={agent.relevant_fields} onChange={v => handleFieldChange('relevant_fields', v)} isDark={isDark} tagLight="bg-blue-50 text-blue-700 border-blue-200" tagDark="bg-slate-800/70 text-blue-200 border-blue-500/25" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.analysis_contract}</label>
|
||||
<TagList tags={agent.analysis_contract} onChange={v => handleFieldChange('analysis_contract', v)} isDark={isDark} tagLight="bg-violet-50 text-violet-700 border-violet-200" tagDark="bg-slate-800/70 text-violet-200 border-violet-500/25" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex justify-end gap-3 px-6 py-4 border-t ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
||||
<button onClick={() => setEditing(false)} className={`px-4 py-2 text-sm rounded-lg border transition-colors ${isDark ? 'border-slate-600 text-slate-400 hover:text-slate-200' : 'border-gray-300 text-gray-500 hover:text-gray-700'}`}>取消</button>
|
||||
<button onClick={() => setEditing(false)} className="px-4 py-2 text-sm rounded-lg bg-blue-500 hover:bg-blue-400 text-white transition-colors flex items-center gap-1.5">
|
||||
<Check className="w-3.5 h-3.5" /> 保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionWrapper({
|
||||
title,
|
||||
icon: Icon,
|
||||
children,
|
||||
isDark,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
children: React.ReactNode;
|
||||
isDark: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className={`rounded-xl border overflow-hidden transition-all duration-200 ${isDark ? 'bg-slate-800/40 border-slate-700/60' : 'bg-white border-gray-200 shadow-sm'}`}>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className={`w-full flex items-center justify-between px-5 py-4 transition-colors duration-200 ${isDark ? 'hover:bg-slate-700/30' : 'hover:bg-gray-50'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`w-7 h-7 rounded-lg flex items-center justify-center ${isDark ? 'bg-slate-700' : 'bg-red-50'}`}>
|
||||
<Icon className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />
|
||||
</div>
|
||||
<span className={`font-semibold text-sm ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{title}</span>
|
||||
</div>
|
||||
{open
|
||||
? <ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />
|
||||
: <ChevronRight className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />}
|
||||
</button>
|
||||
<div className={`grid transition-all duration-300 ease-in-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-70'}`}>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-5 pb-5 space-y-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Template6BEditor() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const [template, setTemplate] = useState<TemplateData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('https://ch1.b.uat.4.cn/zncmweb/templates/6b_template.json')
|
||||
.then(r => r.json())
|
||||
.then((data: TemplateData) => { setTemplate(data); setLoading(false); })
|
||||
.catch(e => { setError(e.message); setLoading(false); });
|
||||
}, []);
|
||||
|
||||
const update = <K extends keyof TemplateData>(key: K, value: TemplateData[K]) => {
|
||||
setTemplate(prev => prev ? { ...prev, [key]: value } : null);
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!template) return;
|
||||
console.log('[Template6BEditor 保存]', JSON.stringify(template, null, 2));
|
||||
window.alert('配置已保存,详情请查看控制台');
|
||||
setDirty(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center min-h-64 ${isDark ? 'text-slate-400' : 'text-gray-400'}`}>
|
||||
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
|
||||
<span className="text-sm">加载模板中…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !template) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 p-4 rounded-xl ${isDark ? 'bg-red-950/30 text-red-400 border border-red-900/50' : 'bg-red-50 text-red-600 border border-red-200'}`}>
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm">模板加载失败:{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-24px)] max-h-[calc(100vh-24px)] flex flex-col max-w-5xl mx-auto px-1 pb-3">
|
||||
<div className={`flex-shrink-0 flex items-center justify-between gap-4 py-3 border-b ${isDark ? 'border-slate-800' : 'border-gray-200'}`}>
|
||||
<div>
|
||||
<h1 className={`text-xl font-bold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>模板配置编辑器</h1>
|
||||
<p className={`text-sm mt-0.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>六步行动方案 · {template.template_id}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{dirty && (
|
||||
<div className={`flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border ${isDark ? 'bg-amber-900/20 border-amber-700/40 text-amber-300' : 'bg-amber-50 border-amber-200 text-amber-600'}`}>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
已有未保存修改
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl border shadow-sm transition-all duration-200 hover:scale-[1.02] ${isDark ? 'bg-slate-900 border-slate-700 text-slate-100 hover:border-blue-500/40' : 'bg-white border-gray-200 text-gray-800 hover:border-blue-300'}`}
|
||||
>
|
||||
<Save className="w-4 h-4" /> 保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-3 pr-1 space-y-4">
|
||||
<SectionWrapper title="基本信息" icon={FileText} isDark={isDark}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{(['template_id', 'template_name'] as const).map(k => (
|
||||
<div key={k} className="space-y-1.5">
|
||||
<span className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS[k]}</span>
|
||||
<EditableText value={template[k]} onChange={v => update(k, v)} isDark={isDark} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.template_brief}</span>
|
||||
<EditableText value={template.template_brief} onChange={v => update('template_brief', v)} isDark={isDark} multiline />
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
|
||||
<SectionWrapper title={FIELD_LABELS.usage_notes} icon={Info} isDark={isDark}>
|
||||
<TagList
|
||||
tags={template.usage_notes}
|
||||
onChange={v => update('usage_notes', v)}
|
||||
isDark={isDark}
|
||||
tagLight="bg-amber-50 text-amber-700 border-amber-200"
|
||||
tagDark="bg-slate-800/70 text-amber-200 border-amber-500/25"
|
||||
/>
|
||||
</SectionWrapper>
|
||||
|
||||
<SectionWrapper title={FIELD_LABELS.report_schema} icon={Settings} isDark={isDark}>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(template.report_schema).map(([key, desc]) => (
|
||||
<div key={key} className={`group/row rounded-lg border px-4 py-3 transition-all duration-200 hover:scale-[1.005] ${isDark ? 'border-slate-700/50 bg-slate-800/30 hover:border-slate-600' : 'border-gray-200 bg-gray-50/50 hover:border-gray-300 hover:shadow-sm'}`}>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<code className={`text-xs px-2 py-0.5 rounded font-mono ${isDark ? 'bg-slate-700 text-slate-400' : 'bg-gray-200 text-gray-500'}`}>{key}</code>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>{REPORT_SCHEMA_LABELS[key] || key}</span>
|
||||
</div>
|
||||
<EditableText
|
||||
value={desc}
|
||||
onChange={v => update('report_schema', { ...template.report_schema, [key]: v })}
|
||||
isDark={isDark}
|
||||
multiline
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
|
||||
<SectionWrapper title={FIELD_LABELS.agents} icon={Brain} isDark={isDark}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{template.agents.map((agent, i) => (
|
||||
<AgentCard
|
||||
key={agent.id + i}
|
||||
agent={agent}
|
||||
onUpdate={updated => {
|
||||
const next = [...template.agents];
|
||||
next[i] = updated;
|
||||
update('agents', next);
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (template.agents.length <= 1) {
|
||||
window.alert('智能体最少必须保留 1 个');
|
||||
return;
|
||||
}
|
||||
update('agents', template.agents.filter((_, j) => j !== i));
|
||||
}}
|
||||
isDark={isDark}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,235 @@
|
||||
.agent-card {
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.agent-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.agent-card-name {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.agent-card-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Card sections */
|
||||
.agent-card-section {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.agent-card-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.agent-card-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Streaming card highlight */
|
||||
.agent-card-streaming {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Streaming badge */
|
||||
.badge-streaming {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent-soft-strong);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
animation: pulse-badge 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-badge {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Streaming plain-text area */
|
||||
.streaming-text {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Blinking cursor */
|
||||
.streaming-cursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 0.9em;
|
||||
background: var(--accent);
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: blink-cursor 0.9s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink-cursor {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.agent-card-content-text {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Thinking section */
|
||||
.agent-card-thinking-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.agent-card-thinking-toggle:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.agent-card-thinking-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: rgba(139, 148, 158, 0.06);
|
||||
border-left: 2px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.agent-card-thinking-text .markdown-body {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.agent-card-thinking-text .markdown-body strong {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Dimension output */
|
||||
.dim-output-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dim-output-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dim-output-key {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.dim-output-value {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Retrieval */
|
||||
.retrieval-source-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.retrieval-source-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.retrieval-query-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.retrieval-adopted-badge {
|
||||
background: rgba(63, 185, 80, 0.15);
|
||||
color: var(--green);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.retrieval-not-adopted-badge {
|
||||
background: rgba(139, 148, 158, 0.1);
|
||||
color: var(--text-muted);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.retrieval-item {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.retrieval-item-num {
|
||||
flex-shrink: 0;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-radius: 3px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
line-height: 18px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.retrieval-item-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.retrieval-source-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
margin-left: 4px;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import './AgentCard.css';
|
||||
import MarkdownContent from './MarkdownContent';
|
||||
|
||||
function getPhaseBadgeClass(phase?: string): string {
|
||||
switch (phase) {
|
||||
case 'starting': return 'badge badge-starting';
|
||||
case 'diverging': return 'badge badge-diverging';
|
||||
case 'closing': return 'badge badge-closing';
|
||||
case 'done': return 'badge badge-done';
|
||||
default: return 'badge badge-default';
|
||||
}
|
||||
}
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
starting: '启动',
|
||||
diverging: '辩论',
|
||||
closing: '收敛',
|
||||
done: '完成',
|
||||
};
|
||||
|
||||
type RetrievalLogItem = {
|
||||
query?: string;
|
||||
source_name?: string;
|
||||
source_id?: string;
|
||||
source?: string;
|
||||
adopted?: boolean;
|
||||
content?: any[];
|
||||
content_ids?: string[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
dimension?: string;
|
||||
round: number;
|
||||
phase: string;
|
||||
content?: string;
|
||||
thinking?: string;
|
||||
dimensionOutput?: Record<string, any>;
|
||||
retrievalLog?: RetrievalLogItem[];
|
||||
retrievalCount?: number;
|
||||
referenceMap?: Record<string, string>;
|
||||
streaming?: boolean;
|
||||
};
|
||||
|
||||
export default function AgentCard({
|
||||
agentId,
|
||||
agentName,
|
||||
dimension,
|
||||
round,
|
||||
phase,
|
||||
content,
|
||||
thinking,
|
||||
dimensionOutput,
|
||||
retrievalLog,
|
||||
retrievalCount = 0,
|
||||
referenceMap,
|
||||
streaming = false,
|
||||
}: Props): JSX.Element {
|
||||
const [thinkingOpen, setThinkingOpen] = useState(false);
|
||||
const autoOpenedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (streaming && thinking && thinking.length > 0 && !autoOpenedRef.current) {
|
||||
setThinkingOpen(true);
|
||||
autoOpenedRef.current = true;
|
||||
}
|
||||
}, [streaming, thinking]);
|
||||
|
||||
const streamingPhase = streaming
|
||||
? (content && content.length > 0 ? 'content' : 'thinking')
|
||||
: null;
|
||||
|
||||
const hasThinking = thinking && thinking.trim().length > 0;
|
||||
const hasRetrieval = retrievalCount > 0 && retrievalLog && retrievalLog.length > 0;
|
||||
const hasDimOutput = dimensionOutput && Object.keys(dimensionOutput).length > 0;
|
||||
|
||||
return (
|
||||
<div className={`agent-card${streaming ? ' agent-card-streaming' : ''}`}>
|
||||
<div className="agent-card-header">
|
||||
<span className="agent-card-name">{agentName || agentId}</span>
|
||||
<div className="agent-card-badges">
|
||||
{dimension && (
|
||||
<span className="badge badge-default">{dimension}</span>
|
||||
)}
|
||||
<span className="badge badge-default">第 {round} 轮</span>
|
||||
<span className={getPhaseBadgeClass(phase)}>
|
||||
{PHASE_LABELS[phase] || phase}
|
||||
</span>
|
||||
{streaming && (
|
||||
<span className="badge badge-streaming">生成中</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(hasThinking || streamingPhase === 'thinking') && (
|
||||
<div className="agent-card-section">
|
||||
<button
|
||||
className="agent-card-thinking-toggle"
|
||||
onClick={() => setThinkingOpen(o => !o)}
|
||||
>
|
||||
<span>{thinkingOpen ? '▾' : '▸'}</span>
|
||||
<span>思考过程</span>
|
||||
{streamingPhase === 'thinking' && (
|
||||
<span className="streaming-cursor" style={{ marginLeft: 6 }} />
|
||||
)}
|
||||
</button>
|
||||
{thinkingOpen && (
|
||||
<div className="agent-card-thinking-text">
|
||||
{streaming ? (
|
||||
<span className="streaming-text" style={{ fontStyle: 'italic', fontSize: 12 }}>
|
||||
{thinking}
|
||||
</span>
|
||||
) : (
|
||||
<MarkdownContent content={thinking} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(content || streamingPhase === 'content') && (
|
||||
<div className="agent-card-section">
|
||||
<div className="agent-card-section-title">分析内容</div>
|
||||
<div className="agent-card-content-text">
|
||||
{streaming ? (
|
||||
<div className="streaming-text">
|
||||
{content}
|
||||
{streamingPhase === 'content' && <span className="streaming-cursor" />}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownContent content={content} referenceMap={referenceMap} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDimOutput && (
|
||||
<div className="agent-card-section">
|
||||
<div className="agent-card-section-title">维度输出</div>
|
||||
<div className="dim-output-grid">
|
||||
{Object.entries(dimensionOutput).map(([key, value]) => (
|
||||
<div key={key} className="dim-output-item">
|
||||
<div className="dim-output-key">{key}</div>
|
||||
<div className="dim-output-value">
|
||||
<MarkdownContent
|
||||
content={typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasRetrieval && (
|
||||
<div className="agent-card-section">
|
||||
<div className="agent-card-section-title">检索信息 ({retrievalCount})</div>
|
||||
{retrievalLog.map((log, logIdx) => (
|
||||
<div key={logIdx} className="retrieval-source-group">
|
||||
<div className="retrieval-query-label">
|
||||
<span>"{log.query}"</span>
|
||||
{(log.source_name || log.source_id || log.source) && (
|
||||
<span className="retrieval-source-label">{log.source_name || log.source_id || log.source}</span>
|
||||
)}
|
||||
{log.adopted !== undefined && (
|
||||
<span className={log.adopted ? 'retrieval-adopted-badge' : 'retrieval-not-adopted-badge'}>
|
||||
{log.adopted ? '已采用' : '未采用'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{log.content && log.content.map((item, itemIdx) => {
|
||||
const refNum = log.content_ids ? log.content_ids[itemIdx] : null;
|
||||
const refKey = refNum ? Object.entries(referenceMap || {}).find(([, v]) => v === refNum)?.[0] : null;
|
||||
return (
|
||||
<div key={itemIdx} className="retrieval-item">
|
||||
{refKey && <span className="retrieval-item-num">[{refKey}]</span>}
|
||||
<div className="retrieval-item-text">
|
||||
{typeof item === 'string'
|
||||
? (item.length > 200 ? item.slice(0, 200) + '…' : item)
|
||||
: JSON.stringify(item).slice(0, 200)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user