diff --git a/web-code/App.css b/web-code/App.css
new file mode 100644
index 00000000..a7b94ee4
--- /dev/null
+++ b/web-code/App.css
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/web-code/App.tsx b/web-code/App.tsx
new file mode 100644
index 00000000..91feb81f
--- /dev/null
+++ b/web-code/App.tsx
@@ -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 (
+
+
+
+
+
+ {appIdFromQuery}
+
+ } />
+
+ {/* 问答中心 */}
+ {/* } />
+ } />
+ } />
+ } />
+ } />
+ } /> */}
+ {/* 问答中心 - 嵌套路由 */}
+ }>
+ {/* 默认重定向到通用问答页面 */}
+ }
+ />
+ {/* 新建问答页面 - 初始化模式为新建 */}
+
+ }
+ />
+ {/* 通用问答页面 - 初始化模式为通用 */}
+
+ }
+ />
+ {/* 智能体问答页面 - 基于AI智能体的问答功能 */}
+ }
+ />
+ {/* 多智能体协作问答页面 - 多个AI智能体协同回答问题 */}
+ }
+ />
+ {/* 问答记录 */}
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+ {/*
+ }
+ /> */}
+
+ }
+ />
+
+ }
+ />
+
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {/* 轻应用中心 */}
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+ }
+ />
+ {/* 任务管理 */}
+ }>
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+ navigate(`/task-management/task-scene/${id}`)
+ }
+ onNavigate={handleNavigate}
+ />
+ }
+ />
+
+ {/* XK任务 */}
+
+ }
+ />
+
+ }
+ />
+
+ {/* 规划任务 */}
+
+ }
+ />
+
+ }
+ />
+
+ }
+ />
+
+ {/* MNTY任务 */}
+
+ }
+ />
+
+ }
+ />
+
+ }
+ />
+
+ {/* 效果PG任务 */}
+
+ }
+ />
+
+ }
+ />
+
+ }
+ />
+
+ }
+ />
+
+
+ {/* 场景管理 */}
+ navigate(`/scene-detail/${id}`)}
+ onNavigate={handleNavigate}
+ />
+ }
+ />
+ navigate("/scene-list")}
+ />
+ }
+ />
+
+ {/* 智能体管理 */}
+ navigate(`/agent-detail/${id}`)}
+ onNavigate={handleNavigate}
+ />
+ }
+ />
+ }
+ />
+ navigate("/agent-list")}
+ />
+ }
+ />
+ }
+ >
+ {/* */}
+ {/* }
+ /> */}
+ }
+ />
+ }
+ />
+ } />
+ } />
+ } />
+ navigate(`/sixstep-agent-edit?key=${key}`)} />}
+ />
+ navigate('/sixstep-agents')}
+ />
+ }
+ />
+ } />
+ {/* 结果管理 */}
+ {/* } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ navigate('/result-management')} />} />
+ navigate('/result-management')} />} /> */}
+ {/* 结果管理 - 嵌套路由 */}
+ }
+ >
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ navigate("/result-management")}
+ />
+ }
+ />
+ navigate("/result-management")}
+ />
+ }
+ />
+
+
+ {/* 资源管理 */}
+ }
+ />
+ }
+ />
+
+ }
+ />
+
+ }
+ />
+ {/*
+ }
+ /> */}
+
+ }
+ />
+
+ {/* 其他路由 */}
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ } />
+ }/>
+ } />
+
+
+
+
+ );
+}
+
+function App() {
+ return (
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/web-code/assets/icon/default-icon.svg b/web-code/assets/icon/default-icon.svg
new file mode 100644
index 00000000..746c61e4
--- /dev/null
+++ b/web-code/assets/icon/default-icon.svg
@@ -0,0 +1,17 @@
+
diff --git a/web-code/assets/icon/information-logo.png b/web-code/assets/icon/information-logo.png
new file mode 100644
index 00000000..6a393438
Binary files /dev/null and b/web-code/assets/icon/information-logo.png differ
diff --git a/web-code/assets/icon/轻应用logo-DB.svg b/web-code/assets/icon/轻应用logo-DB.svg
new file mode 100644
index 00000000..5df2b308
--- /dev/null
+++ b/web-code/assets/icon/轻应用logo-DB.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web-code/components/CreateTaskModal.tsx b/web-code/components/CreateTaskModal.tsx
new file mode 100644
index 00000000..f5a6883f
--- /dev/null
+++ b/web-code/components/CreateTaskModal.tsx
@@ -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 = ({ 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 (
+
+
+
+ {/* Header */}
+
+
+
新建任务
+
创建一个新的宣传策略分析任务
+
+
+
+
+ {/* Content */}
+
+ {/* Task Name */}
+
+
+ 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"
+ />
+
+
+ {/* Task Description */}
+
+
+
+
+ {/* Scene Selection */}
+
+
+
+
+
+ {/* Deadline */}
+
+
+ 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"
+ />
+
+
+ {/* Resources */}
+
+
+ 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"
+ />
+
+
+ {/* Constraints */}
+
+
+ {/* File Upload */}
+
+
+
+
+
点击或拖拽文件到此处上传
+
支持 PDF、Word、图片等格式
+
+
+
+
+ {/* Footer */}
+
+
+
+
+
+
+ );
+};
+
+export default CreateTaskModal;
diff --git a/web-code/components/ErrorBoundary.tsx b/web-code/components/ErrorBoundary.tsx
new file mode 100644
index 00000000..b98b4145
--- /dev/null
+++ b/web-code/components/ErrorBoundary.tsx
@@ -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 (
+
+
Something went wrong.
+
{searilizeError(this.state.error)}
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
diff --git a/web-code/components/Header.tsx b/web-code/components/Header.tsx
new file mode 100644
index 00000000..ae0f670d
--- /dev/null
+++ b/web-code/components/Header.tsx
@@ -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({
+ userName: '',
+ typeName: ''
+ });
+
+ useEffect(() => {
+ let userInfo: any = localStorage.getItem('userInfo');
+ if (userInfo) {
+ userInfo = JSON.parse(userInfo);
+ setUserData(userInfo);
+ }
+ }, []);
+ return (
+
+ {/* 左侧 - 搜索框 */}
+
+
+ {/* 右侧 - 用户信息和通知 */}
+
+ {/* 主题切换按钮 */}
+
+
+ {/* 通知 */}
+ {/*
*/}
+
+ {/* 分隔线 */}
+
+
+ {/* 用户信息 */}
+
+
+
+ );
+};
+
+export default Header;
diff --git a/web-code/components/MarkdownRenderer.tsx b/web-code/components/MarkdownRenderer.tsx
new file mode 100644
index 00000000..49ace999
--- /dev/null
+++ b/web-code/components/MarkdownRenderer.tsx
@@ -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 (
+
+
+ {language || 'code'}
+
+
+
+ {children}
+
+
+ );
+};
+
+const MarkdownRenderer: React.FC = ({ content, className = '', onCitationClick }) => {
+ const contentWithCitationBadge = content.replace(
+ /\[\[(\d+)\]\]/g,
+ '',
+ );
+
+ return (
+ {
+ 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);
+ }}
+ >
+
,
+ h2: ({ node, ...props }) => ,
+ h3: ({ node, ...props }) => ,
+ h4: ({ node, ...props }) => ,
+ h5: ({ node, ...props }) => ,
+ h6: ({ node, ...props }) => ,
+ p: ({ node, ...props }) => ,
+ ul: ({ node, ...props }) => ,
+ ol: ({ node, ...props }) =>
,
+ li: ({ node, ...props }) => ,
+ // code: ({ node, inline, className, children, ...props }: any) => {
+ // if (inline) {
+ // return (
+ //
+ // {children}
+ //
+ // );
+ // }
+ // return {children};
+ // },
+ // pre: ({ node, children }) => <>{children}>,
+ blockquote: ({ node, ...props }) => (
+
+ ),
+ a: ({ node, ...props }) => (
+
+ ),
+ table: ({ node, ...props }) => (
+
+ ),
+ thead: ({ node, ...props }) => ,
+ tbody: ({ node, ...props }) => ,
+ tr: ({ node, ...props }) =>
,
+ th: ({ node, ...props }) => | ,
+ td: ({ node, ...props }) => | ,
+ hr: ({ node, ...props }) =>
,
+ strong: ({ node, ...props }) => ,
+ em: ({ node, ...props }) => ,
+ }}
+ >
+ {contentWithCitationBadge}
+
+
+ );
+};
+
+export default MarkdownRenderer;
\ No newline at end of file
diff --git a/web-code/components/Sidebar.tsx b/web-code/components/Sidebar.tsx
new file mode 100644
index 00000000..528d6c7e
--- /dev/null
+++ b/web-code/components/Sidebar.tsx
@@ -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([
+ "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 = (set: Set, 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>(
+ new Set()
+ );
+ const [expandedTaskGroups, setExpandedTaskGroups] = useState>(
+ () =>
+ 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 (
+
+ 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)}`}
+ >
+
+ {(!isCollapsed || !top) && (
+
+ {item.label}
+
+ )}
+
+ );
+ };
+
+ const renderTaskGroup = (item: MenuItem) => {
+ const expanded = expandedTaskGroups.has(item.id);
+ const active = hasActiveChild(item);
+ const Icon = expanded ? FolderOpen : Folder;
+ return (
+
+
+ {expanded && item.children && (
+
+ {item.children.map((child) => renderLink(child))}
+
+ )}
+
+ );
+ };
+ const renderChildren = (item: MenuItem) =>
+ item.children ? (
+
+ {item.children.map((child) =>
+ child.children ? renderTaskGroup(child) : renderLink(child)
+ )}
+
+ ) : null;
+ const renderSubEntries = (item: MenuItem) => {
+ const entries = subEntries.filter((entry) => entry.parent === item.id);
+ return entries.length && isTopActive(item.id) ? (
+ {entries.map((entry) => renderLink(entry))}
+ ) : null;
+ };
+
+ return (
+
+ );
+}
diff --git a/web-code/components/agent/AgentChatPage.css b/web-code/components/agent/AgentChatPage.css
new file mode 100644
index 00000000..671306c1
--- /dev/null
+++ b/web-code/components/agent/AgentChatPage.css
@@ -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;
+}
diff --git a/web-code/components/agent/AgentChatPage.tsx b/web-code/components/agent/AgentChatPage.tsx
new file mode 100644
index 00000000..7aaeaf29
--- /dev/null
+++ b/web-code/components/agent/AgentChatPage.tsx
@@ -0,0 +1,1048 @@
+import { useEffect, useRef, useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { AlertCircle, ArrowLeft, ChevronDown, ChevronRight, FileText, FolderOpen, Loader2, MessageSquareText, X, Zap } from 'lucide-react';
+import './AgentChatPage.css';
+import { useTheme } from '../../contexts/ThemeContext';
+import { useExpertAuthBootstrap } from '../../hooks';
+import MarkdownRenderer from '../MarkdownRenderer';
+import AgentConversationList, { type ConversationMessage, type ConversationStep } from './AgentConversationList';
+import ChatInputComposer from './ChatInputComposer';
+import SkillCreatorThreadSidebar from './SkillCreatorThreadSidebar';
+import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
+
+interface FileNode {
+ name: string;
+ path: string;
+ type: 'file' | 'directory';
+ size: number | null;
+ extension: string | null;
+ children: FileNode[] | null;
+}
+
+interface Agent {
+ id: string;
+ name: string;
+ description: string;
+ status: string;
+ icon?: string;
+ capabilities?: string[];
+}
+
+interface HistoryMessageApiItem {
+ created_at: string;
+ id: number;
+ role: 'user' | 'assistant';
+ content: unknown;
+ thread_id: number;
+}
+
+const defaultAgent: Agent = {
+ id: '1',
+ name: '测试1',
+ description: '描述01',
+ status: '随时准备为您服务',
+ capabilities: ['告诉我的能力', '给我一些建议', '帮我分析问题'],
+};
+
+const MIN_CHAT_WIDTH = 360;
+const MIN_FILE_SIDEBAR_WIDTH = 420;
+const DEFAULT_FILE_SIDEBAR_WIDTH = 560;
+
+const formatFileSize = (size: number | null) => {
+ if (!size) return '';
+ if (size < 1024) return `${size} B`;
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
+ return `${(size / 1024 / 1024).toFixed(1)} MB`;
+};
+
+const getFileLanguage = (extension: string | null) => {
+ const normalized = extension?.replace('.', '').toLowerCase();
+ if (!normalized) return 'TEXT';
+ return normalized.toUpperCase();
+};
+
+const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null;
+
+const normalizeHistoryContent = (content: unknown) => {
+ const steps: ConversationStep[] = [];
+ const thinkings: Array<{ duration: number; content: string }> = [];
+ const texts: string[] = [];
+
+ const visit = (value: unknown) => {
+ if (typeof value === 'string') {
+ if (value.trim()) texts.push(value);
+ return;
+ }
+
+ if (Array.isArray(value)) {
+ value.forEach(visit);
+ return;
+ }
+
+ if (!isRecord(value)) return;
+
+ const type = typeof value.type === 'string' ? value.type : '';
+ const text = typeof value.text === 'string' ? value.text : '';
+
+ if (type === 'thinking-steps' && Array.isArray(value.steps)) {
+ value.steps.forEach((stepItem) => {
+ if (!isRecord(stepItem)) return;
+ const title = typeof stepItem.title === 'string' ? stepItem.title : '处理中';
+ const status = stepItem.status === 'completed' ? 'done' : 'processing';
+ const items = Array.isArray(stepItem.items) ? stepItem.items.filter((item): item is string => typeof item === 'string') : [];
+ steps.push({
+ text: title,
+ subText: items[0]?.replace('正在处理: ', ''),
+ status,
+ });
+ });
+ return;
+ }
+
+ if (type === 'reasoning' && text) {
+ const duration = typeof value.duration === 'number' ? value.duration / 1000 : 0;
+ thinkings.push({ duration, content: text });
+ return;
+ }
+
+ if (text) {
+ texts.push(text);
+ return;
+ }
+
+ if ('content' in value) {
+ visit(value.content);
+ }
+ };
+
+ visit(content);
+
+ return {
+ content: texts.join('\n\n').trim(),
+ steps,
+ thinkings,
+ };
+};
+
+const toConversationMessage = (message: HistoryMessageApiItem): ConversationMessage => {
+ const normalized = normalizeHistoryContent(message.content);
+
+ return {
+ id: String(message.id),
+ role: message.role,
+ content: normalized.content,
+ steps: normalized.steps,
+ thinkings: normalized.thinkings,
+ toolCalls: [],
+ };
+};
+
+interface FileTreeItemProps {
+ node: FileNode;
+ depth?: number;
+ isDark: boolean;
+ expandedPaths: Set;
+ selectedPath: string | null;
+ onToggle: (path: string) => void;
+ onSelect: (node: FileNode) => void;
+}
+
+function FileTreeItem({
+ node,
+ depth = 0,
+ isDark,
+ expandedPaths,
+ selectedPath,
+ onToggle,
+ onSelect,
+}: FileTreeItemProps) {
+ const isFolder = node.extension === null;
+ const isExpanded = expandedPaths.has(node.path);
+ const isSelected = selectedPath === node.path;
+ const hasChildren = Boolean(node.children?.length);
+
+ return (
+
+
+ {isFolder && isExpanded && hasChildren && (
+
+ {node.children!.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+interface AgentChatPageProps {
+ agent?: Agent;
+}
+
+export default function AgentChatPage({ agent: propAgent }: AgentChatPageProps) {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ const { authedFetch } = useExpertAuthBootstrap();
+ const [searchParams] = useSearchParams();
+ const agentId = searchParams.get('id');
+ const threadStorageKey = `agent_chat_thread_id_${agentId ?? 'default'}`;
+
+ const [agent, setAgent] = useState(propAgent || defaultAgent);
+ const [currentThreadId, setCurrentThreadId] = useState(() => localStorage.getItem(threadStorageKey) || '');
+ const [inputValue, setInputValue] = useState('');
+ const [messages, setMessages] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isStreaming, setIsStreaming] = useState(false);
+ const [isThreadSidebarOpen, setIsThreadSidebarOpen] = useState(false);
+ const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
+ const [showScrollButton, setShowScrollButton] = useState(false);
+ const [fileList, setFileList] = useState([]);
+ const [fileListThreadId, setFileListThreadId] = useState(null);
+ const [isFileSidebarOpen, setIsFileSidebarOpen] = useState(false);
+ const [fileSidebarWidth, setFileSidebarWidth] = useState(DEFAULT_FILE_SIDEBAR_WIDTH);
+ const [expandedPaths, setExpandedPaths] = useState>(() => new Set());
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [filePreviewContent, setFilePreviewContent] = useState('');
+ const [isFilePreviewLoading, setIsFilePreviewLoading] = useState(false);
+ const [filePreviewError, setFilePreviewError] = useState('');
+ const scrollContainerRef = useRef(null);
+ const thinkingStartRef = useRef(0);
+ const abortControllerRef = useRef(null);
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ setCurrentThreadId(localStorage.getItem(threadStorageKey) || '');
+ }, [threadStorageKey]);
+
+ useEffect(() => {
+ const fetchAgentDetail = async () => {
+ if (!agentId) return;
+
+ setIsLoading(true);
+ try {
+ const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}`, {
+ method: 'GET',
+ });
+
+ if (!res.ok) {
+ console.error('获取专家信息失败:', res.status, res.statusText);
+ return;
+ }
+
+ const data: unknown = await res.json();
+ const agentDetail = data as {
+ id: number;
+ name?: string;
+ description?: string | null;
+ };
+
+ const agentName = agentDetail.name || '未命名专家';
+ setAgent({
+ id: String(agentDetail.id),
+ name: agentName,
+ description: agentDetail.description || '暂无描述',
+ status: '随时准备为您服务',
+ capabilities: ['告诉我的能力', '给我一些建议', '帮我分析问题'],
+ });
+ } catch (error) {
+ console.error('初始化专家信息异常:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ void fetchAgentDetail();
+ }, [agentId, authedFetch]);
+
+ useEffect(() => {
+ if (!fileList.length) return;
+
+ setExpandedPaths((prev) => {
+ if (prev.size > 0) return prev;
+ const firstLevelFolders = fileList.filter((node) => node.extension === null).map((node) => node.path);
+ return new Set(firstLevelFolders);
+ });
+ }, [fileList]);
+
+ const clearLoadingStates = () => {
+ setMessages((prev) =>
+ prev.map((msg) => {
+ if (msg.role === 'assistant') {
+ return {
+ ...msg,
+ steps: [],
+ thinkings: [],
+ toolCalls: [],
+ };
+ }
+ return msg;
+ }),
+ );
+ };
+
+ const syncThreadId = (nextThreadId: string) => {
+ setCurrentThreadId(nextThreadId);
+ if (nextThreadId) {
+ localStorage.setItem(threadStorageKey, nextThreadId);
+ } else {
+ localStorage.removeItem(threadStorageKey);
+ }
+ };
+
+ const fetchThreadFiles = async (id: number) => {
+ if (!id) {
+ setFileList([]);
+ setFileListThreadId(null);
+ return;
+ }
+
+ try {
+ const filesRes = await authedFetch(
+ `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${id}/files/all`,
+ { method: 'GET' },
+ );
+ if (filesRes.ok) {
+ const filesData: unknown = await filesRes.json();
+ setFileList(Array.isArray(filesData) ? (filesData as FileNode[]) : []);
+ setFileListThreadId(id);
+ } else {
+ setFileList([]);
+ setFileListThreadId(null);
+ }
+ } catch (e) {
+ console.error('获取文件列表失败:', e);
+ setFileList([]);
+ setFileListThreadId(null);
+ }
+ };
+
+ const fetchThreadMessages = async (id: number) => {
+ setIsLoading(true);
+ try {
+ const response = await authedFetch(
+ `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${id}/messages`,
+ { method: 'GET' },
+ );
+
+ if (!response.ok) {
+ throw new Error(`获取会话消息失败: ${response.status}`);
+ }
+
+ const data = (await response.json()) as HistoryMessageApiItem[];
+ const nextMessages = Array.isArray(data) ? data.map(toConversationMessage) : [];
+ setMessages(nextMessages);
+ } catch (error) {
+ console.error('获取聊天记录消息失败:', error);
+ setMessages([]);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ const id = Number(currentThreadId);
+
+ setSelectedFile(null);
+ setFilePreviewContent('');
+ setFilePreviewError('');
+ setExpandedPaths(new Set());
+
+ if (!id) {
+ setMessages([]);
+ setFileList([]);
+ setFileListThreadId(null);
+ return;
+ }
+
+ void fetchThreadMessages(id);
+ void fetchThreadFiles(id);
+ }, [currentThreadId]);
+
+ const handleStopStream = () => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ setIsStreaming(false);
+ clearLoadingStates();
+ }
+ };
+
+ const handleUploadFile = async (file: File) => {
+ if (!currentThreadId) return;
+
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const response = await authedFetch(
+ `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${currentThreadId}/files/upload`,
+ {
+ method: 'POST',
+ body: formData,
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`上传失败: ${response.status} ${response.statusText}`);
+ }
+
+ await fetchThreadFiles(Number(currentThreadId));
+ } catch (error) {
+ console.error('上传专家会话文件失败:', error);
+ }
+ };
+
+ const handleSendMessage = async () => {
+ const text = inputValue.trim();
+ const numericThreadId = Number(currentThreadId);
+ if (!text || !numericThreadId || isStreaming) return;
+ const assistantId = `assistant-${Date.now()}`;
+
+ setInputValue('');
+ setIsStreaming(true);
+ setAutoScrollEnabled(true);
+ abortControllerRef.current = new AbortController();
+
+ try {
+ const userMsg: ConversationMessage = {
+ id: `user-${Date.now()}`,
+ role: 'user',
+ content: text,
+ };
+ setMessages((prev) => [
+ ...prev,
+ userMsg,
+ { id: assistantId, role: 'assistant', content: '', steps: [], thinkings: [], toolCalls: [] },
+ ]);
+
+ const msgRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/messages`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ thread_id: numericThreadId,
+ role: 'user',
+ content: { type: 'text', text },
+ }),
+ });
+
+ if (!msgRes.ok) {
+ console.error('发送消息失败:', msgRes.status, msgRes.statusText);
+ setMessages((prev) => prev.filter((msg) => msg.id !== assistantId));
+ return;
+ }
+
+ const streamRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/run/stream`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ chat_id: numericThreadId,
+ expert_agent_id: Number(agentId),
+ user_query: text,
+ messages: [{ role: 'user', content: text }],
+ }),
+ signal: abortControllerRef.current.signal,
+ });
+
+ if (!streamRes.ok || !streamRes.body) {
+ console.error('流式接口失败:', streamRes.status);
+ setMessages((prev) => prev.filter((msg) => msg.id !== assistantId));
+ return;
+ }
+
+ const reader = streamRes.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ const thinkingStepsAcc: Array<{ id: string; title: string; status: string; items: string[] }> = [];
+ const reasoningsAcc: Array<{ text: string; duration: number }> = [];
+ let assistantTextAcc = '';
+ let reasoningStartAt = 0;
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() ?? '';
+
+ for (const line of lines) {
+ if (!line.startsWith('data: ')) continue;
+ const raw = line.slice(6).trim();
+ if (raw === '[DONE]') break;
+
+ let event: {
+ type: string;
+ data?: { id: string; title: string; status: string; items?: string[] };
+ delta?: string;
+ toolCallId?: string;
+ toolName?: string;
+ input?: Record;
+ output?: { result?: string } | unknown;
+ } | null = null;
+
+ try {
+ event = JSON.parse(raw);
+ } catch {
+ continue;
+ }
+
+ if (!event) continue;
+
+ switch (event.type) {
+ case 'data-thinking-step': {
+ const step = event.data!;
+ const stepData = {
+ id: step.id,
+ title: step.title,
+ status: step.status,
+ items: step.items ?? [],
+ };
+ const existingIdx = thinkingStepsAcc.findIndex((s) => s.id === step.id);
+ if (existingIdx >= 0) thinkingStepsAcc[existingIdx] = stepData;
+ else thinkingStepsAcc.push(stepData);
+ break;
+ }
+ case 'reasoning-start': {
+ reasoningStartAt = Date.now();
+ reasoningsAcc.push({ text: '', duration: 0 });
+ break;
+ }
+ case 'reasoning-delta': {
+ if (reasoningsAcc.length === 0) {
+ reasoningsAcc.push({ text: event.delta ?? '', duration: 0 });
+ } else {
+ reasoningsAcc[reasoningsAcc.length - 1].text += event.delta ?? '';
+ }
+ break;
+ }
+ case 'reasoning-end': {
+ if (reasoningsAcc.length > 0) {
+ reasoningsAcc[reasoningsAcc.length - 1].duration = Date.now() - reasoningStartAt;
+ }
+ break;
+ }
+ case 'text-delta': {
+ assistantTextAcc += event.delta ?? '';
+ break;
+ }
+ }
+
+ setMessages((prev) =>
+ prev.map((msg) => {
+ if (msg.id !== assistantId) return msg;
+
+ switch (event!.type) {
+ case 'data-thinking-step': {
+ const step = event!.data!;
+ const newStep = {
+ text: step.title,
+ subText: step.items?.[0]?.replace('正在处理: ', '') ?? undefined,
+ status: (step.status === 'completed' ? 'done' : 'processing') as 'done' | 'processing',
+ };
+ const steps = [...(msg.steps ?? [])];
+ const stepIdx = steps.findIndex((s) => s.text === step.title);
+ if (stepIdx >= 0) steps[stepIdx] = newStep;
+ else steps.push(newStep);
+ return { ...msg, steps };
+ }
+ case 'reasoning-start': {
+ thinkingStartRef.current = Date.now();
+ return { ...msg, thinkings: [...(msg.thinkings ?? []), { duration: 0, content: '' }] };
+ }
+ case 'reasoning-delta': {
+ const thinkings = [...(msg.thinkings ?? [])];
+ if (thinkings.length === 0) {
+ thinkings.push({ duration: 0, content: event!.delta ?? '' });
+ } else {
+ const last = thinkings[thinkings.length - 1];
+ thinkings[thinkings.length - 1] = { ...last, content: last.content + (event!.delta ?? '') };
+ }
+ return { ...msg, thinkings };
+ }
+ case 'reasoning-end': {
+ const duration = (Date.now() - thinkingStartRef.current) / 1000;
+ const thinkings = [...(msg.thinkings ?? [])];
+ if (thinkings.length > 0) {
+ thinkings[thinkings.length - 1] = { ...thinkings[thinkings.length - 1], duration };
+ }
+ return { ...msg, thinkings };
+ }
+ case 'tool-input-available': {
+ const toolCalls = [...(msg.toolCalls ?? [])];
+ const tcIdx = toolCalls.findIndex((t) => t.id === event!.toolCallId);
+ const newTc = { id: event!.toolCallId!, name: event!.toolName!, input: event!.input };
+ if (tcIdx >= 0) toolCalls[tcIdx] = { ...toolCalls[tcIdx], ...newTc };
+ else toolCalls.push(newTc);
+ return { ...msg, toolCalls };
+ }
+ case 'tool-output-available': {
+ const toolCalls = [...(msg.toolCalls ?? [])];
+ const tcIdx = toolCalls.findIndex((t) => t.id === event!.toolCallId);
+ if (tcIdx >= 0) {
+ const rawOutput = event!.output;
+ const outputStr =
+ typeof rawOutput === 'object' && rawOutput !== null && 'result' in rawOutput
+ ? String((rawOutput as { result: unknown }).result)
+ : JSON.stringify(rawOutput);
+ toolCalls[tcIdx] = { ...toolCalls[tcIdx], output: outputStr };
+ }
+ return { ...msg, toolCalls };
+ }
+ case 'text-delta': {
+ return { ...msg, content: msg.content + (event!.delta ?? '') };
+ }
+ default:
+ return msg;
+ }
+ }),
+ );
+ }
+ }
+
+ const persistedContent: Array> = [];
+ if (thinkingStepsAcc.length > 0) {
+ persistedContent.push({ type: 'thinking-steps', steps: thinkingStepsAcc });
+ }
+ for (const reasoning of reasoningsAcc) {
+ if (reasoning.text) {
+ persistedContent.push({ type: 'reasoning', text: reasoning.text, duration: reasoning.duration });
+ }
+ }
+ if (assistantTextAcc) {
+ persistedContent.push({ type: 'text', text: assistantTextAcc });
+ }
+
+ if (persistedContent.length > 0) {
+ const persistRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/messages`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ thread_id: numericThreadId,
+ role: 'assistant',
+ content: persistedContent,
+ }),
+ });
+ if (!persistRes.ok) {
+ console.error('保存助手消息失败:', persistRes.status, persistRes.statusText);
+ }
+ }
+
+ await fetchThreadFiles(numericThreadId);
+ } catch (error) {
+ if (error instanceof Error && error.name === 'AbortError') {
+ console.log('流已被取消');
+ } else {
+ console.error('对话异常:', error);
+ }
+ } finally {
+ setIsStreaming(false);
+ abortControllerRef.current = null;
+ }
+ };
+
+ const handleCapabilityClick = (capability: string) => {
+ setInputValue(capability);
+ };
+
+ const handleToggleFolder = (path: string) => {
+ setExpandedPaths((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) next.delete(path);
+ else next.add(path);
+ return next;
+ });
+ };
+
+ const handleSelectFile = async (file: FileNode) => {
+ if (!fileListThreadId) {
+ setFilePreviewError('当前文件列表还没有关联到可预览的会话');
+ return;
+ }
+
+ setSelectedFile(file);
+ setFilePreviewContent('');
+ setFilePreviewError('');
+ setIsFilePreviewLoading(true);
+
+ try {
+ const previewRes = await authedFetch(
+ `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${fileListThreadId}/files/preview?path=${encodeURIComponent(file.path)}`,
+ { method: 'GET' },
+ );
+
+ if (!previewRes.ok) {
+ setFilePreviewError(`文件预览获取失败,状态码 ${previewRes.status}`);
+ return;
+ }
+
+ const contentType = previewRes.headers.get('content-type') ?? '';
+ if (contentType.includes('application/json')) {
+ const data: unknown = await previewRes.json();
+ if (typeof data === 'string') {
+ setFilePreviewContent(data);
+ } else if (data && typeof data === 'object' && 'content' in data) {
+ setFilePreviewContent(String((data as { content: unknown }).content ?? ''));
+ } else {
+ setFilePreviewContent(JSON.stringify(data, null, 2));
+ }
+ } else {
+ setFilePreviewContent(await previewRes.text());
+ }
+ } catch (error) {
+ console.error('获取文件预览失败:', error);
+ setFilePreviewError('文件预览获取失败,请稍后重试');
+ } finally {
+ setIsFilePreviewLoading(false);
+ }
+ };
+
+ const handleResizeStart = (event: React.MouseEvent) => {
+ event.preventDefault();
+ const startX = event.clientX;
+ const startWidth = fileSidebarWidth;
+
+ const handleMouseMove = (moveEvent: MouseEvent) => {
+ const containerWidth = window.innerWidth - 160;
+ const maxWidth = Math.max(MIN_FILE_SIDEBAR_WIDTH, containerWidth - MIN_CHAT_WIDTH);
+ const nextWidth = Math.min(Math.max(startWidth - (moveEvent.clientX - startX), MIN_FILE_SIDEBAR_WIDTH), maxWidth);
+ setFileSidebarWidth(nextWidth);
+ };
+
+ const handleMouseUp = () => {
+ document.body.classList.remove('agent-resizing');
+ window.removeEventListener('mousemove', handleMouseMove);
+ window.removeEventListener('mouseup', handleMouseUp);
+ };
+
+ document.body.classList.add('agent-resizing');
+ window.addEventListener('mousemove', handleMouseMove);
+ window.addEventListener('mouseup', handleMouseUp);
+ };
+
+ const scrollToBottom = (smooth = true) => {
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollTo({
+ top: scrollContainerRef.current.scrollHeight,
+ behavior: smooth ? 'smooth' : 'auto',
+ });
+ }
+ };
+
+ const handleScroll = (e: React.UIEvent) => {
+ const container = e.currentTarget;
+ const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
+ setShowScrollButton(!isAtBottom && messages.length > 0);
+ if (!isAtBottom) {
+ setAutoScrollEnabled(false);
+ }
+ };
+
+ useEffect(() => {
+ if (autoScrollEnabled && messages.length > 0) {
+ const timer = setTimeout(() => scrollToBottom(false), 100);
+ return () => clearTimeout(timer);
+ }
+ }, [messages, autoScrollEnabled]);
+
+ const handleThreadSelect = (nextThreadId: string) => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ setIsStreaming(false);
+ clearLoadingStates();
+ }
+ syncThreadId(nextThreadId);
+ };
+
+ return (
+
+
+
+
+
+
+ {agent.name.charAt(0)}
+
+
+
+ {agent.name}
+
+
{agent.description}
+
+
+
+
+
+
+
+
+
+
+
String(thread.expert_agent_id ?? '') === String(agentId ?? '')}
+ onClose={() => setIsThreadSidebarOpen(false)}
+ onThreadSelect={handleThreadSelect}
+ />
+
+
+
+ {isLoading ? (
+
+ ) : messages.length === 0 ? (
+
+
+
+
+
+
+ 你好,我是 {agent.name}
+
+
{agent.description}
+
+
+
+ {agent.status}
+
+
+
+ {agent.capabilities?.map((capability, idx) => (
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+ {/* {showScrollButton && (
+
+ )} */}
+
+
+
void handleSendMessage()}
+ onStop={handleStopStream}
+ onUploadFile={(file) => void handleUploadFile(file)}
+ isSending={isStreaming}
+ isDarkMode={isDark}
+ showBorderTop
+ placeholder="输入你的问题..."
+ />
+
+
+ {isFileSidebarOpen && (
+
+ )}
+
+
+ );
+}
diff --git a/web-code/components/agent/AgentConversationList.tsx b/web-code/components/agent/AgentConversationList.tsx
new file mode 100644
index 00000000..4fa1d2b1
--- /dev/null
+++ b/web-code/components/agent/AgentConversationList.tsx
@@ -0,0 +1,55 @@
+import SkillCreatorConversationItem from './SkillCreatorConversationItem';
+
+export interface ToolCall {
+ id: string;
+ name: string;
+ input?: Record;
+ 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 (
+
+ {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 (
+
+ );
+ })}
+
+ );
+}
diff --git a/web-code/components/agent/AgentExpertCenterPage.tsx b/web-code/components/agent/AgentExpertCenterPage.tsx
new file mode 100644
index 00000000..51ede3f1
--- /dev/null
+++ b/web-code/components/agent/AgentExpertCenterPage.tsx
@@ -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('mine');
+ const [agentList, setAgentList] = useState([]);
+ const [isListLoading, setIsListLoading] = useState(false);
+ const [isStatusUpdating, setIsStatusUpdating] = useState(false);
+ const [detailVisible, setDetailVisible] = useState(false);
+ const [selectedAgent, setSelectedAgent] = useState(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();
+ [...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 (
+
+
+
+
+
+
+
管理你的专属 AI 专家助手
+
已加载模型:{llmList.length}
+
+
+
+
+
+
+
+
+
+
+
+
+ {isListLoading ? (
+
+
+
+ ) : cards.length === 0 ? (
+
+
+
+
+
+ {activeTab === 'mine' ? '暂无我的专家' : '暂无发布的专家'}
+
+
+ 创建你的第一个专家助手,开始高效工作
+
+
+
+ ) : (
+
+ {cards.map((item, idx) => (
+
+
+
+ {item.avatarSvg ? (
+
}`})
+ ) : (
+
+ {idx % 2 === 0 ? : }
+
+ )}
+
+
{item.title}
+
{item.desc}
+
+
+
+
+ {activeTab === 'mine' && (
+ <>
+
+
+
delAgentSearch(item.id)}
+ >
+
+
+ >
+ )}
+
+
+
+ {item.status}
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
不确定从哪个开始?
+
直接开始对话,AI 会自动匹配最合适的专家助手
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web-code/components/agent/AgentExpertEditPage.tsx b/web-code/components/agent/AgentExpertEditPage.tsx
new file mode 100644
index 00000000..da5184f9
--- /dev/null
+++ b/web-code/components/agent/AgentExpertEditPage.tsx
@@ -0,0 +1,1101 @@
+import { useEffect, useRef, useState } from 'react';
+import { useSearchParams, useNavigate } from 'react-router-dom';
+import {
+ ArrowLeft,
+ Bot,
+ Copy,
+ Download,
+ ImagePlus,
+ Paperclip,
+ Pencil,
+ Save,
+ Sparkles,
+ Wrench,
+ Flower2,
+ CheckCircle2,
+ X,
+ ChevronDown,
+ Edit,
+} from 'lucide-react';
+import AgentConversationList, { type ConversationMessage } from './AgentConversationList';
+import ChatInputComposer from './ChatInputComposer';
+import { Input, Select, Tabs, Textarea, Dialog, MessagePlugin } from 'tdesign-react';
+import { useTheme } from '../../contexts/ThemeContext';
+import { useExpertAuthBootstrap } from '../../hooks';
+import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
+
+interface AgentExpertEditPageProps {
+ agentId?: string | null;
+}
+
+export default function AgentExpertEditPage({ agentId: propAgentId }: AgentExpertEditPageProps) {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ const { llmList, authedFetch } = useExpertAuthBootstrap();
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const agentId = propAgentId || searchParams.get('id') || '12';
+ const [tab, setTab] = useState('edit');
+ const [toolDialogVisible, setToolDialogVisible] = useState(false);
+ const [skillDialogVisible, setSkillDialogVisible] = useState(false);
+ const [selectedTools, setSelectedTools] = useState([]);
+ const [pendingTools, setPendingTools] = useState([]);
+ const [boundToolIdMap, setBoundToolIdMap] = useState>({});
+ const [selectedSkills, setSelectedSkills] = useState([]);
+ const [skillKeyword, setSkillKeyword] = useState('');
+ const [skillOptions, setSkillOptions] = useState<{ id: string; name: string; desc: string }[]>([]);
+ const [boundSkillMap, setBoundSkillMap] = useState>({});
+ const [isSkillLoading, setIsSkillLoading] = useState(false);
+ const [bindingSkillIds, setBindingSkillIds] = useState([]);
+ const [unbindingSkillIds, setUnbindingSkillIds] = useState([]);
+ const [isToolSubmitting, setIsToolSubmitting] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const [toolOptions, setToolOptions] = useState<{ id: string; name: string; desc: string }[]>([]);
+ const [messages, setMessages] = useState([]);
+ const [inputValue, setInputValue] = useState('');
+ const [threadId, setThreadId] = useState(null);
+ const [isStreaming, setIsStreaming] = useState(false);
+ const thinkingStartRef = useRef(0);
+ const scrollContainerRef = useRef(null);
+ const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
+ const [showScrollButton, setShowScrollButton] = useState(false);
+ const router = useNavigate();
+
+ const scrollToBottom = (smooth = true) => {
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollTo({
+ top: scrollContainerRef.current.scrollHeight,
+ behavior: smooth ? 'smooth' : 'auto',
+ });
+ }
+ };
+
+ const handleScroll = (e: React.UIEvent) => {
+ const container = e.currentTarget;
+ const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
+ setShowScrollButton(!isAtBottom && messages.length > 0);
+ if (!isAtBottom) {
+ setAutoScrollEnabled(false);
+ }
+ };
+
+ useEffect(() => {
+ if (autoScrollEnabled && messages.length > 0) {
+ const timer = setTimeout(() => scrollToBottom(false), 100);
+ return () => clearTimeout(timer);
+ }
+ }, [messages, autoScrollEnabled]);
+
+ const ensureThreadId = async () => {
+ if (threadId) return threadId;
+
+ const threadRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/no_real/${agentId}`, {
+ method: 'GET',
+ });
+ if (!threadRes.ok) {
+ throw new Error(`获取会话失败: ${threadRes.status}`);
+ }
+
+ const threadData = (await threadRes.json()) as { id: number };
+ setThreadId(threadData.id);
+ return threadData.id;
+ };
+
+ const sendMessage = async () => {
+ const text = inputValue.trim();
+ if (!text || isStreaming) return;
+ setInputValue('');
+ setIsStreaming(true);
+
+ const userMsg: ConversationMessage = {
+ id: `user-${Date.now()}`,
+ role: 'user',
+ content: text,
+ };
+ setMessages((prev) => [...prev, userMsg]);
+
+ try {
+ // 1. GET or reuse thread
+ let tid = threadId;
+ if (!tid) {
+ const threadRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/no_real/${agentId}`, {
+ method: 'GET',
+ });
+ if (!threadRes.ok) {
+ console.error('创建会话失败:', threadRes.status);
+ return;
+ }
+ const threadData = (await threadRes.json()) as { id: number };
+ tid = threadData.id;
+ setThreadId(tid);
+ }
+
+ // 2. POST user message
+ const msgRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/messages`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ thread_id: tid, role: 'user', content: { type: 'text', text } }),
+ });
+ if (!msgRes.ok) {
+ console.error('发送消息失败:', msgRes.status);
+ return;
+ }
+
+ // 3. Stream assistant response
+ const assistantId = `assistant-${Date.now()}`;
+ setMessages((prev) => [
+ ...prev,
+ { id: assistantId, role: 'assistant', content: '', steps: [], thinkings: [], toolCalls: [] },
+ ]);
+
+ const streamRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/run/stream/help_expert`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ chat_id: tid, expert_agent_id: Number(agentId), user_query: text, messages: [] }),
+ });
+ if (!streamRes.ok || !streamRes.body) {
+ console.error('流式接口失败:', streamRes.status);
+ return;
+ }
+
+ const reader = streamRes.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() ?? '';
+
+ for (const line of lines) {
+ if (!line.startsWith('data: ')) continue;
+ const raw = line.slice(6).trim();
+ if (raw === '[DONE]') break;
+
+ let event: { type: string; data?: { id: string; title: string; status: string; items?: string[] }; id?: string; delta?: string } | null = null;
+ try { event = JSON.parse(raw) as typeof event; } catch { continue; }
+ if (!event) continue;
+
+ setMessages((prev) =>
+ prev.map((msg) => {
+ if (msg.id !== assistantId) return msg;
+ switch (event!.type) {
+ case 'data-thinking-step': {
+ const step = event!.data!;
+ const newStep = {
+ text: step.title,
+ subText: step.items?.[0]?.replace('正在处理: ', '') ?? undefined,
+ status: (step.status === 'completed' ? 'done' : 'processing') as 'done' | 'processing',
+ };
+ const steps = [...(msg.steps ?? [])];
+ const idx = steps.findIndex((s) => s.text === step.title);
+ if (idx >= 0) steps[idx] = newStep; else steps.push(newStep);
+ return { ...msg, steps };
+ }
+ case 'reasoning-start': {
+ thinkingStartRef.current = Date.now();
+ return { ...msg, thinkings: [...(msg.thinkings ?? []), { duration: 0, content: '' }] };
+ }
+ case 'reasoning-delta': {
+ const thinkings = [...(msg.thinkings ?? [])];
+ if (thinkings.length === 0) {
+ thinkings.push({ duration: 0, content: event!.delta ?? '' });
+ } else {
+ const last = thinkings[thinkings.length - 1];
+ thinkings[thinkings.length - 1] = { ...last, content: last.content + (event!.delta ?? '') };
+ }
+ return { ...msg, thinkings };
+ }
+ case 'reasoning-end': {
+ const duration = (Date.now() - thinkingStartRef.current) / 1000;
+ const thinkings = [...(msg.thinkings ?? [])];
+ if (thinkings.length > 0) {
+ thinkings[thinkings.length - 1] = { ...thinkings[thinkings.length - 1], duration };
+ }
+ return { ...msg, thinkings };
+ }
+ case 'text-delta': {
+ return { ...msg, content: msg.content + (event!.delta ?? '') };
+ }
+ default:
+ return msg;
+ }
+ }),
+ );
+ }
+ }
+ } catch (error) {
+ console.error('对话异常:', error);
+ } finally {
+ setIsStreaming(false);
+ }
+ };
+
+ const handleUploadFile = async (file: File) => {
+ try {
+ const tid = await ensureThreadId();
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const response = await authedFetch(
+ `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${tid}/files/upload`,
+ {
+ method: 'POST',
+ body: formData,
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`上传失败: ${response.status} ${response.statusText}`);
+ }
+ } catch (error) {
+ console.error('上传专家编辑文件失败:', error);
+ }
+ };
+
+
+ const [formValues, setFormValues] = useState({
+ name: '未命名专家',
+ modelName: 'Qwen3.5-plus',
+ description: '',
+ systemPrompt: '你是一个专业的助手,能够回答用户的问题。',
+ });
+
+ const modelOptions = llmList.map((item) => ({ label: item.name, value: item.id }));
+
+ useEffect(() => {
+ const fetchExpertDetail = async () => {
+ try {
+ const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}`, {
+ method: 'GET',
+ });
+
+ if (!res.ok) {
+ console.error('查询专家详情失败:', res.status, res.statusText);
+ return;
+ }
+
+ const data: unknown = await res.json();
+ const detail = data as {
+ name?: string;
+ description?: string | null;
+ system_prompt?: string | null;
+ model_name?: string | null;
+ };
+
+ setFormValues((prev) => ({
+ ...prev,
+ name: detail.name || '',
+ description: detail.description || '',
+ systemPrompt: detail.system_prompt || '',
+ modelName: detail.model_name || prev.modelName,
+ }));
+ } catch (error) {
+ console.error('查询专家详情异常:', error);
+ }
+ };
+
+ void fetchExpertDetail();
+ }, [authedFetch, agentId]);
+
+ useEffect(() => {
+ const fetchBuiltinTools = async () => {
+ try {
+ const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/tools/builtin`, {
+ method: 'GET',
+ });
+
+ if (!res.ok) {
+ console.error('builtin tools接口调用失败:', res.status, res.statusText);
+ setToolOptions([]);
+ return;
+ }
+
+ const data: unknown = await res.json();
+ const list = Array.isArray(data)
+ ? (data as { name: string; description: string }[]).map((item) => ({
+ id: item.name,
+ name: item.name,
+ desc: item.description,
+ }))
+ : [];
+
+ setToolOptions(list);
+ } catch (error) {
+ console.error('builtin tools接口异常:', error);
+ setToolOptions([]);
+ }
+ };
+
+ void fetchBuiltinTools();
+ }, [authedFetch]);
+
+ useEffect(() => {
+ const fetchSkills = async () => {
+ setIsSkillLoading(true);
+ try {
+ const keyword = encodeURIComponent(skillKeyword.trim());
+ const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills?type=all&keyword=${keyword}&page=1&page_size=100`, {
+ method: 'GET',
+ });
+
+ if (!res.ok) {
+ console.error('skills接口调用失败:', res.status, res.statusText);
+ setSkillOptions([]);
+ return;
+ }
+
+ const data: unknown = await res.json();
+ const items =
+ data && typeof data === 'object' && Array.isArray((data as { items?: unknown[] }).items)
+ ? ((data as { items: Array<{ id: number; name: string; description: string }> }).items || [])
+ : [];
+
+ setSkillOptions(
+ items.map((item) => ({
+ id: String(item.id),
+ name: item.name,
+ desc: item.description,
+ })),
+ );
+ } catch (error) {
+ console.error('skills接口异常:', error);
+ setSkillOptions([]);
+ } finally {
+ setIsSkillLoading(false);
+ }
+ };
+
+ void fetchSkills();
+ }, [authedFetch, skillKeyword]);
+
+ useEffect(() => {
+ const fetchBoundSkills = async () => {
+ try {
+ const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/skills`, {
+ method: 'GET',
+ });
+
+ if (!res.ok) {
+ console.error('查询已绑定技能失败:', res.status, res.statusText);
+ return;
+ }
+
+ const data: unknown = await res.json();
+ const list = Array.isArray(data)
+ ? (data as Array<{ id: number; name: string; description: string }>).map((item) => ({
+ id: String(item.id),
+ name: item.name,
+ desc: item.description,
+ }))
+ : [];
+
+ setSelectedSkills(list.map((item) => item.id));
+ setBoundSkillMap(
+ list.reduce>((acc, item) => {
+ acc[item.id] = { name: item.name, desc: item.desc };
+ return acc;
+ }, {}),
+ );
+ } catch (error) {
+ console.error('查询已绑定技能异常:', error);
+ }
+ };
+
+ void fetchBoundSkills();
+ }, [authedFetch, agentId]);
+
+ useEffect(() => {
+ const fetchBoundTools = async () => {
+ try {
+ const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/tools`, {
+ method: 'GET',
+ });
+
+ if (!res.ok) {
+ console.error('查询已绑定内置工具失败:', res.status, res.statusText);
+ return;
+ }
+
+ const data: unknown = await res.json();
+ const list = Array.isArray(data)
+ ? (data as Array<{ id: number; name: string }>).map((item) => ({
+ id: String(item.id),
+ name: item.name,
+ }))
+ : [];
+
+ setSelectedTools(list.map((item) => item.name));
+ setBoundToolIdMap(
+ list.reduce>((acc, item) => {
+ acc[item.name] = item.id;
+ return acc;
+ }, {}),
+ );
+ } catch (error) {
+ console.error('查询已绑定内置工具异常:', error);
+ }
+ };
+
+ void fetchBoundTools();
+ }, [authedFetch, agentId]);
+
+ const refreshBoundTools = async () => {
+ try {
+ const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/tools`, {
+ method: 'GET',
+ });
+
+ if (!res.ok) {
+ console.error('刷新已绑定内置工具失败:', res.status, res.statusText);
+ return;
+ }
+
+ const data: unknown = await res.json();
+ const list = Array.isArray(data)
+ ? (data as Array<{ id: number; name: string }>).map((item) => ({ id: String(item.id), name: item.name }))
+ : [];
+
+ setSelectedTools(list.map((item) => item.name));
+ setBoundToolIdMap(
+ list.reduce>((acc, item) => {
+ acc[item.name] = item.id;
+ return acc;
+ }, {}),
+ );
+ } catch (error) {
+ console.error('刷新已绑定内置工具异常:', error);
+ }
+ };
+
+ const unbindTool = async (toolName: string) => {
+ const toolId = boundToolIdMap[toolName];
+ if (!toolId) return;
+
+ try {
+ const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/tools/${toolId}`, {
+ method: 'DELETE',
+ });
+
+ if (!res.ok) {
+ console.error('删除内置工具失败:', res.status, res.statusText);
+ return;
+ }
+
+ await refreshBoundTools();
+ } catch (error) {
+ console.error('删除内置工具异常:', error);
+ }
+ };
+
+ const bindSelectedTools = async () => {
+ setIsToolSubmitting(true);
+ try {
+ const toAdd = pendingTools.filter((name) => !selectedTools.includes(name));
+ for (const toolName of toAdd) {
+ const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/tools`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ tool_name: toolName }),
+ });
+
+ if (!res.ok) {
+ console.error('绑定内置工具失败:', res.status, res.statusText);
+ return;
+ }
+ }
+
+ await refreshBoundTools();
+ setToolDialogVisible(false);
+ } catch (error) {
+ console.error('绑定内置工具异常:', error);
+ } finally {
+ setIsToolSubmitting(false);
+ }
+ };
+
+ const bindSkill = async (skillId: string) => {
+ if (bindingSkillIds.includes(skillId)) return;
+
+ setBindingSkillIds((prev) => [...prev, skillId]);
+ try {
+ const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/skills`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ skill_id: Number(skillId) }),
+ });
+
+ if (!res.ok) {
+ console.error('绑定技能失败:', res.status, res.statusText);
+ return;
+ }
+
+ const refreshRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/skills`, {
+ method: 'GET',
+ });
+
+ if (!refreshRes.ok) {
+ console.error('刷新已绑定技能失败:', refreshRes.status, refreshRes.statusText);
+ return;
+ }
+
+ const refreshData: unknown = await refreshRes.json();
+ const refreshList = Array.isArray(refreshData)
+ ? (refreshData as Array<{ id: number; name: string; description: string }>).map((item) => ({
+ id: String(item.id),
+ name: item.name,
+ desc: item.description,
+ }))
+ : [];
+
+ setSelectedSkills(refreshList.map((item) => item.id));
+ setBoundSkillMap(
+ refreshList.reduce>((acc, item) => {
+ acc[item.id] = { name: item.name, desc: item.desc };
+ return acc;
+ }, {}),
+ );
+ } catch (error) {
+ console.error('绑定技能异常:', error);
+ } finally {
+ setBindingSkillIds((prev) => prev.filter((id) => id !== skillId));
+ }
+ };
+
+ const unbindSkill = async (skillId: string) => {
+ if (unbindingSkillIds.includes(skillId)) return;
+
+ setUnbindingSkillIds((prev) => [...prev, skillId]);
+ try {
+ const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/skills/${skillId}`, {
+ method: 'DELETE',
+ });
+
+ if (!res.ok) {
+ console.error('解除绑定技能失败:', res.status, res.statusText);
+ return;
+ }
+
+ const refreshRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}/skills`, {
+ method: 'GET',
+ });
+
+ if (!refreshRes.ok) {
+ console.error('刷新已绑定技能失败:', refreshRes.status, refreshRes.statusText);
+ return;
+ }
+
+ const refreshData: unknown = await refreshRes.json();
+ const refreshList = Array.isArray(refreshData)
+ ? (refreshData as Array<{ id: number; name: string; description: string }>).map((item) => ({
+ id: String(item.id),
+ name: item.name,
+ desc: item.description,
+ }))
+ : [];
+
+ setSelectedSkills(refreshList.map((item) => item.id));
+ setBoundSkillMap(
+ refreshList.reduce>((acc, item) => {
+ acc[item.id] = { name: item.name, desc: item.desc };
+ return acc;
+ }, {}),
+ );
+ } catch (error) {
+ console.error('解除绑定技能异常:', error);
+ } finally {
+ setUnbindingSkillIds((prev) => prev.filter((id) => id !== skillId));
+ }
+ };
+
+ const saveExpert = async () => {
+ setIsSaving(true);
+ try {
+ const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ name: formValues.name,
+ avatar_url: null,
+ description: formValues.description,
+ system_prompt: formValues.systemPrompt,
+ model_name: formValues.modelName,
+ }),
+ });
+
+ if (!res.ok) {
+ console.error('保存专家失败:', res.status, res.statusText);
+ return;
+ }
+ MessagePlugin.success('保存成功');
+ console.log('保存专家成功');
+ } catch (error) {
+ console.error('保存专家异常:', error);
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {messages.length === 0 ? (
+
+
+
+
+
+
你好,我是 {formValues.name || '未命名专家'}
+
创建你的专属 AI 助手
+
+
+ 想和你聊些什么呀
+
+
+
+ 告诉我你的想法
+ 给我一些建议
+ 帮我分析问题
+
+
+
+ ) : (
+ <>
+
+ >
+ )}
+
+
+ {showScrollButton && (
+
+ )}
+
+ void sendMessage()}
+ onUploadFile={(file) => void handleUploadFile(file)}
+ isSending={isStreaming}
+ isDarkMode={isDark}
+ showBorderTop
+ placeholder={isStreaming ? '正在思考中...' : '输入你的问题...'}
+ trailingActions={
+
+ }
+ className={isStreaming ? 'ring-1 ring-violet-300/40' : ''}
+ />
+
+
+
+ {/*
+ setTab(String(v))} size="medium" className="w-full">
+
+
+
+
*/}
+
+ {tab === 'preview' ? (
+ <>
+
+
+
+
+
+
你好,我是 {formValues.name || '未命名专家'}
+
创建你的专属 AI 助手
+
+
+ 想和你聊些什么呀
+
+
+
+ 告诉我你的想法
+ 给我一些建议
+ 帮我分析问题
+
+
+
+
+
+
+
+ >
+ }
+ />
+ >
+ ) : (
+
+
+
基本信息
+
+
+
+ setFormValues((prev) => ({ ...prev, name: String(value) }))}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
内置工具
+
{
+ setPendingTools(selectedTools);
+ setToolDialogVisible(true);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ setPendingTools(selectedTools);
+ setToolDialogVisible(true);
+ }
+ }}
+ className={`w-full min-h-[170px] rounded-xl border px-3 py-3 text-left cursor-pointer ${isDark ? 'border-slate-700 hover:border-slate-500' : 'border-gray-200 hover:border-gray-300'}`}
+ >
+
+ {selectedTools.map((toolName) => (
+ e.stopPropagation()}
+ >
+
+
+
+ {toolName}
+
+
+ ))}
+
+
+
+
+ 已选择 {selectedTools.length} 个工具
+
+
+
+
+
+
+
+
绑定的技能
+
为专家添加技能,增强其专业能力
+
+
+ {selectedSkills.map((skillId) => {
+ const skillName = boundSkillMap[skillId]?.name || skillOptions.find((item) => item.id === skillId)?.name || skillId;
+ return (
+
+ ✓
+ {skillName}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web-code/components/agent/ChatInputComposer.tsx b/web-code/components/agent/ChatInputComposer.tsx
new file mode 100644
index 00000000..ae72ac07
--- /dev/null
+++ b/web-code/components/agent/ChatInputComposer.tsx
@@ -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) => void;
+ placeholder?: string;
+ disabled?: boolean;
+ isSending?: boolean;
+ isDarkMode: boolean;
+ trailingActions?: ReactNode;
+ readOnly?: boolean;
+ className?: string;
+ showBorderTop?: boolean;
+ showUpload?: boolean;
+ onUploadFile?: (file: File) => void | Promise;
+}
+
+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(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 (
+
+ );
+}
diff --git a/web-code/components/agent/FileSkillsWorkshop.tsx b/web-code/components/agent/FileSkillsWorkshop.tsx
new file mode 100644
index 00000000..cb68f386
--- /dev/null
+++ b/web-code/components/agent/FileSkillsWorkshop.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [searchTerm, setSearchTerm] = useState('');
+
+ const [createOpen, setCreateOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [openCardMenuId, setOpenCardMenuId] = useState(null);
+
+ const cardMenuRef = useRef(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 (
+
+ {createOpen && (
+
setCreateOpen(false)}
+ onCreated={() => {
+ setCreateOpen(false);
+ void reload();
+ }}
+ />
+ )}
+
+ {editing && (
+ setEditing(null)}
+ onSaved={() => {
+ setEditing(null);
+ void reload();
+ }}
+ />
+ )}
+
+
+
+
+
+ 技能管理
+
+
+ 上传 Markdown 提示词或 Python 脚本作为技能,供 MCP 与模型调用。
+
+
+
+
+
+
+
+
+
+ {loading ? (
+
+ 正在加载技能...
+
+ ) : error ? (
+
+ ) : filtered.length === 0 ? (
+
+
+
+
+
+ 暂无技能
+
+
+ 点击右上角「新建技能」上传你的第一个技能。
+
+
+ ) : (
+
+ {filtered.map((skill) => {
+ const isPy = skill.kind === 'py';
+ const Icon = isPy ? FileCode2 : FileText;
+ return (
+
+
+
+
+
+
+ {openCardMenuId === skill.folder && (
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {skill.name}
+
+
+ {skill.description || '暂无描述'}
+
+
+
+
+
+
+ {isPy ? 'Python' : 'Markdown'}
+
+ {new Date(skill.updated_at * 1000).toLocaleString()}
+
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Create modal
+// ---------------------------------------------------------------------------
+
+interface CreateModalProps {
+ isDark: boolean;
+ onClose: () => void;
+ onCreated: () => void;
+}
+
+function CreateSkillModal({ isDark, onClose, onCreated }: CreateModalProps) {
+ const [file, setFile] = useState(null);
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [errMsg, setErrMsg] = useState('');
+ const inputRef = useRef(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 (
+ {
+ if (event.target === event.currentTarget) onClose();
+ }}
+ >
+
+
+
+
+
新建技能
+
+
+
+
+
+
+
handleFileChange(event.target.files?.[0] ?? null)}
+ />
+
+
+
+
+
+ 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]'
+ }`}
+ />
+
+
+
+
+
+
+ {errMsg && (
+
{errMsg}
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 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(null);
+ const [submitting, setSubmitting] = useState(false);
+ const [errMsg, setErrMsg] = useState('');
+ const inputRef = useRef(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 (
+ {
+ if (event.target === event.currentTarget) onClose();
+ }}
+ >
+
+
+
+
+
编辑技能
+
+
+
+
+
+
+ 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]'
+ }`}
+ />
+
+
+
+
+
+
+
+
+
setFile(event.target.files?.[0] ?? null)}
+ />
+
+
+
+ {errMsg &&
{errMsg}
}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web-code/components/agent/SkillCreatorConversationItem.tsx b/web-code/components/agent/SkillCreatorConversationItem.tsx
new file mode 100644
index 00000000..c8fb7554
--- /dev/null
+++ b/web-code/components/agent/SkillCreatorConversationItem.tsx
@@ -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;
+ 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 (
+
+
+ {role === 'assistant' && active && (
+
+ )}
+
+ );
+}
+
+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 (
+
+
+
+
+ {steps.map((step, index) => {
+ const done = step.status === 'done';
+
+ return (
+
+
+ {done ? (
+
+ ) : (
+
+ )}
+
+
+
+ 第 {index + 1} 步:{step.text}
+
+ {step.subText && (
+
+ {step.subText}
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+ );
+}
+
+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 (
+
+
+ {hasDetails && (
+
+
+ {details.input && (
+
+
请求参数
+
{details.input}
+
+ )}
+ {details.output && (
+
+
返回结果
+
{details.output}
+
+ )}
+
+
+ )}
+
+ );
+}
+
+export default function SkillCreatorConversationItem({ message, isConnecting = false }: SkillCreatorConversationItemProps) {
+ const { theme } = useTheme();
+ const isDarkMode = theme === 'dark';
+
+ if (message.role === 'user') {
+ return (
+
+
+ {message.content}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {message.steps && message.steps.length > 0 &&
}
+ {message.thinkings?.map((thinking, index) => (
+
+ ))}
+ {message.toolCalls?.map((toolCall) => (
+
+ ))}
+ {isConnecting && (
+
+
+
+
+
+ 正在连接技能服务...
+
+
+ )}
+ {message.content && (
+
+ )}
+
+
+ );
+}
diff --git a/web-code/components/agent/SkillCreatorPage.tsx b/web-code/components/agent/SkillCreatorPage.tsx
new file mode 100644
index 00000000..26379e4f
--- /dev/null
+++ b/web-code/components/agent/SkillCreatorPage.tsx
@@ -0,0 +1,1085 @@
+import { useEffect, useRef, useState } from 'react';
+import { AlertCircle, ChevronDown, ChevronRight, FileText, FolderOpen, Loader2, MessageSquareText, PackagePlus, Save, Wrench, X, Zap } from 'lucide-react';
+import { useSearchParams } from 'react-router-dom';
+import { useTheme } from '../../contexts/ThemeContext';
+import { useExpertAuthBootstrap } from '../../hooks';
+import MarkdownRenderer from '../MarkdownRenderer';
+import AgentConversationList, { type ConversationMessage, type ConversationStep } from './AgentConversationList';
+import ChatInputComposer from './ChatInputComposer';
+import SkillCreatorThreadSidebar from './SkillCreatorThreadSidebar';
+import { useNavigate } from 'react-router-dom';
+import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
+
+const MOCK_CONVERSATION_MESSAGES: ConversationMessage[] = [];
+const MIN_CHAT_WIDTH = 360;
+const MIN_FILE_SIDEBAR_WIDTH = 420;
+const DEFAULT_FILE_SIDEBAR_WIDTH = 560;
+const THREAD_STORAGE_KEY = 'skill_creator_thread_id';
+
+interface FileNode {
+ name: string;
+ path: string;
+ type: 'file' | 'directory';
+ size: number | null;
+ extension: string | null;
+ children: FileNode[] | null;
+}
+
+interface HistoryMessageApiItem {
+ created_at: string;
+ id: number;
+ role: 'user' | 'assistant';
+ content: unknown;
+ thread_id: number;
+}
+
+const formatFileSize = (size: number | null) => {
+ if (!size) return '';
+ if (size < 1024) return `${size} B`;
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
+ return `${(size / 1024 / 1024).toFixed(1)} MB`;
+};
+
+const getFileLanguage = (extension: string | null) => {
+ const normalized = extension?.replace('.', '').toLowerCase();
+ if (!normalized) return 'TEXT';
+ return normalized.toUpperCase();
+};
+
+const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null;
+
+const normalizeHistoryContent = (content: unknown) => {
+ const steps: ConversationStep[] = [];
+ const thinkings: Array<{ duration: number; content: string }> = [];
+ const texts: string[] = [];
+
+ const visit = (value: unknown) => {
+ if (typeof value === 'string') {
+ if (value.trim()) texts.push(value);
+ return;
+ }
+
+ if (Array.isArray(value)) {
+ value.forEach(visit);
+ return;
+ }
+
+ if (!isRecord(value)) return;
+
+ const type = typeof value.type === 'string' ? value.type : '';
+ const text = typeof value.text === 'string' ? value.text : '';
+
+ if (type === 'thinking-steps' && Array.isArray(value.steps)) {
+ value.steps.forEach((stepItem) => {
+ if (!isRecord(stepItem)) return;
+ const title = typeof stepItem.title === 'string' ? stepItem.title : '处理中';
+ const status = stepItem.status === 'completed' ? 'done' : 'processing';
+ const items = Array.isArray(stepItem.items) ? stepItem.items.filter((item): item is string => typeof item === 'string') : [];
+ steps.push({
+ text: title,
+ subText: items[0]?.replace('正在处理: ', ''),
+ status,
+ });
+ });
+ return;
+ }
+
+ if (type === 'reasoning' && text) {
+ const duration = typeof value.duration === 'number' ? value.duration / 1000 : 0;
+ thinkings.push({ duration, content: text });
+ return;
+ }
+
+ if (text) {
+ texts.push(text);
+ return;
+ }
+
+ if ('content' in value) {
+ visit(value.content);
+ }
+ };
+
+ visit(content);
+
+ return {
+ content: texts.join('\n\n').trim(),
+ steps,
+ thinkings,
+ };
+};
+
+const toConversationMessage = (message: HistoryMessageApiItem): ConversationMessage => {
+ const normalized = normalizeHistoryContent(message.content);
+
+ return {
+ id: String(message.id),
+ role: message.role,
+ content: normalized.content,
+ steps: normalized.steps,
+ thinkings: normalized.thinkings,
+ toolCalls: [],
+ };
+};
+
+interface FileTreeItemProps {
+ node: FileNode;
+ depth?: number;
+ isDarkMode: boolean;
+ expandedPaths: Set;
+ selectedPath: string | null;
+ onToggle: (path: string) => void;
+ onSelect: (node: FileNode) => void;
+}
+
+function FileTreeItem({
+ node,
+ depth = 0,
+ isDarkMode,
+ expandedPaths,
+ selectedPath,
+ onToggle,
+ onSelect,
+}: FileTreeItemProps) {
+ const isFolder = node.extension === null;
+ const isExpanded = expandedPaths.has(node.path);
+ const isSelected = selectedPath === node.path;
+ const hasChildren = Boolean(node.children?.length);
+
+ return (
+
+
+ {isFolder && isExpanded && hasChildren && (
+
+ {node.children!.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default function SkillCreatorPage() {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const { theme } = useTheme();
+ const { authedFetch } = useExpertAuthBootstrap();
+ const isDarkMode = theme === 'dark';
+ const queryThreadId = searchParams.get('id');
+ const [currentThreadId, setCurrentThreadId] = useState(() => queryThreadId || localStorage.getItem(THREAD_STORAGE_KEY) || '');
+ const [messages, setMessages] = useState(MOCK_CONVERSATION_MESSAGES);
+ const [inputValue, setInputValue] = useState('');
+ const [isStreaming, setIsStreaming] = useState(false);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [isThreadSidebarOpen, setIsThreadSidebarOpen] = useState(false);
+ const [fileList, setFileList] = useState([]);
+ const [fileListThreadId, setFileListThreadId] = useState(null);
+ const [isFileSidebarOpen, setIsFileSidebarOpen] = useState(false);
+ const [fileSidebarWidth, setFileSidebarWidth] = useState(DEFAULT_FILE_SIDEBAR_WIDTH);
+ const [expandedPaths, setExpandedPaths] = useState>(() => new Set());
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [filePreviewContent, setFilePreviewContent] = useState('');
+ const [isFilePreviewLoading, setIsFilePreviewLoading] = useState(false);
+ const [filePreviewError, setFilePreviewError] = useState('');
+ const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
+ const [skillName, setSkillName] = useState('');
+ const [skillDesc, setSkillDesc] = useState('');
+ const scrollContainerRef = useRef(null);
+ const thinkingStartRef = useRef(0);
+ const abortControllerRef = useRef(null);
+ const navigate = useNavigate();
+ useEffect(() => {
+ const nextThreadId = queryThreadId || localStorage.getItem(THREAD_STORAGE_KEY) || '';
+ setCurrentThreadId((prev) => (prev === nextThreadId ? prev : nextThreadId));
+ }, [queryThreadId]);
+
+ useEffect(() => {
+ if (!scrollContainerRef.current) return;
+ scrollContainerRef.current.scrollTo({
+ top: scrollContainerRef.current.scrollHeight,
+ behavior: 'smooth',
+ });
+ }, [messages]);
+
+ const syncThreadId = (nextThreadId: string) => {
+ setCurrentThreadId(nextThreadId);
+ setSearchParams((prev) => {
+ const nextParams = new URLSearchParams(prev);
+ if (nextThreadId) nextParams.set('id', nextThreadId);
+ else nextParams.delete('id');
+ return nextParams;
+ });
+
+ if (nextThreadId) {
+ localStorage.setItem(THREAD_STORAGE_KEY, nextThreadId);
+ } else {
+ localStorage.removeItem(THREAD_STORAGE_KEY);
+ }
+ };
+
+ const fetchThreadFiles = async (id: number) => {
+ if (!id) {
+ setFileList([]);
+ setFileListThreadId(null);
+ return;
+ }
+
+ try {
+ const filesRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${id}/files/all`, {
+ method: 'GET',
+ });
+
+ if (!filesRes.ok) {
+ setFileList([]);
+ setFileListThreadId(null);
+ return;
+ }
+
+ const filesData: unknown = await filesRes.json();
+ setFileList(Array.isArray(filesData) ? (filesData as FileNode[]) : []);
+ setFileListThreadId(id);
+ } catch (error) {
+ console.error('获取技能文件列表失败', error);
+ setFileList([]);
+ setFileListThreadId(null);
+ }
+ };
+
+ const fetchThreadMessages = async (id: number) => {
+ setIsConnecting(true);
+ try {
+ const response = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${id}/messages`, {
+ method: 'GET',
+ });
+
+ if (!response.ok) {
+ throw new Error(`获取会话消息失败: ${response.status}`);
+ }
+
+ const data = (await response.json()) as HistoryMessageApiItem[];
+ const nextMessages = Array.isArray(data) ? data.map(toConversationMessage) : [];
+ setMessages(nextMessages);
+ } catch (error) {
+ console.error('获取技能创建会话消息失败', error);
+ setMessages([]);
+ } finally {
+ setIsConnecting(false);
+ }
+ };
+
+ useEffect(() => {
+ const id = Number(currentThreadId);
+
+ setSelectedFile(null);
+ setFilePreviewContent('');
+ setFilePreviewError('');
+ setExpandedPaths(new Set());
+
+ if (!id) {
+ setMessages([]);
+ setFileList([]);
+ setFileListThreadId(null);
+ return;
+ }
+
+ void fetchThreadMessages(id);
+ void fetchThreadFiles(id);
+ }, [currentThreadId]);
+
+ const emptyChange = () => {
+ navigate('/skills-workshop')
+ }
+
+ useEffect(() => {
+ if (!fileList.length) return;
+ setExpandedPaths((prev) => {
+ if (prev.size > 0) return prev;
+ const firstLevelFolders = fileList.filter((node) => node.extension === null).map((node) => node.path);
+ return new Set(firstLevelFolders);
+ });
+ }, [fileList]);
+
+ const clearLoadingStates = () => {
+ setMessages((prev) =>
+ prev.map((msg) => {
+ if (msg.role === 'assistant') {
+ return {
+ ...msg,
+ steps: [],
+ thinkings: [],
+ toolCalls: [],
+ };
+ }
+ return msg;
+ }),
+ );
+ };
+
+ const handleToggleFolder = (path: string) => {
+ setExpandedPaths((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) next.delete(path);
+ else next.add(path);
+ return next;
+ });
+ };
+
+ const handleSelectFile = async (file: FileNode) => {
+ if (!fileListThreadId) {
+ setFilePreviewError('当前文件列表还没有关联到可预览的会话');
+ return;
+ }
+
+ setSelectedFile(file);
+ setFilePreviewContent('');
+ setFilePreviewError('');
+ setIsFilePreviewLoading(true);
+
+ try {
+ const previewRes = await authedFetch(
+ `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${fileListThreadId}/files/preview?path=${encodeURIComponent(file.path)}`,
+ { method: 'GET' },
+ );
+
+ if (!previewRes.ok) {
+ setFilePreviewError(`文件预览获取失败,状态码 ${previewRes.status}`);
+ return;
+ }
+
+ const contentType = previewRes.headers.get('content-type') ?? '';
+ if (contentType.includes('application/json')) {
+ const data: unknown = await previewRes.json();
+ if (typeof data === 'string') {
+ setFilePreviewContent(data);
+ } else if (data && typeof data === 'object' && 'content' in data) {
+ setFilePreviewContent(String((data as { content: unknown }).content ?? ''));
+ } else {
+ setFilePreviewContent(JSON.stringify(data, null, 2));
+ }
+ } else {
+ setFilePreviewContent(await previewRes.text());
+ }
+ } catch (error) {
+ console.error('获取技能文件预览失败', error);
+ setFilePreviewError('文件预览获取失败,请稍后重试');
+ } finally {
+ setIsFilePreviewLoading(false);
+ }
+ };
+
+ const handleResizeStart = (event: React.MouseEvent) => {
+ event.preventDefault();
+ const startX = event.clientX;
+ const startWidth = fileSidebarWidth;
+
+ const handleMouseMove = (moveEvent: MouseEvent) => {
+ const containerWidth = window.innerWidth - 160;
+ const maxWidth = Math.max(MIN_FILE_SIDEBAR_WIDTH, containerWidth - MIN_CHAT_WIDTH);
+ const nextWidth = Math.min(Math.max(startWidth - (moveEvent.clientX - startX), MIN_FILE_SIDEBAR_WIDTH), maxWidth);
+ setFileSidebarWidth(nextWidth);
+ };
+
+ const handleMouseUp = () => {
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ window.removeEventListener('mousemove', handleMouseMove);
+ window.removeEventListener('mouseup', handleMouseUp);
+ };
+
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ window.addEventListener('mousemove', handleMouseMove);
+ window.addEventListener('mouseup', handleMouseUp);
+ };
+
+ const handleSendMessage = async () => {
+ const userQuery = inputValue.trim();
+ if (!userQuery || !currentThreadId || isStreaming) return;
+
+ setInputValue('');
+ setIsStreaming(true);
+ setIsConnecting(true);
+ abortControllerRef.current = new AbortController();
+
+ const userMessage: ConversationMessage = {
+ id: `user-${Date.now()}`,
+ role: 'user',
+ content: userQuery,
+ };
+
+ const assistantId = `assistant-${Date.now()}`;
+ setMessages((prev) => [
+ ...prev,
+ userMessage,
+ { id: assistantId, role: 'assistant', content: '', steps: [], thinkings: [], toolCalls: [] },
+ ]);
+
+ try {
+ const streamRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/skills-agent`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ chat_id: Number(currentThreadId),
+ user_query: userQuery,
+ }),
+ signal: abortControllerRef.current.signal,
+ });
+
+ if (!streamRes.ok || !streamRes.body) {
+ console.error('技能对话流式接口失败', streamRes.status, streamRes.statusText);
+ return;
+ }
+
+ const reader = streamRes.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ const thinkingStepsAcc: Array<{ id: string; title: string; status: string; items: string[] }> = [];
+ const reasoningsAcc: Array<{ text: string; duration: number }> = [];
+ let assistantTextAcc = '';
+ let reasoningStartAt = 0;
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ setIsConnecting(false);
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() ?? '';
+
+ for (const line of lines) {
+ if (!line.startsWith('data: ')) continue;
+ const raw = line.slice(6).trim();
+ if (raw === '[DONE]') break;
+ if (!raw) continue;
+
+ let event: {
+ type: string;
+ data?: { id: string; title: string; status: string; items?: string[] };
+ id?: string;
+ delta?: string;
+ toolCallId?: string;
+ toolName?: string;
+ input?: Record;
+ output?: { result?: string } | unknown;
+ } | null = null;
+
+ try {
+ event = JSON.parse(raw) as typeof event;
+ } catch {
+ continue;
+ }
+
+ if (!event) continue;
+
+ switch (event.type) {
+ case 'data-thinking-step': {
+ const step = event.data!;
+ const stepData = {
+ id: step.id,
+ title: step.title,
+ status: step.status,
+ items: step.items ?? [],
+ };
+ const existingIdx = thinkingStepsAcc.findIndex((s) => s.id === step.id);
+ if (existingIdx >= 0) thinkingStepsAcc[existingIdx] = stepData;
+ else thinkingStepsAcc.push(stepData);
+ break;
+ }
+ case 'reasoning-start': {
+ reasoningStartAt = Date.now();
+ reasoningsAcc.push({ text: '', duration: 0 });
+ break;
+ }
+ case 'reasoning-delta': {
+ if (reasoningsAcc.length === 0) {
+ reasoningsAcc.push({ text: event.delta ?? '', duration: 0 });
+ } else {
+ reasoningsAcc[reasoningsAcc.length - 1].text += event.delta ?? '';
+ }
+ break;
+ }
+ case 'reasoning-end': {
+ if (reasoningsAcc.length > 0) {
+ reasoningsAcc[reasoningsAcc.length - 1].duration = Date.now() - reasoningStartAt;
+ }
+ break;
+ }
+ case 'text-delta': {
+ assistantTextAcc += event.delta ?? '';
+ break;
+ }
+ }
+
+ setMessages((prev) =>
+ prev.map((msg) => {
+ if (msg.id !== assistantId) return msg;
+
+ switch (event!.type) {
+ case 'data-thinking-step': {
+ const step = event!.data!;
+ const newStep = {
+ text: step.title,
+ subText: step.items?.[0]?.replace('正在处理: ', '') ?? undefined,
+ status: (step.status === 'completed' ? 'done' : 'processing') as 'done' | 'processing',
+ };
+ const steps = [...(msg.steps ?? [])];
+ const stepIdx = steps.findIndex((item) => item.text === step.title);
+ if (stepIdx >= 0) steps[stepIdx] = newStep;
+ else steps.push(newStep);
+ return { ...msg, steps };
+ }
+ case 'reasoning-start': {
+ thinkingStartRef.current = Date.now();
+ return { ...msg, thinkings: [...(msg.thinkings ?? []), { duration: 0, content: '' }] };
+ }
+ case 'reasoning-delta': {
+ const thinkings = [...(msg.thinkings ?? [])];
+ if (thinkings.length === 0) {
+ thinkings.push({ duration: 0, content: event!.delta ?? '' });
+ } else {
+ const last = thinkings[thinkings.length - 1];
+ thinkings[thinkings.length - 1] = { ...last, content: last.content + (event!.delta ?? '') };
+ }
+ return { ...msg, thinkings };
+ }
+ case 'reasoning-end': {
+ const duration = (Date.now() - thinkingStartRef.current) / 1000;
+ const thinkings = [...(msg.thinkings ?? [])];
+ if (thinkings.length > 0) {
+ thinkings[thinkings.length - 1] = { ...thinkings[thinkings.length - 1], duration };
+ }
+ return { ...msg, thinkings };
+ }
+ case 'tool-input-available': {
+ const toolCalls = [...(msg.toolCalls ?? [])];
+ const tcIdx = toolCalls.findIndex((toolCall) => toolCall.id === event!.toolCallId);
+ const newToolCall = { id: event!.toolCallId!, name: event!.toolName!, input: event!.input };
+ if (tcIdx >= 0) toolCalls[tcIdx] = { ...toolCalls[tcIdx], ...newToolCall };
+ else toolCalls.push(newToolCall);
+ return { ...msg, toolCalls };
+ }
+ case 'tool-output-available': {
+ const toolCalls = [...(msg.toolCalls ?? [])];
+ const tcIdx = toolCalls.findIndex((toolCall) => toolCall.id === event!.toolCallId);
+ if (tcIdx >= 0) {
+ const rawOutput = event!.output;
+ const outputStr =
+ typeof rawOutput === 'object' && rawOutput !== null && 'result' in rawOutput
+ ? String((rawOutput as { result: unknown }).result)
+ : JSON.stringify(rawOutput);
+ toolCalls[tcIdx] = { ...toolCalls[tcIdx], output: outputStr };
+ }
+ return { ...msg, toolCalls };
+ }
+ case 'text-delta': {
+ return { ...msg, content: msg.content + (event!.delta ?? '') };
+ }
+ default:
+ return msg;
+ }
+ }),
+ );
+ }
+ }
+
+ const persistedContent: Array> = [];
+ if (thinkingStepsAcc.length > 0) {
+ persistedContent.push({ type: 'thinking-steps', steps: thinkingStepsAcc });
+ }
+ for (const reasoning of reasoningsAcc) {
+ if (reasoning.text) {
+ persistedContent.push({ type: 'reasoning', text: reasoning.text, duration: reasoning.duration });
+ }
+ }
+ if (assistantTextAcc) {
+ persistedContent.push({ type: 'text', text: assistantTextAcc });
+ }
+
+ if (persistedContent.length > 0) {
+ const persistRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/messages`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ thread_id: Number(currentThreadId),
+ role: 'assistant',
+ content: persistedContent,
+ }),
+ });
+ if (!persistRes.ok) {
+ console.error('保存助手消息失败:', persistRes.status, persistRes.statusText);
+ }
+ }
+
+ await fetchThreadFiles(Number(currentThreadId));
+ } catch (streamError) {
+ if (streamError instanceof Error && streamError.name === 'AbortError') {
+ console.log('技能对话已手动停止');
+ } else {
+ console.error('技能对话异常', streamError);
+ }
+ } finally {
+ setIsStreaming(false);
+ setIsConnecting(false);
+ abortControllerRef.current = null;
+ }
+ };
+
+ const handleStopStream = () => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ setIsStreaming(false);
+ setIsConnecting(false);
+ clearLoadingStates();
+ }
+ };
+
+ const handleUploadFile = async (file: File) => {
+ if (!currentThreadId) return;
+
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const response = await authedFetch(
+ `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${currentThreadId}/files/upload`,
+ {
+ method: 'POST',
+ body: formData,
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`上传失败: ${response.status} ${response.statusText}`);
+ }
+
+ await fetchThreadFiles(Number(currentThreadId));
+ } catch (error) {
+ console.error('上传技能会话文件失败', error);
+ }
+ };
+
+ const handleThreadSelect = (threadId: string) => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ setIsStreaming(false);
+ setIsConnecting(false);
+ clearLoadingStates();
+ }
+
+ syncThreadId(threadId);
+ };
+
+ return (
+ <>
+
+
+
+ {/*
+
skill creator
+ {currentThreadId && (
+
会话 ID: {currentThreadId}
+ )}
+
*/}
+
+
+
+
+
+
+
+
+
+
setIsThreadSidebarOpen(false)}
+ onThreadSelect={handleThreadSelect}
+ />
+
+
+
+ {messages.length === 0 && !isConnecting ? (
+
+
+
+
+
+ skill-creator
+
+
+ 专注于 Agent Skills 的创建、重构和生产化落地的专家代理,适合用来创建新技能、优化 SKILL.md、补充脚本素材,或整理技能交付流程。
+
+
+ {[
+ { icon:
, label: '创建一个新技能', prompt: '我想创建一个新技能,请帮我开始。' },
+ { icon:
, label: '优化 SKILL.md 文档', prompt: '请帮我优化当前技能的 SKILL.md 文档质量。' },
+ { icon:
, label: '添加可复用脚本', prompt: '请帮我为当前技能添加可复用的脚本或参考资源。' },
+ { icon:
, label: '打包并发布技能', prompt: '请帮我配置验证和打包流程,准备发布 .skill 产物。' },
+ ].map(({ icon, label, prompt }) => (
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+
void handleSendMessage()}
+ onStop={handleStopStream}
+ onUploadFile={(file) => void handleUploadFile(file)}
+ disabled={!currentThreadId}
+ isSending={isStreaming}
+ isDarkMode={isDarkMode}
+ showBorderTop
+ placeholder={!currentThreadId ? '请先从聊天记录中选择或新建一个会话' : isStreaming ? '正在生成中...' : '输入你的问题...'}
+ />
+
+
+ {isFileSidebarOpen && (
+
+ )}
+
+
+
+ {/* setIsSaveModalOpen(false)}
+ title="保存技能"
+ width={520}
+ body={
+
+
+
+ setSkillName(e.target.value)}
+ placeholder="例如:自动写周报"
+ className={`h-9 w-full rounded-lg border px-3 text-sm outline-none transition ${
+ isDarkMode
+ ? 'border-slate-700 bg-slate-900 text-slate-100 placeholder:text-slate-500 focus:border-blue-500'
+ : 'border-gray-300 bg-white text-gray-800 placeholder:text-gray-400 focus:border-blue-500'
+ }`}
+ />
+
+
+
+
+
+ 提示:按住顶部标题栏可拖动弹窗,按 ESC 或点击遮罩关闭。
+
+
+ }
+ confirmText="保存"
+ cancelText="取消"
+ onConfirm={() => {
+ console.log('保存技能:', { skillName, skillDesc });
+ setIsSaveModalOpen(false);
+ }}
+ /> */}
+ >
+ );
+}
diff --git a/web-code/components/agent/SkillCreatorThreadSidebar.tsx b/web-code/components/agent/SkillCreatorThreadSidebar.tsx
new file mode 100644
index 00000000..e95fdf72
--- /dev/null
+++ b/web-code/components/agent/SkillCreatorThreadSidebar.tsx
@@ -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[0], init?: Parameters[1]) => Promise;
+ currentThreadId: string;
+ isDarkMode: boolean;
+ isOpen: boolean;
+ isBusy?: boolean;
+ createThreadPayload?: Record;
+ 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([]);
+ const [archivedThreads, setArchivedThreads] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+ const [deletingThreadId, setDeletingThreadId] = useState(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) => (
+
+
+
+ {archived ?
:
}
+
{title}
+
+
{items.length}
+
+
+ {items.length === 0 ? (
+
+ {emptyText}
+
+ ) : (
+
+ {items.map((thread) => {
+ const isActive = currentThreadId === String(thread.id);
+ const isDeleting = deletingThreadId === thread.id;
+
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+
+ return (
+
+ );
+}
diff --git a/web-code/components/agent/SkillsWorkshop.tsx b/web-code/components/agent/SkillsWorkshop.tsx
new file mode 100644
index 00000000..f7c5d4d6
--- /dev/null
+++ b/web-code/components/agent/SkillsWorkshop.tsx
@@ -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 = {
+ 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('all');
+ const [skills, setSkills] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
+ const [isCreatingSkillThread, setIsCreatingSkillThread] = useState(false);
+ const [openCardMenuId, setOpenCardMenuId] = useState(null);
+ const [editingSkill, setEditingSkill] = useState(null);
+ const [addingSkillId, setAddingSkillId] = useState(null);
+ const [isUploading, setIsUploading] = useState(false);
+ const [isCreatingByUpload, setIsCreatingByUpload] = useState(false);
+ const [isAiEditing, setIsAiEditing] = useState(false);
+
+ const createMenuRef = useRef(null);
+ const cardMenuRef = useRef(null);
+ const uploadInputRef = useRef(null);
+ const createUploadInputRef = useRef(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>((acc, category) => {
+ acc[category] = category === 'all' ? skills.length : skills.filter((skill) => skill.category === category).length;
+ return acc;
+ }, {} as Record),
+ [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 (
+
+ {editingSkill && (
+
{
+ if (event.target === event.currentTarget) setEditingSkill(null);
+ }}
+ >
+
+
+
编辑技能
+
+
+
+
+ 选择编辑方式:{editingSkill.name}
+
+
+
+
+
+
+
+
{
+ const file = event.target.files?.[0];
+ if (file && editingSkill) {
+ void handleUploadSkillFile(file, editingSkill.id);
+ }
+ }}
+ />
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
技能工坊
+
统一管理系统中的技能资产,支持发现、接入、发布与编辑。
+
+
+
+
+
+ {isCreateMenuOpen && (
+
+
+
+
{
+ const file = event.target.files?.[0];
+ if (file) void handleCreateSkillByUpload(file);
+ }}
+ />
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ 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]'}`}
+ />
+
+
+
+
+
+
+
+
+
+
+ {/*
+ 按类型筛选:
+ {categoryOrder.slice(1).map((category) => {
+ const active = selectedCategory === category;
+ return (
+
+ );
+ })}
+
*/}
+
+
+
+
+ {loading ? (
+
+ 正在加载技能...
+
+ ) : error ? (
+
+ ) : filteredSkills.length === 0 ? (
+
+
+
+
+
未找到匹配的技能
+
尝试调整搜索词或切换筛选条件。
+
+ ) : (
+
+ {filteredSkills.map((skill) => {
+ const avatar = getAvatarStyle(skill.id);
+ const AvatarIcon = avatar.Icon;
+ const categoryLabel = CATEGORY_LABELS[skill.category as CategoryKey] ?? '流程类';
+
+ return (
+
+
+ {!skill.isMine && selectedFilter === 'all' && (
+
+
+
+ )}
+
+ {skill.isMine && (
+
+
+
+ {openCardMenuId === skill.id && (
+
+
+
+
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+
{skill.name}
+
+ { skill.isMine ? '我的' : '官方' }
+
+
+
{skill.description}
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
diff --git a/web-code/components/agent/agentResponseParser.ts b/web-code/components/agent/agentResponseParser.ts
new file mode 100644
index 00000000..bb1a5088
--- /dev/null
+++ b/web-code/components/agent/agentResponseParser.ts
@@ -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 = /([\s\S]*?)<\/step>/g;
+const ANSWER_RE = /([\s\S]*?)<\/answer>/;
+const THINKING_COMMENT_RE = //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('');
+ if (start === -1) return { remainder: text, thinkBlock: '', closed: false };
+ const after = text.slice(start + ''.length);
+ const endIdx = after.indexOf('');
+ if (endIdx === -1) {
+ return {
+ remainder: text.slice(0, start),
+ thinkBlock: after,
+ closed: false,
+ };
+ }
+ return {
+ remainder: text.slice(0, start) + after.slice(endIdx + ''.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;
+ 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;
+ 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,
+ };
+};
diff --git a/web-code/components/agent/doc.md b/web-code/components/agent/doc.md
new file mode 100644
index 00000000..d0eb626c
--- /dev/null
+++ b/web-code/components/agent/doc.md
@@ -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)行为正确。
+
diff --git a/web-code/components/angentchat/AgentCardList.tsx b/web-code/components/angentchat/AgentCardList.tsx
new file mode 100644
index 00000000..c92bc3ca
--- /dev/null
+++ b/web-code/components/angentchat/AgentCardList.tsx
@@ -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([]);
+ const [skills, setSkills] = useState([]);
+ const [keyword, setKeyword] = useState('');
+ const [typeFilter, setTypeFilter] = useState('all');
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [notice, setNotice] = useState(null);
+
+ const [createOpen, setCreateOpen] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [createForm, setCreateForm] = useState(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 (
+
+
+
+
+ MCP 智能体配置
+
+
+ 管理内置与用户新增的 MCP,并支持绑定技能
+
+
+
+
+
+
+
+
+
+ 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'
+ }`}
+ />
+
+
+
+ {[
+ { key: 'all', label: '全部' },
+ { key: 'builtin', label: '内置 MCP' },
+ { key: 'custom', label: '用户新增' },
+ ].map((item) => {
+ const active = typeFilter === item.key;
+ return (
+
+ );
+ })}
+
+
+
+ {loading ?
加载中...
: null}
+ {error ?
{error}
: null}
+ {notice ?
{notice}
: null}
+
+ {!loading && !error ? (
+
+ {filteredAgents.map((agent) => (
+
+
+
+
+
+
+
+
+ {agent.name}
+
+
+ {agent.key}
+
+
+
+
+
+
+ {agent.type === 'builtin' ? '内置' : '自定义'}
+
+
+ #{agent.order + 1}
+
+
+
+
+
+ {agent.description || '暂无描述'}
+
+
+
+
+
+ 已绑定技能
+
+
+ {agent.bound_skills.length === 0 ? (
+ 未绑定
+ ) : (
+ agent.bound_skills.map((skillKey) => {
+ const skill = skills.find((item) => item.key === skillKey);
+ return (
+
+ {skill?.name || skillKey}
+
+ );
+ })
+ )}
+
+
+
+
+
+
+ {agent.deletable ? (
+
+ ) : null}
+
+
+ ))}
+
+ ) : null}
+
+ {!loading && !error && filteredAgents.length === 0 ? (
+
+ 没有匹配的 MCP
+
+ ) : null}
+
+ {createOpen ? (
+
{
+ if (event.target === event.currentTarget) {
+ closeCreateModal();
+ }
+ }}
+ >
+
+
+
+
新增 MCP
+
+ 输入 MCP 名称、详情并选择要绑定的技能
+
+
+
+
+
+
+
+
+ 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'
+ }`}
+ />
+
+
+
+
+
+
+
+
+
+
+ 已选 {selectedSkillCount} 项
+
+
+
+
+ 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'
+ }`}
+ />
+
+
+
+ {filteredSkills.length === 0 ? (
+
+ 没有匹配的技能
+
+ ) : (
+ filteredSkills.map((skill) => {
+ const checked = createForm.bound_skills.includes(skill.key);
+ return (
+
+ );
+ })
+ )}
+
+
+
+
+
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/web-code/components/angentchat/AgentChatMessageList.tsx b/web-code/components/angentchat/AgentChatMessageList.tsx
new file mode 100644
index 00000000..a378a032
--- /dev/null
+++ b/web-code/components/angentchat/AgentChatMessageList.tsx
@@ -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 = ({ 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 (
+
+
+
+ {role === 'assistant' && isActive && (
+
+ )}
+
+ );
+};
+
+interface CollapsibleProps {
+ title: React.ReactNode;
+ children: React.ReactNode;
+ defaultOpen?: boolean;
+ className?: string;
+}
+
+const Collapsible: React.FC = ({
+ title,
+ children,
+ defaultOpen = true,
+ className = '',
+}) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ const [open, setOpen] = useState(defaultOpen);
+
+ return (
+
+
+
+
+ );
+};
+
+interface AssistantAnswerProps {
+ steps: { title: string; content: string }[];
+ thinkingMarkdown: string;
+ answerMarkdown: string;
+ thinkingComplete: boolean;
+ streaming: boolean;
+}
+
+const AssistantAnswer: React.FC = ({
+ steps,
+ thinkingMarkdown,
+ answerMarkdown,
+ thinkingComplete,
+ streaming,
+}) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ const showThinkingSection = Boolean(thinkingMarkdown) || (!thinkingComplete && streaming);
+
+ return (
+
+ {steps.length > 0 && (
+
+
+
+
+
+
+ 步骤
+
+
+ 展示技能执行、上下文准备和模型调用进度
+
+
+
+ }
+ >
+
+ {steps.map((step, index) => (
+
+
+ {step.title}
+
+
+ {step.content}
+
+
+ ))}
+
+
+ )}
+
+ {showThinkingSection && (
+
+
+
+
+
+
+ 思考过程
+
+ {thinkingComplete ? (
+
+ 思考已完成,可展开查看
+
+ ) : (
+
+ 正在生成思考内容...
+
+ )}
+
+
+ }
+ >
+ {thinkingMarkdown ? (
+
+ ) : (
+
+
+ 等待模型输出思考内容...
+
+ )}
+
+ )}
+
+ {answerMarkdown ? (
+
+
+
+ ) : null}
+
+ );
+};
+
+interface MessageRowProps {
+ msg: AgentChatMsg;
+ streaming?: boolean;
+}
+
+const MessageRow: React.FC = ({ 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 (
+
+ );
+ }
+
+ return (
+
+
+
+ {parsed ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+};
+
+interface AgentChatMessageListProps {
+ messages: AgentChatMsg[];
+ streamingMsgId?: string | null;
+ agentName?: string;
+}
+
+const AgentChatMessageList: React.FC = ({
+ messages,
+ streamingMsgId,
+ agentName,
+}) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ if (messages.length === 0) {
+ return (
+
+
+
直接与 {agentName || 'MCP 智能体'} 对话以测试当前配置
+
已绑定技能会在步骤区显示实际执行过程
+
+ );
+ }
+
+ return (
+
+ {messages.map((msg, idx) => {
+ const isLast = idx === messages.length - 1;
+ const isStreaming = isLast && msg.role === 'assistant' && msg.id === streamingMsgId;
+ return ;
+ })}
+
+ );
+};
+
+export default AgentChatMessageList;
diff --git a/web-code/components/angentchat/AgentEditPage.tsx b/web-code/components/angentchat/AgentEditPage.tsx
new file mode 100644
index 00000000..248f759c
--- /dev/null
+++ b/web-code/components/angentchat/AgentEditPage.tsx
@@ -0,0 +1,1235 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ ArrowDown,
+ ArrowLeft,
+ Bot,
+ CheckCircle2,
+ History as HistoryIcon,
+ Plus,
+ Save,
+ Search,
+ Sparkles,
+ X,
+} from 'lucide-react';
+import { Input, Textarea } from 'tdesign-react';
+
+import { useTheme } from '../../contexts/ThemeContext';
+import ChatInputComposer from '../agent/ChatInputComposer';
+import AgentChatMessageList, { type AgentChatMsg } from './AgentChatMessageList';
+import SixStepChatHistory, { type SixStepConversationMeta } from './SixStepChatHistory';
+
+interface AgentDetail {
+ key: string;
+ type?: string;
+ deletable?: boolean;
+ name: string;
+ description: string;
+ structured_prompt: string;
+ chat_prompt: string;
+ default_prompt?: string;
+ default_model: string;
+ schema_hint: string;
+ bound_skills: string[];
+ order: number;
+}
+
+interface SkillOption {
+ key: string;
+ name: string;
+ description: string;
+ type: string;
+}
+
+interface ModelOption {
+ id: string;
+ name: string;
+ provider: string;
+ description?: string;
+}
+
+interface ModelsResponse {
+ default_model_id: string;
+ models: ModelOption[];
+}
+
+interface AgentEditPageProps {
+ agentKey: string;
+ onBack?: () => void;
+}
+
+interface ConversationMessage extends AgentChatMsg {
+ created_at?: number;
+}
+
+interface ConversationApiRecord {
+ id: string;
+ agent_key: string;
+ title: string;
+ model?: string | null;
+ created_at: number;
+ updated_at: number;
+ messages: Array<{
+ id: string;
+ role: 'user' | 'assistant';
+ content: string;
+ created_at?: number;
+ }>;
+}
+
+interface Conversation extends SixStepConversationMeta {
+ createdAt: number;
+ model?: string;
+ messages: ConversationMessage[];
+}
+
+interface AgentFormState {
+ name: string;
+ description: string;
+ structured_prompt: string;
+ chat_prompt: string;
+ default_model: string;
+}
+
+const normalizeConversation = (item: ConversationApiRecord): Conversation => ({
+ id: item.id,
+ title: item.title || '未命名对话',
+ model: item.model || '',
+ createdAt: Number(item.created_at || Date.now()),
+ updatedAt: Number(item.updated_at || Date.now()),
+ messages: (item.messages || []).map((message) => ({
+ id: message.id,
+ role: message.role,
+ content: message.content,
+ created_at: message.created_at,
+ })),
+});
+
+const sortConversations = (items: Conversation[]) =>
+ [...items].sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0));
+const backUrl = 'https://ch1.b.uat.4.cn/zncmback';
+export default function AgentEditPage({ agentKey, onBack }: AgentEditPageProps) {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ const [detail, setDetail] = useState(null);
+ const [skills, setSkills] = useState([]);
+ const [models, setModels] = useState([]);
+ const [globalDefaultModel, setGlobalDefaultModel] = useState('');
+ const [form, setForm] = useState({
+ name: '',
+ description: '',
+ structured_prompt: '',
+ chat_prompt: '',
+ default_model: '',
+ });
+ const [boundSkills, setBoundSkills] = useState([]);
+ const [chatModel, setChatModel] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [saveMsg, setSaveMsg] = useState(null);
+ const [pickerOpen, setPickerOpen] = useState(false);
+
+ const [historyOpen, setHistoryOpen] = useState(false);
+ const [historyVisible, setHistoryVisible] = useState(false);
+ const [conversations, setConversations] = useState([]);
+ const [activeId, setActiveId] = useState(null);
+ const [chatInput, setChatInput] = useState('');
+ const [isStreaming, setIsStreaming] = useState(false);
+ const [streamingMsgId, setStreamingMsgId] = useState(null);
+ const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
+
+ const abortRef = useRef(null);
+ const messageListRef = useRef(null);
+ const programmaticScrollRef = useRef(false);
+ const historyTimerRef = useRef(null);
+
+ const openHistoryPanel = useCallback(() => {
+ if (historyTimerRef.current) {
+ window.clearTimeout(historyTimerRef.current);
+ historyTimerRef.current = null;
+ }
+ setHistoryVisible(true);
+ window.requestAnimationFrame(() => {
+ window.requestAnimationFrame(() => {
+ setHistoryOpen(true);
+ });
+ });
+ }, []);
+
+ const closeHistoryPanel = useCallback(() => {
+ if (historyTimerRef.current) {
+ window.clearTimeout(historyTimerRef.current);
+ }
+ setHistoryOpen(false);
+ historyTimerRef.current = window.setTimeout(() => {
+ setHistoryVisible(false);
+ historyTimerRef.current = null;
+ }, 300);
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ if (historyTimerRef.current) {
+ window.clearTimeout(historyTimerRef.current);
+ }
+ };
+ }, []);
+
+ const showTransientMessage = (message: string) => {
+ setSaveMsg(message);
+ window.setTimeout(() => {
+ setSaveMsg((current) => (current === message ? null : current));
+ }, 2400);
+ };
+
+ const upsertConversationState = (conversation: Conversation) => {
+ setConversations((prev) => sortConversations([conversation, ...prev.filter((item) => item.id !== conversation.id)]));
+ };
+
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
+ const container = messageListRef.current;
+ if (!container) {
+ return;
+ }
+ programmaticScrollRef.current = true;
+ container.scrollTo({
+ top: container.scrollHeight,
+ behavior,
+ });
+ window.setTimeout(() => {
+ programmaticScrollRef.current = false;
+ }, behavior === 'smooth' ? 400 : 120);
+ }, []);
+
+ const fetchConversations = useCallback(async (): Promise => {
+ const res = await fetch(`${backUrl}/api/agents/${agentKey}/conversations`);
+ if (!res.ok) {
+ throw new Error(`加载聊天记录失败:${res.status}`);
+ }
+ const raw = (await res.json()) as ConversationApiRecord[];
+ return sortConversations(raw.map(normalizeConversation));
+ }, [agentKey]);
+
+ const createConversationOnServer = async (
+ title: string,
+ model?: string,
+ messages: ConversationMessage[] = [],
+ ): Promise => {
+ const res = await fetch(`${backUrl}/api/agents/${agentKey}/conversations`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title,
+ model: model || undefined,
+ messages,
+ }),
+ });
+ if (!res.ok) {
+ throw new Error(`创建对话失败:${res.status}`);
+ }
+ return normalizeConversation((await res.json()) as ConversationApiRecord);
+ };
+
+ const updateConversationOnServer = async (
+ conversationId: string,
+ payload: {
+ title?: string;
+ model?: string;
+ messages?: ConversationMessage[];
+ },
+ ): Promise => {
+ const res = await fetch(`${backUrl}/api/agents/${agentKey}/conversations/${conversationId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) {
+ throw new Error(`更新对话失败:${res.status}`);
+ }
+ return normalizeConversation((await res.json()) as ConversationApiRecord);
+ };
+
+ useEffect(() => {
+ let cancelled = false;
+
+ (async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const [agentRes, skillRes, modelRes, conversationList] = await Promise.all([
+ fetch(`${backUrl}/api/agents/${agentKey}`),
+ fetch(`${backUrl}/api/skills`),
+ fetch(`${backUrl}/api/models`),
+ fetchConversations(),
+ ]);
+
+ if (!agentRes.ok) {
+ throw new Error(`加载智能体失败:${agentRes.status}`);
+ }
+
+ const agentData = (await agentRes.json()) as AgentDetail;
+ const skillData = skillRes.ok ? ((await skillRes.json()) as SkillOption[]) : [];
+ const modelData = modelRes.ok
+ ? ((await modelRes.json()) as ModelsResponse)
+ : { default_model_id: '', models: [] };
+
+ if (cancelled) {
+ return;
+ }
+
+ const effectiveModel = agentData.default_model || modelData.default_model_id || '';
+ setDetail(agentData);
+ setSkills(skillData);
+ setModels(modelData.models || []);
+ setGlobalDefaultModel(modelData.default_model_id || '');
+ setForm({
+ name: agentData.name || '',
+ description: agentData.description || '',
+ structured_prompt: agentData.structured_prompt || agentData.default_prompt || '',
+ chat_prompt: agentData.chat_prompt || agentData.default_prompt || '',
+ default_model: effectiveModel,
+ });
+ setBoundSkills(agentData.bound_skills || []);
+ setConversations(conversationList);
+ setActiveId((current) => {
+ if (current && conversationList.some((item) => item.id === current)) {
+ return current;
+ }
+ return conversationList[0]?.id || null;
+ });
+ setChatModel((current) => current || effectiveModel);
+ } catch (e) {
+ if (!cancelled) {
+ setError(e instanceof Error ? e.message : '加载失败');
+ }
+ } finally {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [agentKey, fetchConversations]);
+
+ useEffect(() => {
+ if (autoScrollEnabled) {
+ scrollToBottom(isStreaming ? 'auto' : 'smooth');
+ }
+ }, [activeId, autoScrollEnabled, conversations, isStreaming, scrollToBottom]);
+
+ const skillNameMap = useMemo(() => {
+ const map: Record = {};
+ skills.forEach((skill) => {
+ map[skill.key] = skill.name;
+ });
+ return map;
+ }, [skills]);
+
+ const activeConversation = useMemo(
+ () => conversations.find((item) => item.id === activeId) || null,
+ [activeId, conversations],
+ );
+
+ useEffect(() => {
+ if (activeConversation?.model) {
+ setChatModel(activeConversation.model);
+ return;
+ }
+ if (form.default_model) {
+ setChatModel(form.default_model);
+ return;
+ }
+ if (globalDefaultModel) {
+ setChatModel(globalDefaultModel);
+ }
+ }, [activeConversation?.id, activeConversation?.model, form.default_model, globalDefaultModel]);
+
+ useEffect(() => {
+ setAutoScrollEnabled(true);
+ window.setTimeout(() => {
+ scrollToBottom('auto');
+ }, 0);
+ }, [activeId, scrollToBottom]);
+
+ const activeModelLabel = useMemo(() => {
+ const current = models.find((item) => item.id === chatModel);
+ return current?.name || chatModel || '跟随默认模型';
+ }, [chatModel, models]);
+
+ const removeBoundSkill = (key: string) => {
+ setBoundSkills((prev) => prev.filter((item) => item !== key));
+ };
+
+ const handleSave = async () => {
+ if (!detail) {
+ return;
+ }
+ setSaving(true);
+ setSaveMsg(null);
+ try {
+ const res = await fetch(`${backUrl}/api/agents/${detail.key}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: form.name,
+ description: form.description,
+ structured_prompt: form.structured_prompt,
+ chat_prompt: form.chat_prompt,
+ default_model: form.default_model,
+ bound_skills: boundSkills,
+ }),
+ });
+
+ if (!res.ok) {
+ throw new Error(`保存失败:${res.status}`);
+ }
+
+ const updated = (await res.json()) as AgentDetail;
+ setDetail(updated);
+ setForm((prev) => ({
+ ...prev,
+ name: updated.name || '',
+ description: updated.description || '',
+ structured_prompt: updated.structured_prompt || '',
+ chat_prompt: updated.chat_prompt || '',
+ default_model: updated.default_model || prev.default_model,
+ }));
+ setBoundSkills(updated.bound_skills || []);
+ setChatModel((current) => current || updated.default_model || '');
+ showTransientMessage('保存成功');
+ } catch (e) {
+ setSaveMsg(e instanceof Error ? e.message : '保存失败');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleNewConversation = async () => {
+ try {
+ const created = await createConversationOnServer('新对话', chatModel || form.default_model);
+ upsertConversationState(created);
+ setActiveId(created.id);
+ openHistoryPanel();
+ } catch (e) {
+ setSaveMsg(e instanceof Error ? e.message : '创建对话失败');
+ }
+ };
+
+ const handleDeleteConversation = async (id: string) => {
+ try {
+ const res = await fetch(`${backUrl}/api/agents/${agentKey}/conversations/${id}`, {
+ method: 'DELETE',
+ });
+ if (!res.ok) {
+ throw new Error(`删除对话失败:${res.status}`);
+ }
+ setConversations((prev) => {
+ const next = prev.filter((item) => item.id !== id);
+ setActiveId((current) => (current === id ? next[0]?.id || null : current));
+ return next;
+ });
+ } catch (e) {
+ setSaveMsg(e instanceof Error ? e.message : '删除对话失败');
+ }
+ };
+
+ const handleRenameConversation = async (id: string, title: string) => {
+ try {
+ const updated = await updateConversationOnServer(id, { title });
+ upsertConversationState(updated);
+ if (activeId === id) {
+ setActiveId(updated.id);
+ }
+ } catch (e) {
+ setSaveMsg(e instanceof Error ? e.message : '重命名失败');
+ }
+ };
+
+ const handleClearAllConversations = async () => {
+ try {
+ const res = await fetch(`${backUrl}/api/agents/${agentKey}/conversations`, {
+ method: 'DELETE',
+ });
+ if (!res.ok) {
+ throw new Error(`清空聊天记录失败:${res.status}`);
+ }
+ setConversations([]);
+ setActiveId(null);
+ showTransientMessage('聊天记录已清空');
+ } catch (e) {
+ setSaveMsg(e instanceof Error ? e.message : '清空聊天记录失败');
+ }
+ };
+
+ const handleStop = () => {
+ abortRef.current?.abort();
+ abortRef.current = null;
+ setIsStreaming(false);
+ setStreamingMsgId(null);
+ };
+
+ const handleMessageListScroll = () => {
+ const container = messageListRef.current;
+ if (!container || programmaticScrollRef.current) {
+ return;
+ }
+ const distanceToBottom =
+ container.scrollHeight - container.scrollTop - container.clientHeight;
+ const nearBottom = distanceToBottom <= 80;
+ setAutoScrollEnabled(nearBottom);
+ };
+
+ const handleResumeAutoScroll = () => {
+ setAutoScrollEnabled(true);
+ scrollToBottom('smooth');
+ };
+
+ const handleSend = async () => {
+ const text = chatInput.trim();
+ if (!text || !detail) {
+ return;
+ }
+
+ let conversation = activeConversation;
+ try {
+ if (!conversation) {
+ conversation = await createConversationOnServer(
+ text.slice(0, 16) || '新对话',
+ chatModel || form.default_model,
+ );
+ upsertConversationState(conversation);
+ setActiveId(conversation.id);
+ }
+ } catch (e) {
+ setSaveMsg(e instanceof Error ? e.message : '创建对话失败');
+ return;
+ }
+
+ const conversationId = conversation.id;
+ const previousMessages = conversation.messages.slice();
+ const title =
+ previousMessages.length === 0 ? text.slice(0, 16) || conversation.title || '新对话' : conversation.title;
+
+ const userMsg: ConversationMessage = {
+ id: `m-${Date.now()}-u`,
+ role: 'user',
+ content: text,
+ created_at: Date.now() / 1000,
+ };
+ const assistantMsg: ConversationMessage = {
+ id: `m-${Date.now()}-a`,
+ role: 'assistant',
+ content: '',
+ created_at: Date.now() / 1000 + 0.001,
+ };
+
+ let assistantContent = '';
+ const effectiveModel = chatModel || conversation.model || form.default_model || globalDefaultModel;
+ const optimisticMessages = [...previousMessages, userMsg, assistantMsg];
+ setAutoScrollEnabled(true);
+ upsertConversationState({
+ ...conversation,
+ title,
+ model: effectiveModel,
+ updatedAt: Date.now() / 1000,
+ messages: optimisticMessages,
+ });
+ setActiveId(conversationId);
+ setChatInput('');
+ setIsStreaming(true);
+ setStreamingMsgId(assistantMsg.id);
+
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ const appendDelta = (delta: string) => {
+ assistantContent += delta;
+ setConversations((prev) =>
+ prev.map((item) => {
+ if (item.id !== conversationId) {
+ return item;
+ }
+ const messages = item.messages.slice();
+ const idx = messages.findIndex((message) => message.id === assistantMsg.id);
+ if (idx !== -1) {
+ messages[idx] = {
+ ...messages[idx],
+ content: messages[idx].content + delta,
+ };
+ }
+ return { ...item, messages, updatedAt: Date.now() / 1000 };
+ }),
+ );
+ };
+
+ try {
+ const res = await fetch(`${backUrl}/api/agents/${detail.key}/chat/stream`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ message: text,
+ history: previousMessages.map((item) => ({ role: item.role, content: item.content })),
+ model: effectiveModel || undefined,
+ output_format: 'markdown',
+ }),
+ signal: controller.signal,
+ });
+
+ if (!res.ok || !res.body) {
+ throw new Error(`流式请求失败:${res.status}`);
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ buffer += decoder.decode(value, { stream: true });
+ let sepIdx = buffer.indexOf('\n\n');
+ while (sepIdx !== -1) {
+ const raw = buffer.slice(0, sepIdx).replace(/\r/g, '');
+ buffer = buffer.slice(sepIdx + 2);
+
+ let eventName = 'message';
+ let dataStr = '';
+ raw.split('\n').forEach((line) => {
+ if (line.startsWith('event:')) {
+ eventName = line.slice(6).trim();
+ } else if (line.startsWith('data:')) {
+ dataStr += line.slice(5).trim();
+ }
+ });
+
+ if (dataStr) {
+ try {
+ const data = JSON.parse(dataStr) as { text?: string; message?: string };
+ if (eventName === 'delta' && typeof data.text === 'string') {
+ appendDelta(data.text);
+ } else if (eventName === 'error' && data.message) {
+ appendDelta(`\n[错误] ${data.message}`);
+ }
+ } catch {
+ // ignore malformed SSE payloads
+ }
+ }
+
+ sepIdx = buffer.indexOf('\n\n');
+ }
+ }
+ } catch (err) {
+ if ((err as Error)?.name !== 'AbortError') {
+ const message = err instanceof Error ? err.message : '请求失败';
+ if (!assistantContent) {
+ assistantContent = `[错误] ${message}`;
+ }
+ setConversations((prev) =>
+ prev.map((item) => {
+ if (item.id !== conversationId) {
+ return item;
+ }
+ const messages = item.messages.slice();
+ const idx = messages.findIndex((messageItem) => messageItem.id === assistantMsg.id);
+ if (idx !== -1) {
+ messages[idx] = {
+ ...messages[idx],
+ content: messages[idx].content || `[错误] ${message}`,
+ };
+ }
+ return { ...item, messages, updatedAt: Date.now() / 1000 };
+ }),
+ );
+ }
+ } finally {
+ setIsStreaming(false);
+ setStreamingMsgId(null);
+ abortRef.current = null;
+
+ try {
+ const persisted = await updateConversationOnServer(conversationId, {
+ title,
+ model: effectiveModel || undefined,
+ messages: [
+ ...previousMessages,
+ userMsg,
+ {
+ ...assistantMsg,
+ content: assistantContent,
+ },
+ ],
+ });
+ upsertConversationState(persisted);
+ } catch (e) {
+ setSaveMsg(e instanceof Error ? e.message : '保存聊天记录失败');
+ }
+ }
+ };
+
+ if (loading) {
+ return 加载中...
;
+ }
+
+ if (error || !detail) {
+ return {error || '智能体不存在'}
;
+ }
+
+ return (
+
+ {pickerOpen && (
+
setPickerOpen(false)}
+ onConfirm={async (keys) => {
+ setBoundSkills(keys);
+ setPickerOpen(false);
+ if (!detail) {
+ return;
+ }
+ try {
+ const res = await fetch(`${backUrl}/api/agents/${detail.key}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ bound_skills: keys }),
+ });
+ if (!res.ok) {
+ throw new Error(`更新技能绑定失败:${res.status}`);
+ }
+ const updated = (await res.json()) as AgentDetail;
+ setBoundSkills(updated.bound_skills || []);
+ showTransientMessage('技能绑定已更新');
+ } catch (e) {
+ setSaveMsg(e instanceof Error ? e.message : '技能绑定失败');
+ }
+ }}
+ />
+ )}
+
+ {historyVisible ? (
+
+ ({
+ id: item.id,
+ title: item.title,
+ updatedAt: item.updatedAt,
+ }))}
+ activeId={activeId}
+ onClose={closeHistoryPanel}
+ onOpen={openHistoryPanel}
+ onSelect={(id) => setActiveId(id)}
+ onNew={() => void handleNewConversation()}
+ onDelete={(id) => void handleDeleteConversation(id)}
+ onRename={(id, title) => void handleRenameConversation(id, title)}
+ onClearAll={() => void handleClearAllConversations()}
+ />
+
+ ) : null}
+
+
+
+
+ {!historyOpen && (
+
+ )}
+
+
+
+
+
+
+ {form.name || detail.key}
+
+
+ {detail.key}
+
+
+
+
+
+
+
+ 聊天模型
+
+
+
+
+ {saveMsg && (
+
+ {saveMsg}
+
+ )}
+
+
+
+
+
+
+
+
+ {!autoScrollEnabled && (
+
+ )}
+
+
+
void handleSend()}
+ onStop={handleStop}
+ isSending={isStreaming}
+ isDarkMode={isDark}
+ showUpload={false}
+ placeholder={`给 ${form.name || detail.key} 发送消息...`}
+ showBorderTop
+ />
+
+
+
+
+ );
+}
+
+interface SkillPickerModalProps {
+ isDark: boolean;
+ skills: SkillOption[];
+ selectedKeys: string[];
+ onClose: () => void;
+ onConfirm: (keys: string[]) => void;
+}
+
+function SkillPickerModal({
+ isDark,
+ skills,
+ selectedKeys,
+ onClose,
+ onConfirm,
+}: SkillPickerModalProps) {
+ const [search, setSearch] = useState('');
+ const [draft, setDraft] = useState(selectedKeys);
+
+ const filtered = useMemo(() => {
+ const keyword = search.trim().toLowerCase();
+ if (!keyword) {
+ return skills;
+ }
+ return skills.filter((skill) =>
+ `${skill.name} ${skill.key} ${skill.description}`.toLowerCase().includes(keyword),
+ );
+ }, [search, skills]);
+
+ const toggle = (key: string) => {
+ setDraft((prev) => (prev.includes(key) ? prev.filter((item) => item !== key) : [...prev, key]));
+ };
+
+ return (
+ {
+ if (event.target === event.currentTarget) {
+ onClose();
+ }
+ }}
+ >
+
+
+
+
+
+
选择要绑定的技能
+
+ 已选 {draft.length} 项,共 {skills.length} 个可选技能
+
+
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ className={`flex-1 bg-transparent text-sm outline-none ${
+ isDark
+ ? 'text-slate-100 placeholder:text-slate-500'
+ : 'text-gray-800 placeholder:text-gray-400'
+ }`}
+ />
+
+
+
+
+ {filtered.length === 0 ? (
+
+ 没有匹配的技能
+
+ ) : (
+ filtered.map((skill) => {
+ const checked = draft.includes(skill.key);
+ return (
+
+ );
+ })
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web-code/components/angentchat/ExpertMatchCards.tsx b/web-code/components/angentchat/ExpertMatchCards.tsx
new file mode 100644
index 00000000..34306bae
--- /dev/null
+++ b/web-code/components/angentchat/ExpertMatchCards.tsx
@@ -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('
+ );
+ }
+ return (
+
+ {fallback}
+
+ );
+};
+
+const ExpertCardItem: React.FC = ({ 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 (
+
+ );
+};
+
+interface ExpertMatchCardsProps {
+ data: ExpertMatchData;
+ onSelectExpert: (id: string) => void;
+ onRefetch: () => void;
+ onCreateExpert: () => void;
+ onConfirm: () => void;
+ locked?: boolean;
+}
+
+const ExpertMatchCards: React.FC = ({
+ 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 (
+ 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"
+ />
+ }
+ >
+
+
+
+
+ 推荐专家列表
+ {cards.length} 位
+
+ }
+ >
+ {cards.length === 0 ? (
+
+ {isSearching && cards.length === 0 ? '正在匹配专家...' : '暂无匹配的专家'}
+
+ ) : (
+
+ {cards.map((card, idx) => (
+
+ ))}
+
+ )}
+
+
+ {data.status === 'error' && (
+
+ {data.errorMessage}
+
+ )}
+
+
+
+
+
+ 没有想要的专家?
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ExpertMatchCards;
diff --git a/web-code/components/angentchat/INTENT_INTEGRATION.md b/web-code/components/angentchat/INTENT_INTEGRATION.md
new file mode 100644
index 00000000..aae12cd8
--- /dev/null
+++ b/web-code/components/angentchat/INTENT_INTEGRATION.md
@@ -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
+```
+
+**内部逻辑概述**:
+
+```
+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('idle');
+
+// 已选专家和 MCP(用于请求体和 UI 展示)
+const [selectedExpert, setSelectedExpert] = useState(null);
+const [selectedMcps, setSelectedMcps] = useState([]);
+
+// 已推荐过的专家列表(reselect_expert 时用于过滤)
+const [expertCandidates, setExpertCandidates] = useState([]);
+
+// 临时保存原始用户输入(确认专家后需要)
+const [lastUserInput, setLastUserInput] = useState('');
+```
+
+### 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 (
+
+ {/* 【分支 1】专家匹配阶段 */}
+ {isExpertPhase && message.expertMatch && (
+
+ )}
+
+ {/* 【分支 2】6bf 分析阶段(防护条件) */}
+ {showAnalysisContent && (
+ <>
+ {/* 继续分析提示 */}
+ {message.resumedFrom && (
+
+ ⏸ 从「{message.resumedFrom}」继续
+
+ )}
+
+ {/* 各阶段卡片 */}
+ {message.stages?.info &&
}
+ {message.stages?.purpose &&
}
+ {message.stages?.actor &&
}
+ {message.stages?.behavior &&
}
+ {message.stages?.narrative &&
}
+ {message.finalAnswer &&
}
+ >
+ )}
+
+ {/* 【分支 3】追问回答(Markdown) */}
+ {isFollowUp && message.markdownContent && (
+
+ )}
+
+ {/* 【分支 4】Markdown 输出格式 */}
+ {isMarkdown && !isFollowUp && message.markdownContent && (
+
+ )}
+
+ {/* 【分支 5】澄清问题 */}
+ {isClarifying && message.clarifyQuestion && (
+
+
我需要你进一步说明:
+
{message.clarifyQuestion}
+
请重新输入以补充信息
+
+ )}
+
+ );
+};
+```
+
+**关键防护条件**:
+
+```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;
+
+}
+ 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`
diff --git a/web-code/components/angentchat/McpSelectModal.tsx b/web-code/components/angentchat/McpSelectModal.tsx
new file mode 100644
index 00000000..336636ee
--- /dev/null
+++ b/web-code/components/angentchat/McpSelectModal.tsx
@@ -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 = {
+ 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 = {
+ 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 = ({ open, expert, initialSelected, onCancel, onConfirm }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ const defaultAll = useMemo(() => MCP_OPTIONS.map((m) => m.key), []);
+ const [selected, setSelected] = useState(
+ initialSelected && initialSelected.length > 0 ? initialSelected : defaultAll,
+ );
+ const [error, setError] = useState(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 (
+
+
+
+
+
+
选择本次执行的 MCP
+
+ {expert ? `专家:${expert.name}` : '默认全部勾选,至少保留 1 个步骤'}
+
+
+
+
+
+
+ {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 (
+
+ );
+ })}
+ {error && (
+
{error}
+ )}
+
+
+
+
+ 后端固定执行顺序:目的 → 行为体 → 预期行为和驱动因素 → 叙事
+
+
+
+
+
+
+
+
+ );
+};
+
+export default McpSelectModal;
diff --git a/web-code/components/angentchat/SixStepCards.tsx b/web-code/components/angentchat/SixStepCards.tsx
new file mode 100644
index 00000000..d921a785
--- /dev/null
+++ b/web-code/components/angentchat/SixStepCards.tsx
@@ -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 = ({ 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 (
+
+
+
+
+ );
+};
+
+interface CardIndexBadgeProps {
+ index: number;
+ variant: 'purpose' | 'actor' | 'behavior' | 'narrative';
+}
+
+const VARIANT_COLOR: Record = {
+ 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 = ({ index, variant }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ const cls = VARIANT_COLOR[variant][isDark ? 'dark' : 'light'];
+ return (
+
+ {index.toString().padStart(2, '0')}
+
+ );
+};
+
+export const PurposeCardItem: React.FC<{ card: PurposeCard }> = ({ card }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ return (
+
+
+
+
+
+
+
+
+ {card.title}
+
+
+
+ 理由
+
+ {card.reason}
+
+
+
+
+ );
+};
+
+export const ActorCardItem: React.FC<{ card: ActorCard }> = ({ card }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ return (
+
+
+
+
+
+
+
+
+ {card.title}
+
+
+ {card.content}
+
+
+
+
+ );
+};
+
+export const BehaviorCardItem: React.FC<{ card: BehaviorDriverCard }> = ({ card }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ return (
+
+
+
+
+
+
+
+
+
+ 预期行为
+
+
+ {card.expectedBehavior}
+
+
+
+
+
+
+
+
+
+ 驱动因素
+
+
+ {card.driver}
+
+
+
+
+
+ );
+};
+
+export const NarrativeCardItem: React.FC<{ card: NarrativeCard }> = ({ card }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ return (
+
+
+
+
+
+
+
+
+ {card.title}
+
+
+
+ 理由
+
+ {card.reason}
+
+
+
+
+ );
+};
+
+interface GroupHeaderProps {
+ label: string;
+ parentTitle: string;
+ count: number;
+ variant: 'actor' | 'behavior' | 'narrative';
+}
+
+export const ParentGroupHeader: React.FC = ({ 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 (
+
+
+ {label}
+
+ {parentTitle}
+
+ · {count} 张
+
+ );
+};
+
+interface ThinkingBlockProps {
+ text: string;
+ loading?: boolean;
+ empty?: string;
+}
+
+export const ThinkingBlock: React.FC = ({ text, loading, empty = '正在生成思考内容...' }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ return (
+
+
+
+
+ 思考过程
+
+ {loading && (
+
+ 生成中
+
+ )}
+
+ {text ? (
+
+ {text}
+
+ ) : (
+
{empty}
+ )}
+
+ );
+};
+
+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 = {
+ 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 = ({ title, desc, count, loading, done, icon: Icon, accent }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ const cls = ACCENT_BG[accent][isDark ? 'dark' : 'light'];
+ return (
+
+
+
+
+
+
+ {title}
+ {typeof count === 'number' && (
+
+ {count} 张
+
+ )}
+ {done && (
+
+ 已完成
+
+ )}
+ {loading && (
+
+ 生成中
+
+ )}
+
+ {desc && (
+
+ {desc}
+
+ )}
+
+
+ );
+};
+
+export const STAGE_ICONS = {
+ purpose: Target,
+ actor: Users,
+ behavior: Zap,
+ narrative: Megaphone,
+};
diff --git a/web-code/components/angentchat/SixStepChatHistory.tsx b/web-code/components/angentchat/SixStepChatHistory.tsx
new file mode 100644
index 00000000..a704334e
--- /dev/null
+++ b/web-code/components/angentchat/SixStepChatHistory.tsx
@@ -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 = ({
+ isOpen,
+ isDark,
+ conversations,
+ activeId,
+ onClose,
+ onOpen,
+ onSelect,
+ onNew,
+ onDelete,
+ onRename,
+ onClearAll,
+}) => {
+ const [editingId, setEditingId] = useState(null);
+ const [draftTitle, setDraftTitle] = useState('');
+ const inputRef = useRef(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 (
+
+ );
+};
+
+export default SixStepChatHistory;
diff --git a/web-code/components/angentchat/SixStepChatPage.tsx b/web-code/components/angentchat/SixStepChatPage.tsx
new file mode 100644
index 00000000..67550f7e
--- /dev/null
+++ b/web-code/components/angentchat/SixStepChatPage.tsx
@@ -0,0 +1,1358 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { AlignLeft, GitBranch, History, LayoutList, Sparkles } from 'lucide-react';
+
+import { useTheme } from '../../contexts/ThemeContext';
+import ChatInputComposer from '../agent/ChatInputComposer';
+import { runExpertCreate, runExpertSearch, runSixStepDeduction, runIntentOrchestrateStream, type ExpertRunController, type IntentOrchestrateRequest } from './api';
+import McpSelectModal from './McpSelectModal';
+import SixStepConversation from './SixStepConversation';
+import SixStepChatHistory, { type SixStepConversationMeta } from './SixStepChatHistory';
+import SixStepFlowFullscreen from './SixStepFlowFullscreen';
+import SixStepFlowSandbox from './SixStepFlowSandbox';
+import SixStepProgressHorizontal from './SixStepProgressHorizontal';
+import { buildInitialAssistantMessage, type SixStepMockController } from './mockData';
+import './styles.css';
+import type {
+ ExpertCard,
+ ExpertMatchData,
+ MainStepKey,
+ SelectableMcpKey,
+ SixStepMessage,
+ StepStatus,
+ SubStepKey,
+} from './types';
+
+/**
+ * ===========================================================================
+ * SixStepChatPage —— 六步法(6bf)推演问答主界面
+ * ===========================================================================
+ *
+ * 职责概览:
+ * - 管理多会话列表与当前激活会话,并与后端 `/api/conversations` 做增删改查同步。
+ * - 将用户输入与「上一条助手消息」中的会话态(`phase`、`selectedExpert`、`rawSteps` 等)
+ * 组装为 `IntentOrchestrateRequest`,通过 `runIntentOrchestrateStream` 消费 SSE,
+ * 持续合并到当前助手 `SixStepMessage`(意图识别、专家匹配、六步编排、追问 Markdown 等)。
+ * - 专家流程:列表内操作经 `expertHandlers` 回到本页;用户在 `McpSelectModal` 选定子链后,
+ * 以 `intent_mode: 'force_orchestrate'` 立即启动编排,无需再次输入。
+ * - 停止:`handleStop` 中止流式控制器,并把生成中的助手消息标为 `paused`,
+ * 便于后端根据 `last_steps` / `current_phase` 做继续分析等分支。
+ *
+ * 主流程(用户发送一条消息):
+ * 1. `handleSend`:校验输入 → 无会话则 `createConversationOnServer`。
+ * 2. 追加 user 消息 + 新的 assistant 占位(`buildInitialAssistantMessage`,初始 `intent-detecting`)。
+ * 3. 从最后一条 assistant 推导 `current_phase`、`last_steps`(暂停续跑场景)等,构造 `reqBody`。
+ * 4. `runIntentOrchestrateStream`:`onUpdate` 按消息 id 合并助手状态;`onComplete` 持久化对话。
+ *
+ * 专家相关分支:
+ * - 后端下发 `expert_*` 事件 → 助手进入 `expert-match` / `expert-confirm`(由 `api.ts` 内处理)。
+ * - `handleRefetchExpert`:`intent_mode: 'force_find_expert'`,并带上已有 `expert_candidates`。
+ * - `handleMcpModalConfirm`:写入已选专家与子链,再 `force_orchestrate` 跑六步。
+ *
+ * 下方「模块级」函数只做类型与 API 载荷转换;组件内 `useCallback` 承载业务编排。
+ * ===========================================================================
+ */
+
+/** 前端内存中的单条会话:在通用 meta 上扩展创建时间与完整消息列表。 */
+interface SixStepConversationEntry extends SixStepConversationMeta {
+ createdAt: number;
+ model?: string;
+ messages: SixStepMessage[];
+}
+
+/** 后端 `/api/conversations` 中单条消息的存储形态:`data` 承载 SixStep 扩展字段。 */
+interface ConversationApiMessage {
+ id: string;
+ role: 'user' | 'assistant';
+ content: string;
+ created_at?: number;
+ data?: Partial;
+}
+
+/** 后端返回的单条会话记录(含 messages 数组)。 */
+interface ConversationApiRecord {
+ id: string;
+ filter_key: string;
+ filter_value: string;
+ title: string;
+ model?: string | null;
+ meta?: Record;
+ created_at: number;
+ updated_at: number;
+ messages: ConversationApiMessage[];
+}
+
+interface ModelOption {
+ id: string;
+ name: string;
+ provider: string;
+ enabled?: boolean;
+}
+
+interface ModelsResponse {
+ default_model_id: string;
+ models: ModelOption[];
+}
+
+/** 后端对话列表筛选键:区分不同页面写入的会话数据。 */
+const FILTER_KEY = 'page';
+/** 与 `FILTER_KEY` 成对使用,本页固定为 sixstep 聊天场景。 */
+const FILTER_VALUE = 'sixstep-chat';
+/** 新建会话时的占位标题,首条用户消息后会用摘要替换。 */
+const NEW_TITLE = '新对话';
+
+/** 助手消息缺少 `mainStepStatus` 时的默认进度占位(与 `SixStepMessage` 结构对齐)。 */
+const DEFAULT_MAIN_STATUS: Record = {
+ info: 'pending',
+ sixstep: 'pending',
+ final: 'pending',
+};
+
+/** 六步子步骤默认进度(purpose / actor / behavior / narrative)。 */
+const DEFAULT_SUB_STATUS: Record = {
+ purpose: 'pending',
+ actor: 'pending',
+ behavior: 'pending',
+ narrative: 'pending',
+};
+
+/** 右侧流程图沙箱可拖拽宽度区间(像素)。 */
+const MIN_SANDBOX_WIDTH = 360;
+const MAX_SANDBOX_WIDTH = 900;
+/** 沙箱初始宽度。 */
+const DEFAULT_SANDBOX_WIDTH = 520;
+
+// ---------------------------------------------------------------------------
+// 模块级工具:会话排序、时间戳、与后端 Conversation API 的序列化/反序列化
+// ---------------------------------------------------------------------------
+const backUrl = 'https://ch1.b.uat.4.cn/zncmback';
+/** 按 `updatedAt` 降序排列,保证侧边历史列表「最近在上」。 */
+const sortConversations = (items: SixStepConversationEntry[]) =>
+ [...items].sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0));
+
+/** 将 API 或持久化里混用的日期类型统一为 `Date`,解析失败则回退为当前时间。 */
+const toTimestamp = (value?: Date | number | string): Date => {
+ if (value instanceof Date) {
+ return value;
+ }
+ if (typeof value === 'number') {
+ return new Date(value);
+ }
+ if (typeof value === 'string' && value) {
+ const date = new Date(value);
+ if (!Number.isNaN(date.getTime())) {
+ return date;
+ }
+ }
+ return new Date();
+};
+
+/** 将单条 API 消息(`data` 内嵌 SixStep 扩展字段)还原为前端 `SixStepMessage`。 */
+const normalizeMessage = (item: ConversationApiMessage): SixStepMessage => {
+ const payload = item.data || {};
+ return {
+ ...payload,
+ id: item.id,
+ role: item.role,
+ content: item.content ?? payload.content ?? '',
+ timestamp: toTimestamp(payload.timestamp ?? item.created_at),
+ } as SixStepMessage;
+};
+
+/** 将后端完整会话记录转为带 `messages: SixStepMessage[]` 的前端会话条目。 */
+const normalizeConversation = (item: ConversationApiRecord): SixStepConversationEntry => ({
+ id: item.id,
+ title: item.title || NEW_TITLE,
+ createdAt: Number(item.created_at || Date.now()),
+ updatedAt: Number(item.updated_at || Date.now()),
+ model: item.model || '',
+ messages: (item.messages || []).map(normalizeMessage),
+});
+
+/**
+ * 持久化单条消息:`data` 中存放完整 SixStep 状态,便于刷新后恢复卡片/阶段/rawSteps。
+ * `created_at` 使用消息时间戳的毫秒值,供列表排序。
+ */
+const serializeMessage = (message: SixStepMessage) => ({
+ id: message.id,
+ role: message.role,
+ content: message.content || '',
+ created_at: message.timestamp instanceof Date ? message.timestamp.getTime() : Date.now(),
+ data: {
+ ...message,
+ timestamp: message.timestamp instanceof Date ? message.timestamp.toISOString() : new Date().toISOString(),
+ },
+});
+
+/** 拼接带 `filter_key` / `filter_value` 查询参数的会话 REST URL。 */
+const conversationRequestUrl = (conversationId?: string) => {
+ const search = new URLSearchParams({
+ filter_key: FILTER_KEY,
+ filter_value: FILTER_VALUE,
+ });
+ const base = conversationId ? `/api/conversations/${conversationId}` : '/api/conversations';
+ return `${backUrl}${base}?${search.toString()}`;
+};
+
+/**
+ * 六步法推演页组件:对话列表 + 消息区 + 输入区 + 可选流程图沙箱/全屏 + MCP 选择弹窗。
+ * 所有与后端的流式编排交互的入口集中在 `handleSend`、`handleRefetchExpert`、`handleMcpModalConfirm`。
+ */
+export default function SixStepChatPage() {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ // ----- 会话与消息(核心状态)-----
+ const [conversations, setConversations] = useState([]);
+ const [activeConvId, setActiveConvId] = useState(null);
+ const [input, setInput] = useState('');
+ /** 是否有流式任务在跑(意图流、专家创建、旧版推演等共用)。 */
+ const [isGenerating, setIsGenerating] = useState(false);
+ /** 编排请求体 `output_format`:结构化卡片 vs Markdown(影响关闭流程图侧栏等行为)。 */
+ const [outputFormat, setOutputFormat] = useState<'structured' | 'markdown'>('structured');
+ /** 模型切换:模型列表、全局默认模型、当前页面选中模型。 */
+ const [models, setModels] = useState([]);
+ const [globalDefaultModel, setGlobalDefaultModel] = useState('');
+ const [selectedModel, setSelectedModel] = useState('');
+
+ /** 切换输出格式;Markdown 模式下关闭流程图沙箱与全屏,避免与纯文展示冲突。 */
+ const handleSetOutputFormat = useCallback((fmt: 'structured' | 'markdown') => {
+ setOutputFormat(fmt);
+ if (fmt === 'markdown') {
+ setSandboxOpen(false);
+ setFullscreenOpen(false);
+ }
+ }, []);
+
+ // ----- 布局与 UI 附属状态 -----
+ const [sandboxOpen, setSandboxOpen] = useState(false);
+ const [sandboxWidth, setSandboxWidth] = useState(DEFAULT_SANDBOX_WIDTH);
+ const [fullscreenOpen, setFullscreenOpen] = useState(false);
+ const [historyOpen, setHistoryOpen] = useState(false);
+ /** 首次从服务端拉取会话列表完成前为 true,用于禁用输入等。 */
+ const [loading, setLoading] = useState(true);
+
+ // ----- Refs:流式控制器、布局、与异步闭包中读取的最新 conversations -----
+ /** 当前主编排/意图流控制器(`runIntentOrchestrateStream` 或 `runSixStepDeduction`)。 */
+ const controllerRef = useRef(null);
+ /** 专家搜索/创建等独立流(与主控制器分离,便于分别 stop)。 */
+ const expertControllerRef = useRef(null);
+ const containerRef = useRef(null);
+ /** 流程图沙箱宽度拖拽时的指针起点与初始宽度。 */
+ const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null);
+ /** 避免首屏重复请求会话列表。 */
+ const hasLoadedRef = useRef(false);
+ /** 与 `conversations` state 同步,供不依赖 conversations 的闭包读取最新列表。 */
+ const conversationsRef = useRef([]);
+
+ /**
+ * 当前「正在交互」的助手消息 id(专家卡片高亮、重搜专家等)。
+ * 若未显式设置,则回退为会话中最后一条 assistant(见下方 `activeAssistantId` 计算)。
+ */
+ const [activeAssistantMsgId, setActiveAssistantMsgId] = useState(null);
+ /** MCP 子链选择弹窗:挂在某条助手消息上,并持有待确认的专家卡片。 */
+ const [mcpModal, setMcpModal] = useState<{ open: boolean; messageId: string | null; expert: ExpertCard | null }>(
+ { open: false, messageId: null, expert: null },
+ );
+
+ /** 当前选中的完整会话实体;无选中时为 null。 */
+ const activeConv = useMemo(
+ () => conversations.find((conversation) => conversation.id === activeConvId) || null,
+ [activeConvId, conversations],
+ );
+ /** 当前会话下的消息列表(仅渲染用,写入请通过 `setMessages` / `patchMessageById` / `upsertConversationState`)。 */
+ const messages = activeConv?.messages ?? [];
+
+ /** 侧边历史抽屉所需的轻量元数据(已按更新时间排序)。 */
+ const conversationMetas = useMemo(
+ () =>
+ sortConversations(conversations).map(({ id, title, updatedAt }) => ({
+ id,
+ title,
+ updatedAt,
+ })),
+ [conversations],
+ );
+
+ /**
+ * 顶部进度条与流程图沙箱所绑定的「当前助手消息」id:
+ * 优先 `activeAssistantMsgId`(若仍存在于当前会话);否则取最后一条 assistant。
+ */
+ const activeAssistantId = (() => {
+ if (activeAssistantMsgId && messages.some((message) => message.id === activeAssistantMsgId)) {
+ return activeAssistantMsgId;
+ }
+ const lastAssistant = [...messages].reverse().find((message) => message.role === 'assistant');
+ return lastAssistant?.id ?? null;
+ })();
+
+ /** 绑定到顶部进度条、流程图沙箱/全屏的助手消息;无消息时为 undefined。 */
+ const activeAssistant = messages.find((message) => message.id === activeAssistantId);
+ /** 主步骤状态(信息准备 / 六步 / 最终回答)用于 `SixStepProgressHorizontal`。 */
+ const liveMainStatus = activeAssistant?.mainStepStatus ?? DEFAULT_MAIN_STATUS;
+ /** 子步骤(purpose / actor / behavior / narrative)进度展示。 */
+ const liveSubStatus = activeAssistant?.subStepStatus ?? DEFAULT_SUB_STATUS;
+
+ useEffect(() => {
+ conversationsRef.current = conversations;
+ }, [conversations]);
+
+ /** 中止所有流式任务并清理控制器引用;不修改消息内容(停止后的 UI 由 `handleStop` 负责)。 */
+ const stopGeneration = useCallback(() => {
+ controllerRef.current?.stop();
+ controllerRef.current = null;
+ expertControllerRef.current?.stop();
+ expertControllerRef.current = null;
+ setIsGenerating(false);
+ }, []);
+
+ /**
+ * 在指定会话内按消息 id 做一次不可变更新(`patch` 返回完整新消息对象)。
+ * 用于专家卡片局部更新等「只改一条消息」的场景。
+ */
+ const patchMessageById = useCallback(
+ (conversationId: string, messageId: string, patch: (msg: SixStepMessage) => SixStepMessage) => {
+ setConversations((prev) =>
+ prev.map((conversation) => {
+ if (conversation.id !== conversationId) return conversation;
+ return {
+ ...conversation,
+ messages: conversation.messages.map((m) => (m.id === messageId ? patch(m) : m)),
+ updatedAt: Date.now(),
+ };
+ }),
+ );
+ },
+ [],
+ );
+
+ /** GET 列表:拉取本页 `filter_key` / `filter_value` 下的全部会话并排序。 */
+ const fetchConversations = useCallback(async (): Promise => {
+ const response = await fetch(conversationRequestUrl());
+ if (!response.ok) {
+ throw new Error(`加载聊天记录失败:${response.status}`);
+ }
+ const payload = (await response.json()) as ConversationApiRecord[];
+ return sortConversations(payload.map(normalizeConversation));
+ }, []);
+
+ /** GET 模型列表:用于页面头部模型切换。 */
+ const fetchModels = useCallback(async (): Promise => {
+ const response = await fetch(`${backUrl}/api/models`);
+ if (!response.ok) {
+ throw new Error(`加载模型列表失败:${response.status}`);
+ }
+ return (await response.json()) as ModelsResponse;
+ }, []);
+
+ /** POST 创建空会话(可带初始 messages),返回规范化后的会话条目。 */
+ const createConversationOnServer = useCallback(
+ async (
+ title: string,
+ model?: string,
+ messagesToSave: SixStepMessage[] = [],
+ ): Promise => {
+ const response = await fetch(`${backUrl}/api/conversations`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ filter_key: FILTER_KEY,
+ filter_value: FILTER_VALUE,
+ title,
+ model,
+ meta: { page: FILTER_VALUE },
+ messages: messagesToSave.map(serializeMessage),
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(`创建对话失败:${response.status}`);
+ }
+ return normalizeConversation((await response.json()) as ConversationApiRecord);
+ },
+ [],
+ );
+
+ /** PUT 更新会话:标题、模型、meta 或整表 messages(由调用方决定传哪些字段)。 */
+ const updateConversationOnServer = useCallback(
+ async (
+ conversationId: string,
+ payload: {
+ title?: string;
+ model?: string;
+ meta?: Record;
+ messages?: SixStepMessage[];
+ },
+ ): Promise => {
+ const response = await fetch(conversationRequestUrl(conversationId), {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: payload.title,
+ model: payload.model,
+ meta: payload.meta,
+ messages: payload.messages?.map(serializeMessage),
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(`更新对话失败:${response.status}`);
+ }
+ return normalizeConversation((await response.json()) as ConversationApiRecord);
+ },
+ [],
+ );
+
+ /** DELETE 单条会话。 */
+ const deleteConversationOnServer = useCallback(async (conversationId: string) => {
+ const response = await fetch(conversationRequestUrl(conversationId), {
+ method: 'DELETE',
+ });
+ if (!response.ok) {
+ throw new Error(`删除对话失败:${response.status}`);
+ }
+ }, []);
+
+ /** DELETE 当前筛选条件下的全部会话(清空历史)。 */
+ const clearConversationsOnServer = useCallback(async () => {
+ const response = await fetch(conversationRequestUrl(), {
+ method: 'DELETE',
+ });
+ if (!response.ok) {
+ throw new Error(`清空聊天记录失败:${response.status}`);
+ }
+ }, []);
+
+ /** 将一条会话合并进 state:同 id 覆盖,新会话插入队首后再按时间排序。 */
+ const upsertConversationState = useCallback((conversation: SixStepConversationEntry) => {
+ setConversations((prev) =>
+ sortConversations([conversation, ...prev.filter((item) => item.id !== conversation.id)]),
+ );
+ }, []);
+
+ /**
+ * 用 `conversationsRef` 中的最新快照整表 PUT 到服务端,再用返回结果更新本地。
+ * 流式 `onComplete`、停止后延迟保存等会调用,避免丢最后几条 SSE 合并结果。
+ */
+ const persistConversation = useCallback(
+ async (conversationId: string) => {
+ const current = conversationsRef.current.find((item) => item.id === conversationId);
+ if (!current) return null;
+ const updated = await updateConversationOnServer(conversationId, {
+ title: current.title,
+ model: current.model,
+ meta: { page: FILTER_VALUE },
+ messages: current.messages,
+ });
+ upsertConversationState(updated);
+ return updated;
+ },
+ [updateConversationOnServer, upsertConversationState],
+ );
+
+ /**
+ * 仅更新「当前激活会话」的 messages,语义上类似 `useState` 的 setState,
+ * 但实现为在 `conversations` 数组里找到 `activeConvId` 对应项并替换其 `messages`。
+ */
+ const setMessages = useCallback>>(
+ (value) => {
+ setConversations((prev) =>
+ prev.map((conversation) => {
+ if (conversation.id !== activeConvId) return conversation;
+ const nextMessages =
+ typeof value === 'function'
+ ? (value as (messages: SixStepMessage[]) => SixStepMessage[])(conversation.messages)
+ : value;
+ return {
+ ...conversation,
+ messages: nextMessages,
+ updatedAt: Date.now(),
+ };
+ }),
+ );
+ },
+ [activeConvId],
+ );
+
+ /** 挂载时拉取会话列表;成功则选中最新一条,失败则置空列表(组件卸载时忽略异步结果)。 */
+ useEffect(() => {
+ let cancelled = false;
+
+ (async () => {
+ setLoading(true);
+ try {
+ const [items, modelsPayload] = await Promise.all([fetchConversations(), fetchModels()]);
+ if (cancelled) return;
+ const nextModels = modelsPayload.models || [];
+ const defaultModelId = modelsPayload.default_model_id || '';
+ setConversations(items);
+ setModels(nextModels);
+ setGlobalDefaultModel(defaultModelId);
+ setActiveConvId(items[0]?.id || null);
+ setSelectedModel(items[0]?.model || defaultModelId || nextModels[0]?.id || '');
+ hasLoadedRef.current = true;
+ } catch {
+ if (!cancelled) {
+ setConversations([]);
+ setModels([]);
+ setGlobalDefaultModel('');
+ setSelectedModel('');
+ setActiveConvId(null);
+ hasLoadedRef.current = true;
+ }
+ } finally {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [fetchConversations, fetchModels]);
+
+ /** 新建会话:停流 → 服务端创建 → 切到新会话并重置专家/MCP 弹窗与输入。 */
+ const handleNewConversation = useCallback(async () => {
+ stopGeneration();
+ const created = await createConversationOnServer(NEW_TITLE, selectedModel || globalDefaultModel || undefined);
+ upsertConversationState(created);
+ setActiveConvId(created.id);
+ setSelectedModel(created.model || selectedModel || globalDefaultModel || models[0]?.id || '');
+ setActiveAssistantMsgId(null);
+ setMcpModal({ open: false, messageId: null, expert: null });
+ setInput('');
+ }, [createConversationOnServer, globalDefaultModel, models, selectedModel, stopGeneration, upsertConversationState]);
+
+ /** 切换侧边选中的会话:停流、清空专家弹窗与输入(不自动持久化,仅切本地状态)。 */
+ const handleSelectConversation = useCallback(
+ (id: string) => {
+ if (id === activeConvId) return;
+ stopGeneration();
+ const nextConversation = conversationsRef.current.find((item) => item.id === id) || null;
+ setActiveConvId(id);
+ setSelectedModel(nextConversation?.model || globalDefaultModel || models[0]?.id || '');
+ setActiveAssistantMsgId(null);
+ setMcpModal({ open: false, messageId: null, expert: null });
+ setInput('');
+ },
+ [activeConvId, globalDefaultModel, models, stopGeneration],
+ );
+
+ /** 选择模型后写入当前会话,后续 /api/orchestrate/stream 请求会透传该 model。 */
+ const handleModelChange = useCallback(
+ async (modelId: string) => {
+ setSelectedModel(modelId);
+ if (!activeConvId) return;
+ setConversations((prev) =>
+ prev.map((conversation) =>
+ conversation.id === activeConvId
+ ? { ...conversation, model: modelId, updatedAt: Date.now() }
+ : conversation,
+ ),
+ );
+ try {
+ const updated = await updateConversationOnServer(activeConvId, { model: modelId });
+ upsertConversationState(updated);
+ } catch {
+ // 本地模型仍保留,发送时继续透传 selectedModel。
+ }
+ },
+ [activeConvId, updateConversationOnServer, upsertConversationState],
+ );
+
+ /** 删除指定会话;若删的是当前会话则停流并自动选中剩余列表中的下一条(若有)。 */
+ const handleDeleteConversation = useCallback(
+ async (id: string) => {
+ await deleteConversationOnServer(id);
+ setConversations((prev) => {
+ const remaining = prev.filter((item) => item.id !== id);
+ if (id === activeConvId) {
+ stopGeneration();
+ const next = sortConversations(remaining)[0] || null;
+ setActiveConvId(next?.id || null);
+ setSelectedModel(next?.model || globalDefaultModel || models[0]?.id || '');
+ }
+ return remaining;
+ });
+ },
+ [activeConvId, deleteConversationOnServer, globalDefaultModel, models, stopGeneration],
+ );
+
+ /** 乐观更新标题后立即 PUT,再用服务端返回覆盖(避免短暂显示旧标题)。 */
+ const handleRenameConversation = useCallback(
+ async (id: string, title: string) => {
+ const current = conversations.find((item) => item.id === id);
+ if (!current) return;
+ const optimistic = { ...current, title, updatedAt: Date.now() };
+ upsertConversationState(optimistic);
+ const updated = await updateConversationOnServer(id, { title });
+ upsertConversationState(updated);
+ },
+ [conversations, updateConversationOnServer, upsertConversationState],
+ );
+
+ /** 清空服务端与本地的全部本会话类型记录,并取消当前选中。 */
+ const handleClearAllConversations = useCallback(async () => {
+ stopGeneration();
+ await clearConversationsOnServer();
+ setConversations([]);
+ setActiveConvId(null);
+ }, [clearConversationsOnServer, stopGeneration]);
+
+ /**
+ * 仅调用专家搜索 API(不经意图编排 SSE)。
+ * 当前主对话已统一走 `runIntentOrchestrateStream`;本函数保留为独立专家搜索能力的封装。
+ */
+ const startExpertSearch = useCallback(
+ (conversationId: string, messageId: string, userInput: string, excludeIds?: string[]) => {
+ expertControllerRef.current?.stop();
+ const baseData: ExpertMatchData = {
+ status: 'searching',
+ thinking: '',
+ experts: [],
+ };
+ const controller = runExpertSearch(
+ baseData,
+ { user_input: userInput, exclude_ids: excludeIds },
+ {
+ onUpdate: (data) => {
+ patchMessageById(conversationId, messageId, (m) => ({
+ ...m,
+ expertMatch: data,
+ }));
+ },
+ onComplete: () => {
+ expertControllerRef.current = null;
+ setIsGenerating(false);
+ void persistConversation(conversationId);
+ },
+ },
+ );
+ expertControllerRef.current = controller;
+ void controller.promise.catch(() => {
+ setIsGenerating(false);
+ });
+ },
+ [patchMessageById, persistConversation],
+ );
+
+ /**
+ * 旧版六步推演:在已选定专家与子链后,直接 `runSixStepDeduction` 拉流。
+ * 与 `runIntentOrchestrateStream` + `force_orchestrate` 并存,便于迁移期或特殊入口复用。
+ */
+ const runDeductionForMessage = useCallback(
+ (conversationId: string, message: SixStepMessage, userInput: string, expert: ExpertCard, selectedMcps: SelectableMcpKey[]) => {
+ const requestExpert = { ...expert, id: expert.backendId ?? expert.id };
+ setIsGenerating(true);
+ setActiveAssistantMsgId(message.id);
+
+ const controller = runSixStepDeduction(
+ { ...message, phase: 'generate', selectedExpert: requestExpert, selectedMcps },
+ {
+ onUpdate: (updated) => {
+ setConversations((prev) =>
+ prev.map((conversation) => {
+ if (conversation.id !== conversationId) return conversation;
+ return {
+ ...conversation,
+ messages: conversation.messages.map((item) =>
+ item.id === updated.id ? { ...item, ...updated } : item,
+ ),
+ updatedAt: Date.now(),
+ };
+ }),
+ );
+ },
+ onComplete: () => {
+ setIsGenerating(false);
+ controllerRef.current = null;
+ void persistConversation(conversationId);
+ },
+ },
+ {
+ user_input: userInput,
+ output_format: outputFormat,
+ model: selectedModel || undefined,
+ selected_expert: requestExpert,
+ selected_mcps: selectedMcps,
+ },
+ );
+
+ controllerRef.current = controller;
+ void controller.promise.catch(() => {
+ setIsGenerating(false);
+ });
+ },
+ [outputFormat, persistConversation, selectedModel],
+ );
+
+ /**
+ * 用户点击发送后的主编排入口(意图识别 + 分支 SSE)。
+ *
+ * 流程要点:
+ * 1. 无当前会话则先创建服务端会话。
+ * 2. 从「上一条助手消息」读取 `phase`、`selectedExpert`、`expertMatch.experts`、`rawSteps` 等,
+ * 组装 `IntentOrchestrateRequest`,供后端 `detect_intent` 与 continue 分支使用。
+ * 3. 若 `phase === 'paused'`,把上一轮的 stages / rawSteps / 选中专家与子链预填到新助手占位里,
+ * 并把 `last_steps`、`selected_mcps` 放进请求体(仅暂停续跑场景传 `last_steps`,避免误传)。
+ * 4. 追加 user + assistant 后立刻 `runIntentOrchestrateStream`,用返回的 `updated` 合并同 id 助手消息。
+ */
+ const handleSend = useCallback(async () => {
+ const trimmed = input.trim();
+ if (!trimmed || isGenerating) return;
+
+ let target = activeConv;
+ if (!target) {
+ target = await createConversationOnServer(NEW_TITLE);
+ upsertConversationState(target);
+ setActiveConvId(target.id);
+ }
+
+ // 意图编排上下文:来自「上一条助手消息」,与后端 OrchestrateRequest 字段对齐
+ const lastAssistant = [...target.messages].reverse().find((m) => m.role === 'assistant');
+ const currentPhase = lastAssistant?.phase ?? 'idle';
+ const selectedExpert = lastAssistant?.selectedExpert ?? null;
+ const expertCandidates = (lastAssistant?.expertMatch?.experts ?? []) as unknown as Record[];
+ const isPaused = currentPhase === 'paused';
+ const lastSteps = isPaused && lastAssistant?.rawSteps
+ ? (lastAssistant.rawSteps as unknown as Record[])
+ : [];
+ const selectedExpertPayload = selectedExpert
+ ? (selectedExpert as unknown as Record)
+ : null;
+ const pausedSelectedMcps = isPaused && lastAssistant?.selectedMcps ? lastAssistant.selectedMcps : [];
+
+ // 本轮用户气泡
+ const userMessage: SixStepMessage = {
+ id: `user-${Date.now()}`,
+ role: 'user',
+ content: trimmed,
+ timestamp: new Date(),
+ };
+
+ /**
+ * 新的助手占位:从「意图识别中」开始。
+ * 若从 paused 恢复,则克隆上一轮卡片/步骤/rawSteps/已选专家子链,便于 UI 连续与后端续跑。
+ */
+ const assistantMessage: SixStepMessage = {
+ ...buildInitialAssistantMessage(),
+ outputFormat,
+ phase: 'intent-detecting',
+ ...(isPaused && lastAssistant ? {
+ stages: { ...lastAssistant.stages },
+ rawSteps: lastAssistant.rawSteps ? [...lastAssistant.rawSteps] : undefined,
+ subStepStatus: lastAssistant.subStepStatus ? { ...lastAssistant.subStepStatus } : undefined,
+ mainStepStatus: lastAssistant.mainStepStatus ? { ...lastAssistant.mainStepStatus } : undefined,
+ selectedExpert: lastAssistant.selectedExpert,
+ selectedMcps: lastAssistant.selectedMcps,
+ } : {}),
+ };
+
+ // 首条真实问题时,用摘要覆盖默认标题「新对话」
+ const nextTitle = !target.title || target.title === NEW_TITLE ? trimmed.slice(0, 24) : target.title;
+ const nextConversation: SixStepConversationEntry = {
+ ...target,
+ title: nextTitle,
+ messages: [...target.messages, userMessage, assistantMessage],
+ updatedAt: Date.now(),
+ };
+
+ upsertConversationState(nextConversation);
+ setActiveConvId(nextConversation.id);
+ setInput('');
+ setIsGenerating(true);
+ setActiveAssistantMsgId(assistantMessage.id);
+ setHistoryOpen(false);
+
+ const convId = nextConversation.id;
+ // intent_mode 默认 auto,由 api 层与后端协商;此处只传会话态供意图识别与 continue 使用
+ const reqBody: IntentOrchestrateRequest = {
+ user_input: trimmed,
+ output_format: outputFormat,
+ model: selectedModel || target.model || globalDefaultModel || undefined,
+ current_phase: currentPhase,
+ selected_expert: selectedExpertPayload,
+ selected_mcps: pausedSelectedMcps,
+ expert_candidates: expertCandidates,
+ last_steps: lastSteps,
+ };
+
+ const controller = runIntentOrchestrateStream(
+ assistantMessage,
+ {
+ onUpdate: (updated) => {
+ setConversations((prev) =>
+ prev.map((conv) => {
+ if (conv.id !== convId) return conv;
+ return {
+ ...conv,
+ messages: conv.messages.map((m) => (m.id === updated.id ? { ...m, ...updated } : m)),
+ updatedAt: Date.now(),
+ };
+ }),
+ );
+ },
+ onComplete: () => {
+ setIsGenerating(false);
+ controllerRef.current = null;
+ void persistConversation(convId);
+ },
+ },
+ reqBody,
+ );
+ controllerRef.current = controller;
+ void controller.promise.catch(() => { setIsGenerating(false); });
+ }, [
+ activeConv,
+ createConversationOnServer,
+ globalDefaultModel,
+ input,
+ isGenerating,
+ outputFormat,
+ persistConversation,
+ selectedModel,
+ upsertConversationState,
+ ]);
+
+ // ---------------------------------------------------------------------------
+ // 专家匹配与 MCP 子链:由 SixStepConversation 经 expertHandlers 回调到本页
+ // 典型顺序:选专家卡片 → 确认 → McpSelectModal 选子链 → handleMcpModalConfirm 开跑编排
+ // ---------------------------------------------------------------------------
+
+ /** 在 `conversationsRef` 全集中按消息 id 查找所属会话与消息(避免闭包拿到过期列表)。 */
+ const findMessage = useCallback(
+ (messageId: string): { conv: SixStepConversationEntry | null; msg: SixStepMessage | null } => {
+ for (const conv of conversationsRef.current) {
+ const m = conv.messages.find((x) => x.id === messageId);
+ if (m) return { conv, msg: m };
+ }
+ return { conv: null, msg: null };
+ },
+ [],
+ );
+
+ /** 用户在专家卡片上点选某张专家:只写入 `expertMatch.selectedExpertId`(尚未开始推演)。 */
+ const handleSelectExpert = useCallback(
+ (messageId: string, expertId: string) => {
+ const { conv } = findMessage(messageId);
+ if (!conv) return;
+ patchMessageById(conv.id, messageId, (m) => ({
+ ...m,
+ expertMatch: m.expertMatch ? { ...m.expertMatch, selectedExpertId: expertId } : m.expertMatch,
+ }));
+ },
+ [findMessage, patchMessageById],
+ );
+
+ /**
+ * 「换一批 / 重新找专家」:用触发该助手气泡前的用户原句 + `force_find_expert`,
+ * 并传入已有 `expert_candidates` 供后端去重。流控制器挂在 `expertControllerRef` 以便 stop。
+ */
+ const handleRefetchExpert = useCallback(
+ (messageId: string) => {
+ const { conv, msg } = findMessage(messageId);
+ if (!conv || !msg) return;
+ const idx = conv.messages.findIndex((m) => m.id === messageId);
+ const userMsg = idx > 0 ? [...conv.messages.slice(0, idx)].reverse().find((m) => m.role === 'user') : null;
+ const userInput = userMsg?.content?.trim() ?? '';
+ if (!userInput) return;
+
+ const existingCandidates = (msg.expertMatch?.experts ?? []) as unknown as Record[];
+
+ // 清空上一轮专家列表与意图,界面进入「识别中」再收 SSE
+ patchMessageById(conv.id, messageId, (m) => ({
+ ...m,
+ phase: 'intent-detecting' as const,
+ expertMatch: { status: 'searching' as const, thinking: '', experts: [] },
+ intent: undefined,
+ }));
+ setIsGenerating(true);
+ setActiveAssistantMsgId(messageId);
+
+ const convId = conv.id;
+ const refetchMsg: SixStepMessage = {
+ ...msg,
+ phase: 'intent-detecting',
+ expertMatch: { status: 'searching', thinking: '', experts: [] },
+ intent: undefined,
+ };
+
+ const controller = runIntentOrchestrateStream(
+ refetchMsg,
+ {
+ onUpdate: (updated) => {
+ setConversations((prev) =>
+ prev.map((c) => {
+ if (c.id !== convId) return c;
+ return {
+ ...c,
+ messages: c.messages.map((m) => (m.id === updated.id ? { ...m, ...updated } : m)),
+ updatedAt: Date.now(),
+ };
+ }),
+ );
+ },
+ onComplete: () => {
+ setIsGenerating(false);
+ controllerRef.current = null;
+ void persistConversation(convId);
+ },
+ },
+ {
+ user_input: userInput,
+ intent_mode: 'force_find_expert',
+ model: selectedModel || conv.model || globalDefaultModel || undefined,
+ expert_candidates: existingCandidates,
+ },
+ );
+ expertControllerRef.current = { stop: controller.stop, promise: controller.promise };
+ void controller.promise.catch(() => { setIsGenerating(false); });
+ },
+ [findMessage, globalDefaultModel, patchMessageById, persistConversation, selectedModel],
+ );
+
+ /** 调用创建专家接口,进度与结果写回同一条消息的 `expertMatch`。 */
+ const handleCreateExpert = useCallback(
+ (messageId: string) => {
+ const { conv, msg } = findMessage(messageId);
+ if (!conv || !msg) return;
+ const idx = conv.messages.findIndex((m) => m.id === messageId);
+ const userMsg = idx > 0 ? [...conv.messages.slice(0, idx)].reverse().find((m) => m.role === 'user') : null;
+ const userInput = userMsg?.content?.trim() || '';
+ if (!userInput) return;
+
+ expertControllerRef.current?.stop();
+ setIsGenerating(true);
+ setActiveAssistantMsgId(messageId);
+ const baseData = msg.expertMatch ?? { status: 'idle', thinking: '', experts: [] };
+ const controller = runExpertCreate(
+ baseData,
+ { user_input: userInput },
+ {
+ onUpdate: (data) => {
+ patchMessageById(conv.id, messageId, (m) => ({ ...m, expertMatch: data }));
+ },
+ onComplete: () => {
+ expertControllerRef.current = null;
+ setIsGenerating(false);
+ void persistConversation(conv.id);
+ },
+ },
+ );
+ expertControllerRef.current = controller;
+ void controller.promise.catch(() => {
+ setIsGenerating(false);
+ });
+ },
+ [findMessage, patchMessageById, persistConversation],
+ );
+
+ /**
+ * 用户确认当前选中的专家:打开 MCP 子链选择弹窗(不立即请求编排)。
+ * 真正开跑在 `handleMcpModalConfirm`。
+ */
+ const handleConfirmExpertSelect = useCallback(
+ (messageId: string) => {
+ const { msg } = findMessage(messageId);
+ if (!msg || !msg.expertMatch) return;
+ const expert = msg.expertMatch.experts.find((e) => e.id === msg.expertMatch?.selectedExpertId) || null;
+ if (!expert) return;
+ setMcpModal({ open: true, messageId, expert });
+ },
+ [findMessage],
+ );
+
+ /** 关闭 MCP 弹窗且不提交子链选择。 */
+ const handleMcpModalCancel = useCallback(() => {
+ setMcpModal({ open: false, messageId: null, expert: null });
+ }, []);
+
+ /**
+ * MCP 弹窗确认:把专家 + 勾选子链写入消息,并以「原始用户问题」为 `user_input`、
+ * `intent_mode: 'force_orchestrate'` 立即启动六步编排 SSE(不再依赖本轮意图识别)。
+ *
+ * 注意:会清空 `stages` / 最终回答等,相当于从专家确认点重新开始一条生成链路。
+ */
+ const handleMcpModalConfirm = useCallback(
+ (selectedMcps: SelectableMcpKey[]) => {
+ const messageId = mcpModal.messageId;
+ const expert = mcpModal.expert;
+ if (!messageId || !expert) {
+ setMcpModal({ open: false, messageId: null, expert: null });
+ return;
+ }
+ const { conv, msg } = findMessage(messageId);
+ if (!conv || !msg) return;
+
+ // 回溯该助手消息之前的最近一条用户消息,作为编排 user_input(与首次 handleSend 的句子一致)
+ const msgIdx = conv.messages.findIndex((m) => m.id === messageId);
+ const userMsg = msgIdx > 0
+ ? [...conv.messages.slice(0, msgIdx)].reverse().find((m) => m.role === 'user')
+ : null;
+ const originalInput = userMsg?.content?.trim() ?? '';
+
+ const requestExpert = { ...expert, id: expert.backendId ?? expert.id } as unknown as ExpertCard;
+
+ // 重置为「即将跑六步」的干净状态,避免残留上一次的 stages / 流式片段
+ const resetMsg: SixStepMessage = {
+ ...msg,
+ phase: 'generate',
+ outputFormat,
+ selectedExpert: requestExpert,
+ selectedMcps,
+ expertMatch: msg.expertMatch
+ ? { ...msg.expertMatch, status: 'confirmed', selectedExpertId: expert.id, selectedMcps }
+ : msg.expertMatch,
+ intent: undefined,
+ stages: {},
+ finalAnswer: '',
+ markdownContent: '',
+ mainStepStatus: { info: 'pending', sixstep: 'pending', final: 'pending' },
+ subStepStatus: { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' },
+ currentMainStep: undefined,
+ currentSubStep: undefined,
+ };
+
+ // 先同步到 state,再发起流式请求,避免首包事件到达时仍显示旧 UI
+ patchMessageById(conv.id, messageId, () => resetMsg);
+ setMcpModal({ open: false, messageId: null, expert: null });
+ setActiveAssistantMsgId(messageId);
+ setIsGenerating(true);
+
+ const convId = conv.id;
+ const controller = runIntentOrchestrateStream(
+ resetMsg,
+ {
+ onUpdate: (updated) => {
+ setConversations((prev) =>
+ prev.map((c) => {
+ if (c.id !== convId) return c;
+ return {
+ ...c,
+ messages: c.messages.map((m) => (m.id === updated.id ? { ...m, ...updated } : m)),
+ updatedAt: Date.now(),
+ };
+ }),
+ );
+ },
+ onComplete: () => {
+ setIsGenerating(false);
+ controllerRef.current = null;
+ void persistConversation(convId);
+ },
+ },
+ {
+ user_input: originalInput,
+ output_format: outputFormat,
+ model: selectedModel || conv.model || globalDefaultModel || undefined,
+ intent_mode: 'force_orchestrate',
+ selected_expert: requestExpert as unknown as Record,
+ selected_mcps: selectedMcps,
+ },
+ );
+ controllerRef.current = controller;
+ void controller.promise.catch(() => { setIsGenerating(false); });
+ // 与 onComplete 双保险:确保刚写入 resetMsg 的本地状态也被持久化(避免竞态丢保存)
+ window.setTimeout(() => { void persistConversation(convId); }, 0);
+ },
+ [findMessage, globalDefaultModel, mcpModal.expert, mcpModal.messageId, outputFormat, patchMessageById, persistConversation, selectedModel],
+ );
+
+ /**
+ * 用户中止生成:停止所有流式控制器,并把「未完成的助手消息」标为可续跑状态。
+ * - 将 `running` 的主/子步骤重置为 `pending`,清除当前指针。
+ * - 若正处于 `generate` 或 `intent-detecting`,将 `phase` 置为 `paused`,配合下次 `handleSend` 传 `last_steps`。
+ * - 异步触发一次 `persistConversation`,把 paused 态写入服务端。
+ */
+ const handleStop = useCallback(() => {
+ const targetId = activeConvId;
+ controllerRef.current?.stop();
+ controllerRef.current = null;
+ expertControllerRef.current?.stop();
+ expertControllerRef.current = null;
+ setIsGenerating(false);
+ setMessages((prev) =>
+ prev.map((message) => {
+ if (message.role !== 'assistant' || message.mainStepStatus?.final === 'done') return message;
+ const main = { ...(message.mainStepStatus ?? DEFAULT_MAIN_STATUS) };
+ const sub = { ...(message.subStepStatus ?? DEFAULT_SUB_STATUS) };
+ (Object.keys(main) as MainStepKey[]).forEach((key) => {
+ if (main[key] === 'running') main[key] = 'pending';
+ });
+ (Object.keys(sub) as SubStepKey[]).forEach((key) => {
+ if (sub[key] === 'running') sub[key] = 'pending';
+ });
+ // 暂停态:后端 continue_analysis 依赖 current_phase 与 last_steps
+ const isInGenerate = message.phase === 'generate' || message.phase === 'intent-detecting';
+ return {
+ ...message,
+ phase: isInGenerate ? ('paused' as const) : message.phase,
+ mainStepStatus: main,
+ subStepStatus: sub,
+ currentMainStep: undefined,
+ currentSubStep: undefined,
+ };
+ }),
+ );
+ if (targetId) {
+ window.setTimeout(() => { void persistConversation(targetId); }, 0);
+ }
+ }, [activeConvId, persistConversation, setMessages]);
+
+ /** 拖拽调整流程图沙箱宽度:在最小/最大值与主内容区剩余宽度之间取交集。 */
+ const handleResizeMove = useCallback((event: MouseEvent) => {
+ const resizeData = resizeRef.current;
+ const container = containerRef.current;
+ if (!resizeData || !container) return;
+ const containerRect = container.getBoundingClientRect();
+ const delta = resizeData.startX - event.clientX;
+ let next = resizeData.startWidth + delta;
+ next = Math.max(MIN_SANDBOX_WIDTH, Math.min(MAX_SANDBOX_WIDTH, next));
+ next = Math.min(next, containerRect.width - 480);
+ setSandboxWidth(next);
+ }, []);
+
+ /** 结束拖拽:恢复光标与文本选择,移除全局鼠标监听。 */
+ const handleResizeEnd = useCallback(() => {
+ resizeRef.current = null;
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ window.removeEventListener('mousemove', handleResizeMove);
+ window.removeEventListener('mouseup', handleResizeEnd);
+ }, [handleResizeMove]);
+
+ /** 开始拖拽沙箱左边界:记录初始宽度并注册 `mousemove` / `mouseup`。 */
+ const handleResizeStart = useCallback(
+ (event: React.MouseEvent) => {
+ event.preventDefault();
+ resizeRef.current = { startX: event.clientX, startWidth: sandboxWidth };
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ window.addEventListener('mousemove', handleResizeMove);
+ window.addEventListener('mouseup', handleResizeEnd);
+ },
+ [handleResizeEnd, handleResizeMove, sandboxWidth],
+ );
+
+ /** 组件卸载时:中止主编排流并清理拖拽监听,防止泄漏与幽灵请求。 */
+ useEffect(() => {
+ return () => {
+ controllerRef.current?.stop();
+ window.removeEventListener('mousemove', handleResizeMove);
+ window.removeEventListener('mouseup', handleResizeEnd);
+ };
+ }, [handleResizeEnd, handleResizeMove]);
+
+ // ---------------------------------------------------------------------------
+ // 布局:顶栏(标题 + 进度 + 格式/历史/沙箱开关)| 侧栏历史 | 主列对话+输入 | 可选沙箱/全屏
+ // ---------------------------------------------------------------------------
+ return (
+
+
+
+
+
+
+
+
+
+ 6 bf推演问答1
+
+
+ 基于 6 步法的结构化推演与可视化生成
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 模型
+
+
+
+
+ {/* Output format toggle */}
+
+
+
+
+
+
+
+
+ {outputFormat === 'structured' && (
+
+ )}
+
+
+
+
+
+
setHistoryOpen(false)}
+ onOpen={() => setHistoryOpen(true)}
+ onSelect={handleSelectConversation}
+ onNew={() => void handleNewConversation()}
+ onDelete={(id) => void handleDeleteConversation(id)}
+ onRename={(id, title) => void handleRenameConversation(id, title)}
+ onClearAll={() => void handleClearAllConversations()}
+ />
+
+
+
+
+
+
void handleSend()}
+ onStop={handleStop}
+ placeholder="输入你想要推演的问题或场景..."
+ isSending={isGenerating}
+ isDarkMode={isDark}
+ showBorderTop
+ showUpload
+ onUploadFile={async () => undefined}
+ />
+
+
+ {sandboxOpen ? (
+ setSandboxOpen(false)}
+ onOpenFullscreen={() => setFullscreenOpen(true)}
+ />
+ ) : null}
+
+
+ {fullscreenOpen ? (
+
setFullscreenOpen(false)} />
+ ) : null}
+
+
+
+ );
+}
diff --git a/web-code/components/angentchat/SixStepConversation.tsx b/web-code/components/angentchat/SixStepConversation.tsx
new file mode 100644
index 00000000..ecc00b5e
--- /dev/null
+++ b/web-code/components/angentchat/SixStepConversation.tsx
@@ -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 = ({ role, active }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ const Icon = role === 'assistant' ? Bot : UserRound;
+ return (
+
+
+ {role === 'assistant' && active && (
+
+ )}
+
+ );
+};
+
+const InfoStage: React.FC<{ data: InfoStageData; running: boolean }> = ({ data, running }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ return (
+
+
+
+
+
+
+ 信息准备
+ {data.complete && (
+ 已完成
+ )}
+ {running && (
+ 收集中
+ )}
+
+
+ {running ? '正在收集热搜与上下文信息...' : '已整合信息准备素材'}
+
+
+
+ }
+ >
+ {data.content ? (
+
+ {data.content}
+
+ ) : (
+
+ {running ? '正在获取信息...' : '无内容'}
+
+ )}
+
+ );
+};
+
+const MarkdownMessage: React.FC<{ content: string; isDark: boolean }> = ({ content, isDark }) => (
+
+);
+
+const PurposeStage: React.FC<{ data: NonNullable['purpose']; running: boolean }> = ({ data, running }) => {
+ if (!data) return null;
+ const Icon = STAGE_ICONS.purpose;
+ const cards = data.cards ?? [];
+ return (
+
+ }
+ >
+
+
+
+ 目的卡片列表
+ {cards.length} 张
+
+ }
+ >
+ {cards.length === 0 ? (
+ 等待生成目的卡片...
+ ) : (
+
+ {cards.map((card) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+const ActorStage: React.FC<{
+ data: NonNullable['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();
+ 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 (
+
+ }
+ >
+
+
+
+ 行为体卡片列表
+ {cards.length} 张
+
+ }
+ >
+ {groups.length === 0 ? (
+ 等待生成行为体卡片...
+ ) : (
+
+ {groups.map((group) => (
+
+
+
+ {group.items.map((card) => (
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+const BehaviorStage: React.FC<{
+ data: NonNullable['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();
+ 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 (
+
+ }
+ >
+
+
+
+ 预期行为与驱动因素卡片列表
+ {cards.length} 张
+
+ }
+ >
+ {groups.length === 0 ? (
+ 等待生成预期行为与驱动因素卡片...
+ ) : (
+
+ {groups.map((group) => (
+
+
+
+ {group.items.map((card) => (
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+const NarrativeStage: React.FC<{
+ data: NonNullable['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();
+ 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 (
+
+ }
+ >
+
+
+
+ 叙事卡片列表
+ {cards.length} 张
+
+ }
+ >
+ {groups.length === 0 ? (
+ 等待生成叙事卡片...
+ ) : (
+
+ {groups.map((group) => (
+
+
+
+ {group.items.map((card) => (
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+const FinalAnswer: React.FC<{ text: string; running: boolean }> = ({ text, running }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ if (!text && !running) return null;
+ return (
+
+
+
+ 最终结果
+ {running && (
+
+ 正在汇总输出
+
+ )}
+
+
+ {text || '准备汇总输出...'}
+
+
+ );
+};
+
+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 = {
+ purpose: '目的',
+ actor: '行为体',
+ behavior_factor: '预期行为和驱动因素',
+ narrative: '叙事',
+ };
+ return (
+
+
+ 已选专家
+
+ {message.selectedExpert.name}
+
+ {mcps.length > 0 && (
+ <>
+ ·
+
+ 执行步骤:{mcps.map((k) => labelMap[k] || k).join(' → ')}
+
+ >
+ )}
+
+ );
+};
+
+interface AssistantMessageProps {
+ message: SixStepMessage;
+ active: boolean;
+ expertHandlers?: ExpertMatchHandlers;
+}
+
+const AssistantMessage: React.FC = ({ 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 (
+
+
+
+
+ {/* Expert match phase: show expert cards before stages */}
+ {inExpertPhase && expertMatch && (
+
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 && (
+
+ )}
+
+ {/* Selected expert / MCP summary once moved to generate phase */}
+ {message.selectedExpert && message.phase !== 'expert-match' && (
+
+ )}
+
+ {showAnalysisContent && (
+ <>
+ {/* Info preparing placeholder when no stage data yet */}
+ {isInfoRunning && noStageYet && (
+
+
+
+
+ 正在调用信息准备,收集热搜与上下文...
+
+
+
+ )}
+
+ {/* Info prepare result (collapsible) */}
+ {stages.info && }
+
+ {(isMarkdown || isFollowUp) ? (
+ /* ---- Markdown / Follow-up mode ---- */
+ <>
+ {(message.markdownContent || generating || followUpGenerating) && (
+ message.markdownContent ? (
+
+ ) : (
+
+
+
+
+ {isFollowUp ? '正在回答你的问题...' : '正在生成推演内容...'}
+
+
+
+ )
+ )}
+ {(message.errors?.length ?? 0) > 0 && (
+
+ )}
+ >
+ ) : (
+ /* ---- Structured mode ---- */
+ <>
+ {stages.purpose && }
+ {stages.actor && }
+ {stages.behavior && }
+ {stages.narrative && (
+
+ )}
+ {(message.finalAnswer || isFinalRunning) && (
+
+ )}
+ {(message.errors?.length ?? 0) > 0 && (
+
+ )}
+ >
+ )}
+ >
+ )}
+
+
+
+ );
+};
+
+const UserMessage: React.FC<{ message: SixStepMessage }> = ({ message }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ return (
+
+ );
+};
+
+const SixStepConversation: React.FC = ({ messages, isGenerating, activeMessageId, expertHandlers }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ const scrollRef = useRef(null);
+
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ el.scrollTop = el.scrollHeight;
+ }, [messages, isGenerating]);
+
+ if (messages.length === 0) {
+ return (
+
+
+
+
+
开始 6 步法推演
+
+ 输入问题或场景描述,模型会按照"信息准备 → 6 步法推演 → 最终结果"的流程逐步生成结构化输出。
+
+
+ );
+ }
+
+ return (
+
+ {messages.map((msg) => (
+
+ {msg.role === 'user' ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+ );
+};
+
+export default SixStepConversation;
diff --git a/web-code/components/angentchat/SixStepFlowFullscreen.tsx b/web-code/components/angentchat/SixStepFlowFullscreen.tsx
new file mode 100644
index 00000000..4bd42ee0
--- /dev/null
+++ b/web-code/components/angentchat/SixStepFlowFullscreen.tsx
@@ -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;
+ 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 = {
+ 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) => {
+ if (!canToggle) return;
+ e.stopPropagation();
+ onToggleCollapse?.(node.id);
+ };
+ const handleMouseDown = (e: React.MouseEvent) => {
+ if (canToggle) e.stopPropagation();
+ };
+ return (
+
+
+
+ {collapsible && (
+
+ {collapsed ? '+' : '-'}
+
+ )}
+
+
+
+
+
+ {VARIANT_LABEL[node.variant]}
+
+
+
+ {node.title}
+
+
+ {node.detail || '—'}
+
+
+
+
+ );
+};
+
+const ToolbarButton: React.FC<{ title: string; onClick: () => void; isDark: boolean; children: React.ReactNode }> = ({ title, onClick, isDark, children }) => (
+
+);
+
+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 = ({ 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) => {
+ 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 (
+
+
+ 缩略图
+
+
+
+ );
+};
+
+const SixStepFlowFullscreen: React.FC = ({ 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>(new Set());
+ const [displayedNodeIds, setDisplayedNodeIds] = useState>(new Set());
+ const [enteringNodeIds, setEnteringNodeIds] = useState>(new Set());
+ const [exitingNodeIds, setExitingNodeIds] = useState>(new Set());
+ const [displayedEdgeIds, setDisplayedEdgeIds] = useState>(new Set());
+ const [exitingEdgeIds, setExitingEdgeIds] = useState>(new Set());
+ const containerRef = useRef(null);
+ const svgRef = useRef(null);
+ const panStart = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
+ const transitionTimerRef = useRef(null);
+
+ const nodeMap = useMemo(() => new Map(layout.nodes.map((node) => [node.id, node])), [layout.nodes]);
+
+ const targetVisibleNodeSet = useMemo(() => {
+ const visible = new Set();
+ 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();
+ 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();
+ 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 (
+
+
+
+
+
+
+
+
+ {statusTitle}
+
+
+ 全屏视图 · 完整内容展示
+
+
+
+
+
+
+
+
+
+
+
+
+ {Math.round(transform.scale * 100)}%
+
+
+
+
+
+
+
+
+ {(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
+ const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
+ const active = activePair === pair;
+ return (
+
+
+ {EDGE_LABEL[pair]}
+
+ );
+ })}
+
+
+
+ {renderedNodes.length <= 1 ? (
+
+
+
等待推演开始
+
推演产出将以流程图形式呈现
+
+ ) : (
+
+ )}
+
+ {targetVisibleNodeSet.size > 1 && (
+
+ targetVisibleNodeSet.has(node.id)),
+ width: layout.width,
+ height: layout.height,
+ }}
+ transform={transform}
+ viewportSize={viewportSize}
+ onJump={handleMinimapJump}
+ isDark={isDark}
+ />
+
+ )}
+
+ 点击父节点可收起/展开子节点
+
+
+
+ );
+};
+
+export default SixStepFlowFullscreen;
diff --git a/web-code/components/angentchat/SixStepFlowSandbox.tsx b/web-code/components/angentchat/SixStepFlowSandbox.tsx
new file mode 100644
index 00000000..840ce391
--- /dev/null
+++ b/web-code/components/angentchat/SixStepFlowSandbox.tsx
@@ -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 = {
+ 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> = {
+ agent: Bot,
+ purpose: Target,
+ actor: UserCheck,
+ behavior: TrendingUp,
+ driver: Zap,
+ narrative: ScrollText,
+};
+
+// Colored icon wrapper per variant (dark / light)
+const VARIANT_ICON_CLS: Record = {
+ 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) => {
+ if (!canToggle) return;
+ e.stopPropagation();
+ onToggleCollapse(node.id);
+ };
+ const handleMouseDown = (e: React.MouseEvent) => {
+ if (canToggle) e.stopPropagation();
+ };
+ return (
+
+ {appearing && (
+
+ )}
+
+
+ {collapsible && (
+
+ {collapsed ? '+' : '-'}
+
+ )}
+
+
+
+
+ {VARIANT_ABBREV[node.variant]}
+
+
+
+
+ );
+};
+
+const ToolbarButton: React.FC<{ title: string; onClick: () => void; isDark: boolean; children: React.ReactNode }> = ({ title, onClick, isDark, children }) => (
+
+);
+
+const SixStepFlowSandbox: React.FC = ({ 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>(new Set());
+ const containerRef = useRef(null);
+ const panStart = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
+
+ // Track newly appearing nodes for animation
+ const seenIdsRef = useRef>(new Set());
+ const [appearingIds, setAppearingIds] = useState>(new Set());
+
+ const nodeMap = useMemo(() => {
+ return new Map(layout.nodes.map((node) => [node.id, node]));
+ }, [layout.nodes]);
+
+ const visibleNodeSet = useMemo(() => {
+ const visible = new Set();
+ 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();
+ 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 (
+
+
+
+
+
+
+
+
+
+
+ {statusTitle}
+
+
+ 简化视图 · 节点缩写显示
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Math.round(transform.scale * 100)}%
+
+
+
+
+
+
+
+
+ {(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
+ const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
+ const active = activePair === pair;
+ return (
+
+
+ {EDGE_LABEL[pair]}
+
+ );
+ })}
+
+
+
+ {visibleNodes.length <= 1 ? (
+
+
+
等待推演开始
+
推演产出将以流程图形式呈现
+
+ ) : (
+
+ )}
+
+
+ {VARIANT_LABEL.purpose}=md · {VARIANT_LABEL.actor}=xwt · {VARIANT_LABEL.behavior}=yqxw · {VARIANT_LABEL.driver}=qdys · {VARIANT_LABEL.narrative}=xs
+
+
+ 点击父节点可收起/展开子节点
+
+
+
+ );
+};
+
+export default SixStepFlowSandbox;
diff --git a/web-code/components/angentchat/SixStepProgress.tsx b/web-code/components/angentchat/SixStepProgress.tsx
new file mode 100644
index 00000000..c4bc74e9
--- /dev/null
+++ b/web-code/components/angentchat/SixStepProgress.tsx
@@ -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;
+ subStatus: Record;
+ collapsed?: boolean;
+ onToggleCollapse?: () => void;
+}
+
+interface StepItem {
+ key: T;
+ title: string;
+ desc: string;
+ icon: React.ComponentType<{ className?: string }>;
+ color: { dark: string; light: string };
+}
+
+const MAIN_STEPS: StepItem[] = [
+ { 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[] = [
+ { 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 ;
+ if (status === 'running') return ;
+ return ;
+}
+
+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;
+ subStatus: Record;
+ isDark: boolean;
+ onExpand: () => void;
+}> = ({ mainStatus, subStatus, isDark, onExpand }) => {
+ const sixstepUnlocked = mainStatus.sixstep !== 'pending';
+ return (
+
+
+
+
+ {MAIN_STEPS.map((step) => {
+ const status = mainStatus[step.key];
+ const Icon = step.icon;
+ const clr = step.color[isDark ? 'dark' : 'light'];
+ return (
+
+
+ {status === 'running'
+ ?
+ :
+ }
+
+
+
+ {step.key === 'sixstep' && sixstepUnlocked && (
+
+ {SUB_STEPS.map((sub) => {
+ const ss = subStatus[sub.key];
+ const SubIcon = sub.icon;
+ const sc = sub.color[isDark ? 'dark' : 'light'];
+ return (
+
+ {ss === 'running'
+ ?
+ :
+ }
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+
+ );
+};
+
+// ── Expanded view ─────────────────────────────────────────────────────────────
+const SixStepProgress: React.FC = ({
+ 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 (
+ onToggleCollapse?.()}
+ />
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Steps */}
+
+ {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 (
+
+ {idx < MAIN_STEPS.length - 1 && (
+
+ )}
+
+
+
+ {status === 'running'
+ ?
+ :
+ }
+
+
+
+
{step.title}
+
+
+ {status === 'done' ? '已完成' : status === 'running' ? '生成中' : '待开始'}
+
+
+
{step.desc}
+
+
+
+ {/* 6步法子阶段 */}
+ {showSubBlock && (
+
+
+ 6 步法 · 子阶段
+
+
+ {SUB_STEPS.map((sub, subIdx) => {
+ const ss = subStatus[sub.key];
+ const SubIcon = sub.icon;
+ const sc = sub.color[isDark ? 'dark' : 'light'];
+ return (
+
+ {subIdx < SUB_STEPS.length - 1 && (
+
+ )}
+
+
+
+ {ss === 'running'
+ ?
+ :
+ }
+
+
+
+
+ );
+ })}
+
+ {sixstepRunning && (
+
+
+ 推演正在进行...
+
+ )}
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default SixStepProgress;
diff --git a/web-code/components/angentchat/SixStepProgressHorizontal.tsx b/web-code/components/angentchat/SixStepProgressHorizontal.tsx
new file mode 100644
index 00000000..9fee1d24
--- /dev/null
+++ b/web-code/components/angentchat/SixStepProgressHorizontal.tsx
@@ -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;
+ subStatus: Record;
+ isDark: boolean;
+}
+
+interface StepDef {
+ key: T;
+ title: string;
+ desc: string;
+ icon: React.ComponentType<{ className?: string }>;
+ color: { dark: string; light: string };
+}
+
+const MAIN_STEPS: StepDef[] = [
+ { 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[] = [
+ { 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 = ({ 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 (
+
+
+ {status === 'running'
+ ?
+ :
+ }
+
+
+ {status === 'done' && (
+
+
+
+ )}
+
+
+
{title}
+
+ {desc}
+
+
+ {status === 'running' && }
+ {statusLabel(status)}
+
+
+
+ );
+};
+
+const SixStepProgressHorizontal: React.FC = ({ mainStatus, subStatus, isDark }) => {
+ const sixstepUnlocked = mainStatus.sixstep !== 'pending';
+
+ return (
+
+ {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 (
+
+ {idx > 0 && (
+
+ )}
+
+ {isSixStep && sixstepUnlocked && (
+ <>
+
+
+ {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 (
+
+ {sidx > 0 && (
+
+ )}
+
+
+ );
+ })}
+
+ >
+ )}
+
+ );
+ })}
+
+ );
+};
+
+export default SixStepProgressHorizontal;
diff --git a/web-code/components/angentchat/StreamErrorNotice.tsx b/web-code/components/angentchat/StreamErrorNotice.tsx
new file mode 100644
index 00000000..85b240b6
--- /dev/null
+++ b/web-code/components/angentchat/StreamErrorNotice.tsx
@@ -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 = ({ 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 (
+
+
+
+ {expanded && (
+
+ {errors.map((err) => (
+
+
+ {err.level === 'error'
+ ?
+ :
}
+
{err.message}
+
+
+ 错误 ID:{err.errorId}
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default StreamErrorNotice;
diff --git a/web-code/components/angentchat/api.ts b/web-code/components/angentchat/api.ts
new file mode 100644
index 00000000..9895fd56
--- /dev/null
+++ b/web-code/components/angentchat/api.ts
@@ -0,0 +1,1353 @@
+/**
+ * 智能体聊天(六步法 / 意图编排 / 专家匹配)的前端 API 封装模块。
+ *
+ * 主要职责:
+ * - 解析后端 SSE(`event:` / `data:` 行协议),将 POST 流式响应转为异步可迭代的 `{ event, data }`。
+ * - 把后端 `structured_outputs`、图节点等数据映射为前端卡片类型(与 `./types` 对齐)。
+ * - 暴露 `runSixStepDeduction`(直连编排流)、`runIntentOrchestrateStream`(意图识别 + 多分支),
+ * 以及 `runExpertSearch` / `runExpertCreate`(单 MCP 同步调用)。
+ * - 使用分块写入模拟「打字机」效果,使真实接口下的 UI 节奏与 `mockData` 接近。
+ */
+import type {
+ ActorCard,
+ BackendStepResult,
+ BehaviorDriverCard,
+ ExpertCard,
+ ExpertMatchData,
+ MainStepKey,
+ NarrativeCard,
+ PurposeCard,
+ SelectableMcpKey,
+ SixStepMessage,
+ StreamErrorItem,
+ SubStepKey,
+} from './types';
+import type { SixStepMockController } from './mockData';
+
+// ------------------------------------------------------------------ //
+// SSE:POST + ReadableStream → 异步生成器,逐条产出解析后的事件 //
+// ------------------------------------------------------------------ //
+
+/** 单条 SSE 解析结果:`event` 为事件名,`data` 为已 JSON 解析的负载对象。 */
+interface SSEEvent {
+ event: string;
+ data: Record;
+}
+
+/**
+ * 以 POST 发起请求,按 SSE 规范读取响应体并 yield 事件。
+ * - 缓冲不完整的行,按 `\n` 切分;`event:` 与紧随其后的 `data:` 配对。
+ * - `data` 行需为合法 JSON;解析失败则静默跳过该行。
+ * @throws 非 2xx 或响应无 body 时抛出带 HTTP 状态信息的 Error
+ */
+async function* readSSE(url: string, body: object, signal: AbortSignal): AsyncGenerator {
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal,
+ });
+ if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
+
+ const reader = resp.body.getReader();
+ const decoder = new TextDecoder();
+ let buf = '';
+ let eventName = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buf += decoder.decode(value, { stream: true });
+ const lines = buf.split('\n');
+ buf = lines.pop() ?? '';
+ for (const line of lines) {
+ if (line.startsWith('event: ')) {
+ eventName = line.slice(7).trim();
+ } else if (line.startsWith('data: ')) {
+ try {
+ const data = JSON.parse(line.slice(6)) as Record;
+ yield { event: eventName, data };
+ } catch {
+ // 非 JSON 的 data 行忽略,避免半包或心跳污染中断整条流
+ }
+ eventName = '';
+ }
+ }
+ }
+}
+
+// ------------------------------------------------------------------ //
+// 类型映射:后端列表项 → 前端卡片(字段名与 parent_* 与后端约定一致) //
+// ------------------------------------------------------------------ //
+
+/** 将后端「目的」结构化项转为 `PurposeCard[]`,缺失 id 时用占位 id。 */
+function toPurposeCards(items: Record[]): PurposeCard[] {
+ return items.map((it, idx) => ({
+ id: (it.id as string) || `purpose-${idx + 1}`,
+ index: idx + 1,
+ title: (it.title as string) || '',
+ reason: (it.reason as string) || '',
+ }));
+}
+
+/** 行为体卡片:`parent_purpose_id` 回退到第一个目的或空串,保证 UI 可挂载。 */
+function toActorCards(items: Record[], purposes: PurposeCard[]): ActorCard[] {
+ return items.map((it, idx) => ({
+ id: (it.id as string) || `actor-${idx + 1}`,
+ index: idx + 1,
+ purposeId: (it.parent_purpose_id as string) || purposes[0]?.id || '',
+ title: (it.title as string) || '',
+ content: (it.content as string) || '',
+ }));
+}
+
+/** 预期行为与驱动因素:`parent_actor_id` 关联行为体;字段名与后端 snake_case 对应。 */
+function toBehaviorCards(items: Record[], actors: ActorCard[]): BehaviorDriverCard[] {
+ return items.map((it, idx) => ({
+ id: (it.id as string) || `behavior-${idx + 1}`,
+ index: idx + 1,
+ actorId: (it.parent_actor_id as string) || actors[0]?.id || '',
+ expectedBehavior: (it.expected_behavior as string) || '',
+ driver: (it.driving_factor as string) || '',
+ }));
+}
+
+/** 叙事卡片:`parent_factor_id` 关联上一步的驱动因素节点。 */
+function toNarrativeCards(items: Record[], behaviors: BehaviorDriverCard[]): NarrativeCard[] {
+ return items.map((it, idx) => ({
+ id: (it.id as string) || `narrative-${idx + 1}`,
+ index: idx + 1,
+ driverId: (it.parent_factor_id as string) || behaviors[0]?.id || '',
+ title: (it.title as string) || '',
+ reason: (it.reason as string) || '',
+ }));
+}
+
+// ------------------------------------------------------------------ //
+// 打字机模拟:按固定步长追加字符,与 mock 侧流式体验一致 //
+// ------------------------------------------------------------------ //
+
+/**
+ * 将完整文本按小块定时写入 `write`,直到写完或 `isCancelled()` 为 true。
+ * 用于结构化模式下展示 thinking 与最终汇总,避免界面瞬间跳变。
+ */
+function typeText(
+ text: string,
+ write: (chunk: string) => void,
+ isCancelled: () => boolean,
+): Promise {
+ return new Promise((resolve) => {
+ const step = 5;
+ let pos = 0;
+ const tick = () => {
+ if (isCancelled()) { resolve(); return; }
+ pos = Math.min(pos + step, text.length);
+ write(text.slice(0, pos));
+ if (pos < text.length) {
+ setTimeout(tick, 20);
+ } else {
+ resolve();
+ }
+ };
+ tick();
+ });
+}
+
+// ------------------------------------------------------------------ //
+// 结构化模式:根据已收集各阶段卡片拼出一份 Markdown 风格的最终摘要 //
+// ------------------------------------------------------------------ //
+
+/** 从 `message.stages` 抽取各阶段标题/行为描述,生成给用户看的综合结论文案。 */
+function buildFinalFromStages(msg: SixStepMessage): string {
+ const purposes = (msg.stages?.purpose?.cards ?? []).map((c) => c.title).join('、');
+ const actors = (msg.stages?.actor?.cards ?? []).map((c) => c.title).join('、');
+ const behaviors = (msg.stages?.behavior?.cards ?? []).map((c) => c.expectedBehavior).join('、');
+ const narratives = (msg.stages?.narrative?.cards ?? []).map((c) => c.title).join('、');
+ return [
+ '综合本次 6 步法推演结果,可以得到如下战略要点:\n',
+ purposes ? `1. **目的**:${purposes}。` : '',
+ actors ? `2. **行为体**:${actors}。` : '',
+ behaviors ? `3. **预期行为与驱动因素**:${behaviors}。` : '',
+ narratives ? `4. **叙事**:${narratives}。` : '',
+ '\n后续可基于该推演继续生成传播节奏、渠道分工与内容资产清单。',
+ ]
+ .filter(Boolean)
+ .join('\n');
+}
+
+// ------------------------------------------------------------------ //
+// 后端步骤 key → 前端子步骤 `SubStepKey`(编排事件里 key 与 UI 对齐) //
+// ------------------------------------------------------------------ //
+
+/** 编排层 `behavior_factor` 在前端统一为 `behavior`。 */
+const KEY_MAP: Record = {
+ purpose: 'purpose',
+ actor: 'actor',
+ behavior_factor: 'behavior',
+ narrative: 'narrative',
+};
+
+// ------------------------------------------------------------------ //
+// 主入口:与 mockData.runSixStepDeduction 相同的控制器形态(message/stop/promise) //
+// ------------------------------------------------------------------ //
+
+/** `runSixStepDeduction` / `runIntentOrchestrateStream` 共用的回调选项。 */
+export interface RunOptions {
+ /** 任意阶段状态或内容变化时调用;实现方应视为不可变快照触发重渲染。 */
+ onUpdate: (message: SixStepMessage) => void;
+ /** 整条流正常结束、被取消后的收尾、或出错并写入 message 后调用(具体见各分支)。 */
+ onComplete?: () => void;
+}
+
+/**
+ * 执行六步法编排(直连 `/api/orchestrate/stream`,无意图分支)。
+ * - 根据 `requestBody.output_format` 区分结构化卡片流与 Markdown 整段流。
+ * - 根据 `selected_mcps` 过滤 `mcp_list` 中实际执行的子步骤顺序。
+ * - 监听 `start` / `step_start` / `delta` / `step_end` / `flow` / `done`,同步 `message` 并 `emit`。
+ * @returns 与 mock 一致:`stop()` 中止 fetch;`promise` 在流结束后 settle。
+ */
+
+const backUrl = 'https://ch1.b.uat.4.cn/zncmback';
+export function runSixStepDeduction(
+ message: SixStepMessage,
+ options: RunOptions,
+ requestBody?: Record,
+): SixStepMockController {
+ // 就地更新传入的 `message`;emit 时对 `stages` 做浅拷贝,便于依赖该引用的 UI 刷新。
+ const abortCtrl = new AbortController();
+ let cancelled = false;
+
+ const outputFormat = (requestBody?.output_format as string) || 'structured';
+ const isMarkdown = outputFormat === 'markdown';
+
+ message.outputFormat = outputFormat as 'structured' | 'markdown';
+
+ const stop = () => {
+ cancelled = true;
+ abortCtrl.abort();
+ };
+
+ const emit = () => {
+ if (cancelled) return;
+ options.onUpdate({ ...message, stages: { ...message.stages } });
+ };
+
+ const setMain = (key: MainStepKey, status: 'pending' | 'running' | 'done') => {
+ message.mainStepStatus = {
+ ...(message.mainStepStatus ?? { info: 'pending', sixstep: 'pending', final: 'pending' }),
+ [key]: status,
+ };
+ if (status === 'running') message.currentMainStep = key;
+ };
+
+ const setSub = (key: SubStepKey, status: 'pending' | 'running' | 'done') => {
+ message.subStepStatus = {
+ ...(message.subStepStatus ?? { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' }),
+ [key]: status,
+ };
+ if (status === 'running') message.currentSubStep = key;
+ };
+
+ const promise = (async () => {
+ setMain('info', 'running');
+ emit();
+
+ const userInput = (requestBody?.user_input as string) || '';
+ const selectedMcps = (requestBody?.selected_mcps as SelectableMcpKey[] | undefined);
+ const FIXED_ORDER: SelectableMcpKey[] = ['purpose', 'actor', 'behavior_factor', 'narrative'];
+ const filteredMain = selectedMcps && selectedMcps.length > 0
+ ? FIXED_ORDER.filter((key) => selectedMcps.includes(key))
+ : FIXED_ORDER;
+ const mcpList = ['info_prepare', ...filteredMain];
+
+ const body = {
+ user_input: userInput,
+ mcp_list: mcpList,
+ output_format: outputFormat,
+ max_items: 3,
+ ...requestBody,
+ };
+
+ let purposeCards: PurposeCard[] = [];
+ let actorCards: ActorCard[] = [];
+ let behaviorCards: BehaviorDriverCard[] = [];
+ /** 当前子步骤开始时 `markdownContent` 的长度;用于判断本步是否收到过 `delta`,避免与 `step_end` 重复拼接。 */
+ let markdownLenAtStepStart = 0;
+
+ let completed = false;
+ try {
+ for await (const { event, data } of readSSE(`${backUrl}/api/orchestrate/stream`, body, abortCtrl.signal)) {
+ if (cancelled) break;
+
+ // ---- start:流已建立(尚未必有步骤数据)----
+ if (event === 'start') {
+ // 保持连接感知;若链中无 info_prepare,后续仍可能仅靠其它事件推进,此处仅做一次 emit。
+ emit();
+ }
+
+ // ---- step_start:某 MCP 子步骤开始 ----
+ if (event === 'step_start') {
+ const backendKey = data.key as string;
+
+ if (backendKey === 'info_prepare') {
+ message.stages = {
+ ...message.stages,
+ info: { content: '', complete: false },
+ };
+ emit();
+ } else {
+ const key = KEY_MAP[backendKey];
+ if (key) {
+ if (message.mainStepStatus?.info !== 'done') {
+ setMain('info', 'done');
+ }
+ if (message.mainStepStatus?.sixstep !== 'running') {
+ setMain('sixstep', 'running');
+ }
+ setSub(key, 'running');
+ markdownLenAtStepStart = message.markdownContent?.length ?? 0;
+ if (!isMarkdown) {
+ message.stages = {
+ ...message.stages,
+ [key]: { thinking: '', cards: [], complete: false },
+ };
+ }
+ emit();
+ }
+ }
+ }
+
+ // ---- delta:Markdown 模式下后端按片段推送正文 ----
+ if (event === 'delta' && isMarkdown) {
+ const text = (data.text as string) || '';
+ if (text) {
+ message.markdownContent = (message.markdownContent || '') + text;
+ emit();
+ }
+ }
+
+ // ---- step_end:单步结束,携带 thinking / structured_outputs / answer 等 ----
+ if (event === 'step_end') {
+ const backendKey = data.key as string;
+
+ // 信息准备阶段:合并 answer 与 thinking 作为 info 区展示内容
+ if (backendKey === 'info_prepare') {
+ const answer = (data.answer as string) || (data.text as string) || '';
+ const thinking = (data.thinking as string) || '';
+ const content = answer || thinking;
+ message.stages = {
+ ...message.stages,
+ info: { content, complete: true },
+ };
+ if (message.mainStepStatus?.info !== 'done') {
+ setMain('info', 'done');
+ }
+ emit();
+ continue;
+ }
+
+ const key = KEY_MAP[backendKey];
+ if (!key) continue;
+
+ if (isMarkdown) {
+ // 若本步未收到 delta,则从 step_end 的 answer/text/thinking 补一段,避免空白步
+ const gotDeltasForStep = (message.markdownContent?.length ?? 0) > markdownLenAtStepStart;
+ if (!gotDeltasForStep) {
+ const answer = (data.answer as string) || (data.text as string) || (data.thinking as string) || '';
+ if (answer) {
+ const sep = message.markdownContent ? '\n\n' : '';
+ message.markdownContent = (message.markdownContent || '') + sep + answer;
+ }
+ }
+ setSub(key, 'done');
+ message.stages = {
+ ...message.stages,
+ [key]: { thinking: '', cards: [], complete: true },
+ };
+ emit();
+ } else {
+ // 结构化模式:先打字机展示 thinking,再逐张追加卡片(与 mock 节奏一致)
+ const thinking = (data.thinking as string) || '';
+ const rawItems = (data.structured_outputs as Record[]) || [];
+
+ await typeText(
+ thinking,
+ (chunk) => {
+ message.stages = {
+ ...message.stages,
+ [key]: { ...(message.stages?.[key] as object), thinking: chunk },
+ };
+ emit();
+ },
+ () => cancelled,
+ );
+ if (cancelled) break;
+
+ let cards: PurposeCard[] | ActorCard[] | BehaviorDriverCard[] | NarrativeCard[] = [];
+ if (key === 'purpose') {
+ cards = purposeCards = toPurposeCards(rawItems);
+ } else if (key === 'actor') {
+ cards = actorCards = toActorCards(rawItems, purposeCards);
+ } else if (key === 'behavior') {
+ cards = behaviorCards = toBehaviorCards(rawItems, actorCards);
+ } else if (key === 'narrative') {
+ cards = toNarrativeCards(rawItems, behaviorCards);
+ }
+
+ for (const card of cards) {
+ if (cancelled) break;
+ const existing = (message.stages?.[key]?.cards ?? []) as typeof cards;
+ message.stages = {
+ ...message.stages,
+ [key]: {
+ ...(message.stages?.[key] as object),
+ cards: [...existing, card],
+ },
+ };
+ emit();
+ await new Promise((r) => setTimeout(r, 240));
+ }
+ if (cancelled) break;
+
+ message.stages = {
+ ...message.stages,
+ [key]: { ...(message.stages?.[key] as object), complete: true },
+ };
+ setSub(key, 'done');
+ emit();
+ }
+ }
+
+ // ---- flow:后端在全部步骤后下发完整图;用于补全未单独收到 step_end 的阶段(如部分 MCP 被跳过)----
+ if (event === 'flow' && !isMarkdown) {
+ const nodes = (data.nodes as Array>) || [];
+ const itemsOf = (type: string) =>
+ nodes
+ .filter((n) => n.type === type)
+ .map((n) => (n.data as Record) || {});
+
+ const flowPurposes = toPurposeCards(itemsOf('purpose'));
+ const flowActors = toActorCards(itemsOf('actor'), flowPurposes);
+ const flowBehaviors = toBehaviorCards(itemsOf('behavior'), flowActors);
+ const flowNarratives = toNarrativeCards(itemsOf('narrative'), flowBehaviors);
+
+ const fillStage = (
+ key: K,
+ cards: PurposeCard[] | ActorCard[] | BehaviorDriverCard[] | NarrativeCard[],
+ ) => {
+ const existing = (message.stages?.[key]?.cards ?? []) as typeof cards;
+ if (existing.length === 0 && cards.length > 0) {
+ message.stages = {
+ ...message.stages,
+ [key]: { thinking: '', cards, complete: true },
+ };
+ if (key === 'purpose') purposeCards = cards as PurposeCard[];
+ if (key === 'actor') actorCards = cards as ActorCard[];
+ if (key === 'behavior') behaviorCards = cards as BehaviorDriverCard[];
+ }
+ };
+ fillStage('purpose', flowPurposes);
+ fillStage('actor', flowActors);
+ fillStage('behavior', flowBehaviors);
+ fillStage('narrative', flowNarratives);
+ emit();
+ }
+
+ // ---- done:整条编排结束 ----
+ if (event === 'done') {
+ if (cancelled) break;
+
+ // 强制各子步骤为 done,避免漏事件导致进度条/沙盘卡在中间态
+ if (message.mainStepStatus?.info !== 'done') setMain('info', 'done');
+ message.subStepStatus = {
+ purpose: 'done',
+ actor: 'done',
+ behavior: 'done',
+ narrative: 'done',
+ };
+ setMain('sixstep', 'done');
+
+ if (isMarkdown) {
+ // Markdown 无单独「最终答案」主步骤,直接标记 final 完成
+ setMain('final', 'done');
+ message.currentMainStep = undefined;
+ message.currentSubStep = undefined;
+ emit();
+ completed = true;
+ options.onComplete?.();
+ } else {
+ setMain('final', 'running');
+ emit();
+
+ const finalText = buildFinalFromStages(message);
+ await typeText(
+ finalText,
+ (chunk) => { message.finalAnswer = chunk; emit(); },
+ () => cancelled,
+ );
+
+ if (!cancelled) {
+ setMain('final', 'done');
+ message.currentMainStep = undefined;
+ message.currentSubStep = undefined;
+ emit();
+ completed = true;
+ options.onComplete?.();
+ }
+ }
+ }
+ }
+ if (!completed && !cancelled) {
+ options.onComplete?.();
+ }
+ } catch (err) {
+ if (!cancelled) {
+ const errMsg = err instanceof Error ? err.message : '网络错误,请检查后端服务是否正常运行';
+ const networkErrItem: StreamErrorItem = {
+ errorId: `local-${Date.now()}`,
+ level: 'error',
+ source: 'network',
+ message: `接口请求失败:${errMsg}`,
+ recoverable: false,
+ createdAt: Date.now(),
+ };
+ message.errors = [...(message.errors ?? []), networkErrItem];
+ if (isMarkdown) {
+ message.markdownContent = (message.markdownContent || '') + `\n\n⚠️ 接口请求失败:${errMsg}`;
+ } else {
+ message.finalAnswer = `⚠️ 接口请求失败:${errMsg}`;
+ }
+ options.onUpdate({ ...message });
+ options.onComplete?.();
+ }
+ }
+ })();
+
+ return { message, stop, promise };
+}
+
+// ------------------------------------------------------------------ //
+// 专家:通过 /invoke 同步调用单个 MCP(查找 / 创建),非 SSE //
+// ------------------------------------------------------------------ //
+
+/** agents 下各 MCP 的 invoke 接口(如 find_expert、create_expert)返回体的常用字段子集(与后端约定,字段可选)。 */
+interface InvokeResponse {
+ status?: string;
+ thinking?: string;
+ structured_outputs?: Array>;
+ markdown?: string;
+ references?: Array>;
+ duration_ms?: number;
+}
+
+/**
+ * 将 invoke 返回的 `structured_outputs` 与 `references` 交叉解析为 `ExpertCard[]`。
+ * - `item.references` 可为 id 字符串或下标,用于定位 `references` 中的详情与 `raw`(含 avatar 等)。
+ */
+const buildExpertCards = (resp: InvokeResponse): ExpertCard[] => {
+ const items = resp.structured_outputs ?? [];
+ const refs = resp.references ?? [];
+ const refById = new Map(refs.map((ref) => [ref.id as string | undefined, ref]));
+
+ return items.map((item, idx) => {
+ const itemRefs = Array.isArray(item.references) ? item.references : [];
+ const firstRefToken = itemRefs[0];
+ const firstRef =
+ typeof firstRefToken === 'string'
+ ? refById.get(firstRefToken)
+ : typeof firstRefToken === 'number'
+ ? refs[firstRefToken]
+ : refs[idx];
+ const raw = (firstRef?.raw as Record | undefined) ?? undefined;
+ const rawId = raw?.id;
+ const backendId = rawId === undefined || rawId === null ? undefined : String(rawId);
+ const id = (item.id as string) || (firstRef?.id as string) || `expert-${Date.now()}-${idx}`;
+ const avatarUrl = (raw?.avatar_url as string | undefined) || undefined;
+ const title = (firstRef?.title as string | undefined) || undefined;
+ return {
+ id,
+ backendId,
+ name: (item.name as string) || title || '未命名专家',
+ detail: (item.detail as string) || (firstRef?.snippet as string) || '',
+ avatarUrl,
+ title,
+ tags: [],
+ isCustom: false,
+ };
+ });
+};
+
+/** 专家查找/创建流程的 UI 回传选项;`onComplete` 可拿到最终 `ExpertMatchData`。 */
+export interface ExpertRunOptions {
+ onUpdate: (data: ExpertMatchData) => void;
+ onComplete?: (data: ExpertMatchData) => void;
+}
+
+/** 与六步法控制器类似:`stop` 中止请求,`promise` 在结束后 settle。 */
+export interface ExpertRunController {
+ stop: () => void;
+ promise: Promise;
+}
+
+/**
+ * 先打字机展示 `thinking`,再一次性写入专家列表,最后逐张「展开」专家卡片(动画节奏)。
+ * @param appendMode 为 true 时在已有 thinking 后拼接新一段(用于连续说明场景)。
+ */
+const animateExpertResponse = async (
+ current: ExpertMatchData,
+ resp: InvokeResponse,
+ isCancelled: () => boolean,
+ onUpdate: (data: ExpertMatchData) => void,
+ appendMode = false,
+): Promise => {
+ let next: ExpertMatchData = appendMode ? current : { ...current, status: current.status, thinking: '', experts: [] };
+ onUpdate(next);
+
+ const thinking = (resp.thinking as string) || '';
+ if (thinking) {
+ await new Promise((resolve) => {
+ const step = 6;
+ let pos = 0;
+ const tick = () => {
+ if (isCancelled()) { resolve(); return; }
+ pos = Math.min(pos + step, thinking.length);
+ const composed = appendMode
+ ? `${current.thinking ? current.thinking + '\n\n' : ''}${thinking.slice(0, pos)}`
+ : thinking.slice(0, pos);
+ next = { ...next, thinking: composed };
+ onUpdate(next);
+ if (pos < thinking.length) setTimeout(tick, 18);
+ else resolve();
+ };
+ tick();
+ });
+ }
+ if (isCancelled()) return next;
+
+ const newCards = buildExpertCards(resp);
+ next = { ...next, experts: newCards, status: 'ready', searchedAt: Date.now() };
+ onUpdate(next);
+
+ for (let idx = 1; idx <= newCards.length; idx += 1) {
+ if (isCancelled()) return next;
+ next = { ...next, experts: newCards.slice(0, idx) };
+ onUpdate(next);
+ await new Promise((r) => setTimeout(r, 180));
+ }
+ return next;
+};
+
+/**
+ * 调用 `find_expert` MCP:根据用户描述检索专家候选。
+ * - `exclude_ids` 通过 `context_list.exclude_expert_ids` 传给后端,避免重复推荐。
+ */
+export function runExpertSearch(
+ baseData: ExpertMatchData,
+ body: { user_input: string; exclude_ids?: string[]; conversation_history?: unknown[] },
+ options: ExpertRunOptions,
+): ExpertRunController {
+ const abortCtrl = new AbortController();
+ let cancelled = false;
+ const stop = () => { cancelled = true; abortCtrl.abort(); };
+
+ const promise = (async () => {
+ let next: ExpertMatchData = {
+ ...baseData,
+ status: 'searching',
+ thinking: '',
+ experts: [],
+ errorMessage: undefined,
+ };
+ options.onUpdate(next);
+ try {
+ const resp = await fetch(`${backUrl}/api/agents/find_expert/invoke`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ user_input: body.user_input,
+ output_format: 'structured',
+ max_items: 3,
+ context_list: body.exclude_ids && body.exclude_ids.length > 0
+ ? [{ key: 'exclude_expert_ids', value: body.exclude_ids.join(',') }]
+ : undefined,
+ }),
+ signal: abortCtrl.signal,
+ });
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ const data = (await resp.json()) as InvokeResponse;
+ if (cancelled) return;
+ next = await animateExpertResponse(next, data, () => cancelled, options.onUpdate, false);
+ if (cancelled) return;
+ options.onComplete?.(next);
+ } catch (err) {
+ if (cancelled) return;
+ const msg = err instanceof Error ? err.message : '查找专家失败';
+ next = { ...next, status: 'error', errorMessage: msg };
+ options.onUpdate(next);
+ options.onComplete?.(next);
+ }
+ })();
+
+ return { stop, promise };
+}
+
+// ------------------------------------------------------------------ //
+// 意图编排 SSE:在六步法事件之上扩展 intent / 专家 / 澄清 / 恢复 / 重跑等 //
+// ------------------------------------------------------------------ //
+
+/**
+ * `runIntentOrchestrateStream` 请求体:与后端意图路由、断点续跑、专家上下文等字段对齐。
+ */
+export interface IntentOrchestrateRequest {
+ /** 用户当前轮输入(主问题或补充说明)。 */
+ user_input: string;
+ /** 输出形态:结构化卡片链或整段 Markdown。 */
+ output_format?: 'structured' | 'markdown';
+ /** 各 MCP 结构化项数量上限,默认 3。 */
+ max_items?: number;
+ /** 可选模型名,透传后端。 */
+ model?: string;
+ /** 意图路由策略:自动、强制六步法、强制找专家。 */
+ intent_mode?: 'auto' | 'force_orchestrate' | 'force_find_expert';
+ /** 前端会话阶段(如 idle / expert-match),供后端恢复上下文。 */
+ current_phase?: string;
+ /** 用户已选中的专家快照,可为 null。 */
+ selected_expert?: Record | null;
+ /** 用户勾选的主线 MCP 子步骤 key 列表(与编排层一致)。 */
+ selected_mcps?: string[];
+ /** 已完成的各步原始结果,用于续跑或对比。 */
+ last_steps?: Record[];
+ /** 从某后端步骤 key 恢复执行时为非 null。 */
+ resume_from_step?: string | null;
+ /** 当前轮专家候选(SSE expert_end 等回灌)。 */
+ expert_candidates?: Record